[
  {
    "path": ".agents/skills/frontend-design/LICENSE.txt",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n"
  },
  {
    "path": ".agents/skills/frontend-design/SKILL.md",
    "content": "---\nname: frontend-design\ndescription: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.\nlicense: Complete terms in LICENSE.txt\n---\n\nThis skill guides creation of distinctive, production-grade frontend interfaces that avoid generic \"AI slop\" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.\n\nThe user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.\n\n## Design Thinking\n\nBefore coding, understand the context and commit to a BOLD aesthetic direction:\n- **Purpose**: What problem does this interface solve? Who uses it?\n- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.\n- **Constraints**: Technical requirements (framework, performance, accessibility).\n- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?\n\n**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.\n\nThen implement working code (HTML/CSS/JS, React, Vue, etc.) that is:\n- Production-grade and functional\n- Visually striking and memorable\n- Cohesive with a clear aesthetic point-of-view\n- Meticulously refined in every detail\n\n## Frontend Aesthetics Guidelines\n\nFocus on:\n- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.\n- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.\n- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.\n- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.\n- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.\n\nNEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.\n\nInterpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.\n\n**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.\n\nRemember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.\n"
  },
  {
    "path": ".agents/skills/gh-cli/SKILL.md",
    "content": "---\nname: gh-cli\ndescription: GitHub CLI (gh) comprehensive reference for repositories, issues, pull requests, Actions, projects, releases, gists, codespaces, organizations, extensions, and all GitHub operations from the command line.\n---\n\n# GitHub CLI (gh)\n\nComprehensive reference for GitHub CLI (gh) - work seamlessly with GitHub from the command line.\n\n**Version:** 2.85.0 (current as of January 2026)\n\n## Prerequisites\n\n### Installation\n\n```bash\n# macOS\nbrew install gh\n\n# Linux\ncurl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg\necho \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null\nsudo apt update\nsudo apt install gh\n\n# Windows\nwinget install --id GitHub.cli\n\n# Verify installation\ngh --version\n```\n\n### Authentication\n\n```bash\n# Interactive login (default: github.com)\ngh auth login\n\n# Login with specific hostname\ngh auth login --hostname enterprise.internal\n\n# Login with token\ngh auth login --with-token < mytoken.txt\n\n# Check authentication status\ngh auth status\n\n# Switch accounts\ngh auth switch --hostname github.com --user username\n\n# Logout\ngh auth logout --hostname github.com --user username\n```\n\n### Setup Git Integration\n\n```bash\n# Configure git to use gh as credential helper\ngh auth setup-git\n\n# View active token\ngh auth token\n\n# Refresh authentication scopes\ngh auth refresh --scopes write:org,read:public_key\n```\n\n## CLI Structure\n\n```\ngh                          # Root command\n├── auth                    # Authentication\n│   ├── login\n│   ├── logout\n│   ├── refresh\n│   ├── setup-git\n│   ├── status\n│   ├── switch\n│   └── token\n├── browse                  # Open in browser\n├── codespace               # GitHub Codespaces\n│   ├── code\n│   ├── cp\n│   ├── create\n│   ├── delete\n│   ├── edit\n│   ├── jupyter\n│   ├── list\n│   ├── logs\n│   ├── ports\n│   ├── rebuild\n│   ├── ssh\n│   ├── stop\n│   └── view\n├── gist                    # Gists\n│   ├── clone\n│   ├── create\n│   ├── delete\n│   ├── edit\n│   ├── list\n│   ├── rename\n│   └── view\n├── issue                   # Issues\n│   ├── create\n│   ├── list\n│   ├── status\n│   ├── close\n│   ├── comment\n│   ├── delete\n│   ├── develop\n│   ├── edit\n│   ├── lock\n│   ├── pin\n│   ├── reopen\n│   ├── transfer\n│   ├── unlock\n│   └── view\n├── org                     # Organizations\n│   └── list\n├── pr                      # Pull Requests\n│   ├── create\n│   ├── list\n│   ├── status\n│   ├── checkout\n│   ├── checks\n│   ├── close\n│   ├── comment\n│   ├── diff\n│   ├── edit\n│   ├── lock\n│   ├── merge\n│   ├── ready\n│   ├── reopen\n│   ├── revert\n│   ├── review\n│   ├── unlock\n│   ├── update-branch\n│   └── view\n├── project                 # Projects\n│   ├── close\n│   ├── copy\n│   ├── create\n│   ├── delete\n│   ├── edit\n│   ├── field-create\n│   ├── field-delete\n│   ├── field-list\n│   ├── item-add\n│   ├── item-archive\n│   ├── item-create\n│   ├── item-delete\n│   ├── item-edit\n│   ├── item-list\n│   ├── link\n│   ├── list\n│   ├── mark-template\n│   ├── unlink\n│   └── view\n├── release                 # Releases\n│   ├── create\n│   ├── list\n│   ├── delete\n│   ├── delete-asset\n│   ├── download\n│   ├── edit\n│   ├── upload\n│   ├── verify\n│   ├── verify-asset\n│   └── view\n├── repo                    # Repositories\n│   ├── create\n│   ├── list\n│   ├── archive\n│   ├── autolink\n│   ├── clone\n│   ├── delete\n│   ├── deploy-key\n│   ├── edit\n│   ├── fork\n│   ├── gitignore\n│   ├── license\n│   ├── rename\n│   ├── set-default\n│   ├── sync\n│   ├── unarchive\n│   └── view\n├── cache                   # Actions caches\n│   ├── delete\n│   └── list\n├── run                     # Workflow runs\n│   ├── cancel\n│   ├── delete\n│   ├── download\n│   ├── list\n│   ├── rerun\n│   ├── view\n│   └── watch\n├── workflow                # Workflows\n│   ├── disable\n│   ├── enable\n│   ├── list\n│   ├── run\n│   └── view\n├── agent-task              # Agent tasks\n├── alias                   # Command aliases\n│   ├── delete\n│   ├── import\n│   ├── list\n│   └── set\n├── api                     # API requests\n├── attestation             # Artifact attestations\n│   ├── download\n│   ├── trusted-root\n│   └── verify\n├── completion              # Shell completion\n├── config                  # Configuration\n│   ├── clear-cache\n│   ├── get\n│   ├── list\n│   └── set\n├── extension               # Extensions\n│   ├── browse\n│   ├── create\n│   ├── exec\n│   ├── install\n│   ├── list\n│   ├── remove\n│   ├── search\n│   └── upgrade\n├── gpg-key                 # GPG keys\n│   ├── add\n│   ├── delete\n│   └── list\n├── label                   # Labels\n│   ├── clone\n│   ├── create\n│   ├── delete\n│   ├── edit\n│   └── list\n├── preview                 # Preview features\n├── ruleset                 # Rulesets\n│   ├── check\n│   ├── list\n│   └── view\n├── search                  # Search\n│   ├── code\n│   ├── commits\n│   ├── issues\n│   ├── prs\n│   └── repos\n├── secret                  # Secrets\n│   ├── delete\n│   ├── list\n│   └── set\n├── ssh-key                 # SSH keys\n│   ├── add\n│   ├── delete\n│   └── list\n├── status                  # Status overview\n└── variable                # Variables\n    ├── delete\n    ├── get\n    ├── list\n    └── set\n```\n\n## Configuration\n\n### Global Configuration\n\n```bash\n# List all configuration\ngh config list\n\n# Get specific configuration value\ngh config list git_protocol\ngh config get editor\n\n# Set configuration value\ngh config set editor vim\ngh config set git_protocol ssh\ngh config set prompt disabled\ngh config set pager \"less -R\"\n\n# Clear configuration cache\ngh config clear-cache\n```\n\n### Environment Variables\n\n```bash\n# GitHub token (for automation)\nexport GH_TOKEN=ghp_xxxxxxxxxxxx\n\n# GitHub hostname\nexport GH_HOST=github.com\n\n# Disable prompts\nexport GH_PROMPT_DISABLED=true\n\n# Custom editor\nexport GH_EDITOR=vim\n\n# Custom pager\nexport GH_PAGER=less\n\n# HTTP timeout\nexport GH_TIMEOUT=30\n\n# Custom repository (override default)\nexport GH_REPO=owner/repo\n\n# Custom git protocol\nexport GH_ENTERPRISE_HOSTNAME=hostname\n```\n\n## Authentication (gh auth)\n\n### Login\n\n```bash\n# Interactive login\ngh auth login\n\n# Web-based authentication\ngh auth login --web\n\n# With clipboard for OAuth code\ngh auth login --web --clipboard\n\n# With specific git protocol\ngh auth login --git-protocol ssh\n\n# With custom hostname (GitHub Enterprise)\ngh auth login --hostname enterprise.internal\n\n# Login with token from stdin\ngh auth login --with-token < token.txt\n\n# Insecure storage (plain text)\ngh auth login --insecure-storage\n```\n\n### Status\n\n```bash\n# Show all authentication status\ngh auth status\n\n# Show active account only\ngh auth status --active\n\n# Show specific hostname\ngh auth status --hostname github.com\n\n# Show token in output\ngh auth status --show-token\n\n# JSON output\ngh auth status --json hosts\n\n# Filter with jq\ngh auth status --json hosts --jq '.hosts | add'\n```\n\n### Switch Accounts\n\n```bash\n# Interactive switch\ngh auth switch\n\n# Switch to specific user/host\ngh auth switch --hostname github.com --user monalisa\n```\n\n### Token\n\n```bash\n# Print authentication token\ngh auth token\n\n# Token for specific host/user\ngh auth token --hostname github.com --user monalisa\n```\n\n### Refresh\n\n```bash\n# Refresh credentials\ngh auth refresh\n\n# Add scopes\ngh auth refresh --scopes write:org,read:public_key\n\n# Remove scopes\ngh auth refresh --remove-scopes delete_repo\n\n# Reset to default scopes\ngh auth refresh --reset-scopes\n\n# With clipboard\ngh auth refresh --clipboard\n```\n\n### Setup Git\n\n```bash\n# Setup git credential helper\ngh auth setup-git\n\n# Setup for specific host\ngh auth setup-git --hostname enterprise.internal\n\n# Force setup even if host not known\ngh auth setup-git --hostname enterprise.internal --force\n```\n\n## Browse (gh browse)\n\n```bash\n# Open repository in browser\ngh browse\n\n# Open specific path\ngh browse script/\ngh browse main.go:312\n\n# Open issue or PR\ngh browse 123\n\n# Open commit\ngh browse 77507cd94ccafcf568f8560cfecde965fcfa63\n\n# Open with specific branch\ngh browse main.go --branch bug-fix\n\n# Open different repository\ngh browse --repo owner/repo\n\n# Open specific pages\ngh browse --actions       # Actions tab\ngh browse --projects      # Projects tab\ngh browse --releases      # Releases tab\ngh browse --settings      # Settings page\ngh browse --wiki          # Wiki page\n\n# Print URL instead of opening\ngh browse --no-browser\n```\n\n## Repositories (gh repo)\n\n### Create Repository\n\n```bash\n# Create new repository\ngh repo create my-repo\n\n# Create with description\ngh repo create my-repo --description \"My awesome project\"\n\n# Create public repository\ngh repo create my-repo --public\n\n# Create private repository\ngh repo create my-repo --private\n\n# Create with homepage\ngh repo create my-repo --homepage https://example.com\n\n# Create with license\ngh repo create my-repo --license mit\n\n# Create with gitignore\ngh repo create my-repo --gitignore python\n\n# Initialize as template repository\ngh repo create my-repo --template\n\n# Create repository in organization\ngh repo create org/my-repo\n\n# Create without cloning locally\ngh repo create my-repo --source=.\n\n# Disable issues\ngh repo create my-repo --disable-issues\n\n# Disable wiki\ngh repo create my-repo --disable-wiki\n```\n\n### Clone Repository\n\n```bash\n# Clone repository\ngh repo clone owner/repo\n\n# Clone to specific directory\ngh repo clone owner/repo my-directory\n\n# Clone with different branch\ngh repo clone owner/repo --branch develop\n```\n\n### List Repositories\n\n```bash\n# List all repositories\ngh repo list\n\n# List repositories for owner\ngh repo list owner\n\n# Limit results\ngh repo list --limit 50\n\n# Public repositories only\ngh repo list --public\n\n# Source repositories only (not forks)\ngh repo list --source\n\n# JSON output\ngh repo list --json name,visibility,owner\n\n# Table output\ngh repo list --limit 100 | tail -n +2\n\n# Filter with jq\ngh repo list --json name --jq '.[].name'\n```\n\n### View Repository\n\n```bash\n# View repository details\ngh repo view\n\n# View specific repository\ngh repo view owner/repo\n\n# JSON output\ngh repo view --json name,description,defaultBranchRef\n\n# View in browser\ngh repo view --web\n```\n\n### Edit Repository\n\n```bash\n# Edit description\ngh repo edit --description \"New description\"\n\n# Set homepage\ngh repo edit --homepage https://example.com\n\n# Change visibility\ngh repo edit --visibility private\ngh repo edit --visibility public\n\n# Enable/disable features\ngh repo edit --enable-issues\ngh repo edit --disable-issues\ngh repo edit --enable-wiki\ngh repo edit --disable-wiki\ngh repo edit --enable-projects\ngh repo edit --disable-projects\n\n# Set default branch\ngh repo edit --default-branch main\n\n# Rename repository\ngh repo rename new-name\n\n# Archive repository\ngh repo archive\ngh repo unarchive\n```\n\n### Delete Repository\n\n```bash\n# Delete repository\ngh repo delete owner/repo\n\n# Confirm without prompt\ngh repo delete owner/repo --yes\n```\n\n### Fork Repository\n\n```bash\n# Fork repository\ngh repo fork owner/repo\n\n# Fork to organization\ngh repo fork owner/repo --org org-name\n\n# Clone after forking\ngh repo fork owner/repo --clone\n\n# Remote name for fork\ngh repo fork owner/repo --remote-name upstream\n```\n\n### Sync Fork\n\n```bash\n# Sync fork with upstream\ngh repo sync\n\n# Sync specific branch\ngh repo sync --branch feature\n\n# Force sync\ngh repo sync --force\n```\n\n### Set Default Repository\n\n```bash\n# Set default repository for current directory\ngh repo set-default\n\n# Set default explicitly\ngh repo set-default owner/repo\n\n# Unset default\ngh repo set-default --unset\n```\n\n### Repository Autolinks\n\n```bash\n# List autolinks\ngh repo autolink list\n\n# Add autolink\ngh repo autolink add \\\n  --key-prefix JIRA- \\\n  --url-template https://jira.example.com/browse/<num>\n\n# Delete autolink\ngh repo autolink delete 12345\n```\n\n### Repository Deploy Keys\n\n```bash\n# List deploy keys\ngh repo deploy-key list\n\n# Add deploy key\ngh repo deploy-key add ~/.ssh/id_rsa.pub \\\n  --title \"Production server\" \\\n  --read-only\n\n# Delete deploy key\ngh repo deploy-key delete 12345\n```\n\n### Gitignore and License\n\n```bash\n# View gitignore template\ngh repo gitignore\n\n# View license template\ngh repo license mit\n\n# License with full name\ngh repo license mit --fullname \"John Doe\"\n```\n\n## Issues (gh issue)\n\n### Create Issue\n\n```bash\n# Create issue interactively\ngh issue create\n\n# Create with title\ngh issue create --title \"Bug: Login not working\"\n\n# Create with title and body\ngh issue create \\\n  --title \"Bug: Login not working\" \\\n  --body \"Steps to reproduce...\"\n\n# Create with body from file\ngh issue create --body-file issue.md\n\n# Create with labels\ngh issue create --title \"Fix bug\" --labels bug,high-priority\n\n# Create with assignees\ngh issue create --title \"Fix bug\" --assignee user1,user2\n\n# Create in specific repository\ngh issue create --repo owner/repo --title \"Issue title\"\n\n# Create issue from web\ngh issue create --web\n```\n\n### List Issues\n\n```bash\n# List all open issues\ngh issue list\n\n# List all issues (including closed)\ngh issue list --state all\n\n# List closed issues\ngh issue list --state closed\n\n# Limit results\ngh issue list --limit 50\n\n# Filter by assignee\ngh issue list --assignee username\ngh issue list --assignee @me\n\n# Filter by labels\ngh issue list --labels bug,enhancement\n\n# Filter by milestone\ngh issue list --milestone \"v1.0\"\n\n# Search/filter\ngh issue list --search \"is:open is:issue label:bug\"\n\n# JSON output\ngh issue list --json number,title,state,author\n\n# Table view\ngh issue list --json number,title,labels --jq '.[] | [.number, .title, .labels[].name] | @tsv'\n\n# Show comments count\ngh issue list --json number,title,comments --jq '.[] | [.number, .title, .comments]'\n\n# Sort by\ngh issue list --sort created --order desc\n```\n\n### View Issue\n\n```bash\n# View issue\ngh issue view 123\n\n# View with comments\ngh issue view 123 --comments\n\n# View in browser\ngh issue view 123 --web\n\n# JSON output\ngh issue view 123 --json title,body,state,labels,comments\n\n# View specific fields\ngh issue view 123 --json title --jq '.title'\n```\n\n### Edit Issue\n\n```bash\n# Edit interactively\ngh issue edit 123\n\n# Edit title\ngh issue edit 123 --title \"New title\"\n\n# Edit body\ngh issue edit 123 --body \"New description\"\n\n# Add labels\ngh issue edit 123 --add-label bug,high-priority\n\n# Remove labels\ngh issue edit 123 --remove-label stale\n\n# Add assignees\ngh issue edit 123 --add-assignee user1,user2\n\n# Remove assignees\ngh issue edit 123 --remove-assignee user1\n\n# Set milestone\ngh issue edit 123 --milestone \"v1.0\"\n```\n\n### Close/Reopen Issue\n\n```bash\n# Close issue\ngh issue close 123\n\n# Close with comment\ngh issue close 123 --comment \"Fixed in PR #456\"\n\n# Reopen issue\ngh issue reopen 123\n```\n\n### Comment on Issue\n\n```bash\n# Add comment\ngh issue comment 123 --body \"This looks good!\"\n\n# Edit comment\ngh issue comment 123 --edit 456789 --body \"Updated comment\"\n\n# Delete comment\ngh issue comment 123 --delete 456789\n```\n\n### Issue Status\n\n```bash\n# Show issue status summary\ngh issue status\n\n# Status for specific repository\ngh issue status --repo owner/repo\n```\n\n### Pin/Unpin Issues\n\n```bash\n# Pin issue (pinned to repo dashboard)\ngh issue pin 123\n\n# Unpin issue\ngh issue unpin 123\n```\n\n### Lock/Unlock Issue\n\n```bash\n# Lock conversation\ngh issue lock 123\n\n# Lock with reason\ngh issue lock 123 --reason off-topic\n\n# Unlock\ngh issue unlock 123\n```\n\n### Transfer Issue\n\n```bash\n# Transfer to another repository\ngh issue transfer 123 --repo owner/new-repo\n```\n\n### Delete Issue\n\n```bash\n# Delete issue\ngh issue delete 123\n\n# Confirm without prompt\ngh issue delete 123 --yes\n```\n\n### Develop Issue (Draft PR)\n\n```bash\n# Create draft PR from issue\ngh issue develop 123\n\n# Create in specific branch\ngh issue develop 123 --branch fix/issue-123\n\n# Create with base branch\ngh issue develop 123 --base main\n```\n\n## Pull Requests (gh pr)\n\n### Create Pull Request\n\n```bash\n# Create PR interactively\ngh pr create\n\n# Create with title\ngh pr create --title \"Feature: Add new functionality\"\n\n# Create with title and body\ngh pr create \\\n  --title \"Feature: Add new functionality\" \\\n  --body \"This PR adds...\"\n\n# Fill body from template\ngh pr create --body-file .github/PULL_REQUEST_TEMPLATE.md\n\n# Set base branch\ngh pr create --base main\n\n# Set head branch (default: current branch)\ngh pr create --head feature-branch\n\n# Create draft PR\ngh pr create --draft\n\n# Add assignees\ngh pr create --assignee user1,user2\n\n# Add reviewers\ngh pr create --reviewer user1,user2\n\n# Add labels\ngh pr create --labels enhancement,feature\n\n# Link to issue\ngh pr create --issue 123\n\n# Create in specific repository\ngh pr create --repo owner/repo\n\n# Open in browser after creation\ngh pr create --web\n```\n\n### List Pull Requests\n\n```bash\n# List open PRs\ngh pr list\n\n# List all PRs\ngh pr list --state all\n\n# List merged PRs\ngh pr list --state merged\n\n# List closed (not merged) PRs\ngh pr list --state closed\n\n# Filter by head branch\ngh pr list --head feature-branch\n\n# Filter by base branch\ngh pr list --base main\n\n# Filter by author\ngh pr list --author username\ngh pr list --author @me\n\n# Filter by assignee\ngh pr list --assignee username\n\n# Filter by labels\ngh pr list --labels bug,enhancement\n\n# Limit results\ngh pr list --limit 50\n\n# Search\ngh pr list --search \"is:open is:pr label:review-required\"\n\n# JSON output\ngh pr list --json number,title,state,author,headRefName\n\n# Show check status\ngh pr list --json number,title,statusCheckRollup --jq '.[] | [.number, .title, .statusCheckRollup[]?.status]'\n\n# Sort by\ngh pr list --sort created --order desc\n```\n\n### View Pull Request\n\n```bash\n# View PR\ngh pr view 123\n\n# View with comments\ngh pr view 123 --comments\n\n# View in browser\ngh pr view 123 --web\n\n# JSON output\ngh pr view 123 --json title,body,state,author,commits,files\n\n# View diff\ngh pr view 123 --json files --jq '.files[].path'\n\n# View with jq query\ngh pr view 123 --json title,state --jq '\"\\(.title): \\(.state)\"'\n```\n\n### Checkout Pull Request\n\n```bash\n# Checkout PR branch\ngh pr checkout 123\n\n# Checkout with specific branch name\ngh pr checkout 123 --branch name-123\n\n# Force checkout\ngh pr checkout 123 --force\n```\n\n### Diff Pull Request\n\n```bash\n# View PR diff\ngh pr diff 123\n\n# View diff with color\ngh pr diff 123 --color always\n\n# Output to file\ngh pr diff 123 > pr-123.patch\n\n# View diff of specific files\ngh pr diff 123 --name-only\n```\n\n### Merge Pull Request\n\n```bash\n# Merge PR\ngh pr merge 123\n\n# Merge with specific method\ngh pr merge 123 --merge\ngh pr merge 123 --squash\ngh pr merge 123 --rebase\n\n# Delete branch after merge\ngh pr merge 123 --delete-branch\n\n# Merge with comment\ngh pr merge 123 --subject \"Merge PR #123\" --body \"Merging feature\"\n\n# Merge draft PR\ngh pr merge 123 --admin\n\n# Force merge (skip checks)\ngh pr merge 123 --admin\n```\n\n### Close Pull Request\n\n```bash\n# Close PR (as draft, not merge)\ngh pr close 123\n\n# Close with comment\ngh pr close 123 --comment \"Closing due to...\"\n```\n\n### Reopen Pull Request\n\n```bash\n# Reopen closed PR\ngh pr reopen 123\n```\n\n### Edit Pull Request\n\n```bash\n# Edit interactively\ngh pr edit 123\n\n# Edit title\ngh pr edit 123 --title \"New title\"\n\n# Edit body\ngh pr edit 123 --body \"New description\"\n\n# Add labels\ngh pr edit 123 --add-label bug,enhancement\n\n# Remove labels\ngh pr edit 123 --remove-label stale\n\n# Add assignees\ngh pr edit 123 --add-assignee user1,user2\n\n# Remove assignees\ngh pr edit 123 --remove-assignee user1\n\n# Add reviewers\ngh pr edit 123 --add-reviewer user1,user2\n\n# Remove reviewers\ngh pr edit 123 --remove-reviewer user1\n\n# Mark as ready for review\ngh pr edit 123 --ready\n```\n\n### Ready for Review\n\n```bash\n# Mark draft PR as ready\ngh pr ready 123\n```\n\n### Pull Request Checks\n\n```bash\n# View PR checks\ngh pr checks 123\n\n# Watch checks in real-time\ngh pr checks 123 --watch\n\n# Watch interval (seconds)\ngh pr checks 123 --watch --interval 5\n```\n\n### Comment on Pull Request\n\n```bash\n# Add comment\ngh pr comment 123 --body \"Looks good!\"\n\n# Comment on specific line\ngh pr comment 123 --body \"Fix this\" \\\n  --repo owner/repo \\\n  --head-owner owner --head-branch feature\n\n# Edit comment\ngh pr comment 123 --edit 456789 --body \"Updated\"\n\n# Delete comment\ngh pr comment 123 --delete 456789\n```\n\n### Review Pull Request\n\n```bash\n# Review PR (opens editor)\ngh pr review 123\n\n# Approve PR\ngh pr review 123 --approve --body \"LGTM!\"\n\n# Request changes\ngh pr review 123 --request-changes \\\n  --body \"Please fix these issues\"\n\n# Comment on PR\ngh pr review 123 --comment --body \"Some thoughts...\"\n\n# Dismiss review\ngh pr review 123 --dismiss\n```\n\n### Update Branch\n\n```bash\n# Update PR branch with latest base branch\ngh pr update-branch 123\n\n# Force update\ngh pr update-branch 123 --force\n\n# Use merge strategy\ngh pr update-branch 123 --merge\n```\n\n### Lock/Unlock Pull Request\n\n```bash\n# Lock PR conversation\ngh pr lock 123\n\n# Lock with reason\ngh pr lock 123 --reason off-topic\n\n# Unlock\ngh pr unlock 123\n```\n\n### Revert Pull Request\n\n```bash\n# Revert merged PR\ngh pr revert 123\n\n# Revert with specific branch name\ngh pr revert 123 --branch revert-pr-123\n```\n\n### Pull Request Status\n\n```bash\n# Show PR status summary\ngh pr status\n\n# Status for specific repository\ngh pr status --repo owner/repo\n```\n\n## GitHub Actions\n\n### Workflow Runs (gh run)\n\n```bash\n# List workflow runs\ngh run list\n\n# List for specific workflow\ngh run list --workflow \"ci.yml\"\n\n# List for specific branch\ngh run list --branch main\n\n# Limit results\ngh run list --limit 20\n\n# JSON output\ngh run list --json databaseId,status,conclusion,headBranch\n\n# View run details\ngh run view 123456789\n\n# View run with verbose logs\ngh run view 123456789 --log\n\n# View specific job\ngh run view 123456789 --job 987654321\n\n# View in browser\ngh run view 123456789 --web\n\n# Watch run in real-time\ngh run watch 123456789\n\n# Watch with interval\ngh run watch 123456789 --interval 5\n\n# Rerun failed run\ngh run rerun 123456789\n\n# Rerun specific job\ngh run rerun 123456789 --job 987654321\n\n# Cancel run\ngh run cancel 123456789\n\n# Delete run\ngh run delete 123456789\n\n# Download run artifacts\ngh run download 123456789\n\n# Download specific artifact\ngh run download 123456789 --name build\n\n# Download to directory\ngh run download 123456789 --dir ./artifacts\n```\n\n### Workflows (gh workflow)\n\n```bash\n# List workflows\ngh workflow list\n\n# View workflow details\ngh workflow view ci.yml\n\n# View workflow YAML\ngh workflow view ci.yml --yaml\n\n# View in browser\ngh workflow view ci.yml --web\n\n# Enable workflow\ngh workflow enable ci.yml\n\n# Disable workflow\ngh workflow disable ci.yml\n\n# Run workflow manually\ngh workflow run ci.yml\n\n# Run with inputs\ngh workflow run ci.yml \\\n  --raw-field \\\n  version=\"1.0.0\" \\\n  environment=\"production\"\n\n# Run from specific branch\ngh workflow run ci.yml --ref develop\n```\n\n### Action Caches (gh cache)\n\n```bash\n# List caches\ngh cache list\n\n# List for specific branch\ngh cache list --branch main\n\n# List with limit\ngh cache list --limit 50\n\n# Delete cache\ngh cache delete 123456789\n\n# Delete all caches\ngh cache delete --all\n```\n\n### Action Secrets (gh secret)\n\n```bash\n# List secrets\ngh secret list\n\n# Set secret (prompts for value)\ngh secret set MY_SECRET\n\n# Set secret from environment\necho \"$MY_SECRET\" | gh secret set MY_SECRET\n\n# Set secret for specific environment\ngh secret set MY_SECRET --env production\n\n# Set secret for organization\ngh secret set MY_SECRET --org orgname\n\n# Delete secret\ngh secret delete MY_SECRET\n\n# Delete from environment\ngh secret delete MY_SECRET --env production\n```\n\n### Action Variables (gh variable)\n\n```bash\n# List variables\ngh variable list\n\n# Set variable\ngh variable set MY_VAR \"some-value\"\n\n# Set variable for environment\ngh variable set MY_VAR \"value\" --env production\n\n# Set variable for organization\ngh variable set MY_VAR \"value\" --org orgname\n\n# Get variable value\ngh variable get MY_VAR\n\n# Delete variable\ngh variable delete MY_VAR\n\n# Delete from environment\ngh variable delete MY_VAR --env production\n```\n\n## Projects (gh project)\n\n```bash\n# List projects\ngh project list\n\n# List for owner\ngh project list --owner owner\n\n# Open projects\ngh project list --open\n\n# View project\ngh project view 123\n\n# View project items\ngh project view 123 --format json\n\n# Create project\ngh project create --title \"My Project\"\n\n# Create in organization\ngh project create --title \"Project\" --org orgname\n\n# Create with readme\ngh project create --title \"Project\" --readme \"Description here\"\n\n# Edit project\ngh project edit 123 --title \"New Title\"\n\n# Delete project\ngh project delete 123\n\n# Close project\ngh project close 123\n\n# Copy project\ngh project copy 123 --owner target-owner --title \"Copy\"\n\n# Mark template\ngh project mark-template 123\n\n# List fields\ngh project field-list 123\n\n# Create field\ngh project field-create 123 --title \"Status\" --datatype single_select\n\n# Delete field\ngh project field-delete 123 --id 456\n\n# List items\ngh project item-list 123\n\n# Create item\ngh project item-create 123 --title \"New item\"\n\n# Add item to project\ngh project item-add 123 --owner-owner --repo repo --issue 456\n\n# Edit item\ngh project item-edit 123 --id 456 --title \"Updated title\"\n\n# Delete item\ngh project item-delete 123 --id 456\n\n# Archive item\ngh project item-archive 123 --id 456\n\n# Link items\ngh project link 123 --id 456 --link-id 789\n\n# Unlink items\ngh project unlink 123 --id 456 --link-id 789\n\n# View project in browser\ngh project view 123 --web\n```\n\n## Releases (gh release)\n\n```bash\n# List releases\ngh release list\n\n# View latest release\ngh release view\n\n# View specific release\ngh release view v1.0.0\n\n# View in browser\ngh release view v1.0.0 --web\n\n# Create release\ngh release create v1.0.0 \\\n  --notes \"Release notes here\"\n\n# Create release with notes from file\ngh release create v1.0.0 --notes-file notes.md\n\n# Create release with target\ngh release create v1.0.0 --target main\n\n# Create release as draft\ngh release create v1.0.0 --draft\n\n# Create pre-release\ngh release create v1.0.0 --prerelease\n\n# Create release with title\ngh release create v1.0.0 --title \"Version 1.0.0\"\n\n# Upload asset to release\ngh release upload v1.0.0 ./file.tar.gz\n\n# Upload multiple assets\ngh release upload v1.0.0 ./file1.tar.gz ./file2.tar.gz\n\n# Upload with label (casing sensitive)\ngh release upload v1.0.0 ./file.tar.gz --casing\n\n# Delete release\ngh release delete v1.0.0\n\n# Delete with cleanup tag\ngh release delete v1.0.0 --yes\n\n# Delete specific asset\ngh release delete-asset v1.0.0 file.tar.gz\n\n# Download release assets\ngh release download v1.0.0\n\n# Download specific asset\ngh release download v1.0.0 --pattern \"*.tar.gz\"\n\n# Download to directory\ngh release download v1.0.0 --dir ./downloads\n\n# Download archive (zip/tar)\ngh release download v1.0.0 --archive zip\n\n# Edit release\ngh release edit v1.0.0 --notes \"Updated notes\"\n\n# Verify release signature\ngh release verify v1.0.0\n\n# Verify specific asset\ngh release verify-asset v1.0.0 file.tar.gz\n```\n\n## Gists (gh gist)\n\n```bash\n# List gists\ngh gist list\n\n# List all gists (including private)\ngh gist list --public\n\n# Limit results\ngh gist list --limit 20\n\n# View gist\ngh gist view abc123\n\n# View gist files\ngh gist view abc123 --files\n\n# Create gist\ngh gist create script.py\n\n# Create gist with description\ngh gist create script.py --desc \"My script\"\n\n# Create public gist\ngh gist create script.py --public\n\n# Create multi-file gist\ngh gist create file1.py file2.py\n\n# Create from stdin\necho \"print('hello')\" | gh gist create\n\n# Edit gist\ngh gist edit abc123\n\n# Delete gist\ngh gist delete abc123\n\n# Rename gist file\ngh gist rename abc123 --filename old.py new.py\n\n# Clone gist\ngh gist clone abc123\n\n# Clone to directory\ngh gist clone abc123 my-directory\n```\n\n## Codespaces (gh codespace)\n\n```bash\n# List codespaces\ngh codespace list\n\n# Create codespace\ngh codespace create\n\n# Create with specific repository\ngh codespace create --repo owner/repo\n\n# Create with branch\ngh codespace create --branch develop\n\n# Create with specific machine\ngh codespace create --machine premiumLinux\n\n# View codespace details\ngh codespace view\n\n# SSH into codespace\ngh codespace ssh\n\n# SSH with specific command\ngh codespace ssh --command \"cd /workspaces && ls\"\n\n# Open codespace in browser\ngh codespace code\n\n# Open in VS Code\ngh codespace code --codec\n\n# Open with specific path\ngh codespace code --path /workspaces/repo\n\n# Stop codespace\ngh codespace stop\n\n# Delete codespace\ngh codespace delete\n\n# View logs\ngh codespace logs\n\n--tail 100\n\n# View ports\ngh codespace ports\n\n# Forward port\ngh codespace cp 8080:8080\n\n# Rebuild codespace\ngh codespace rebuild\n\n# Edit codespace\ngh codespace edit --machine standardLinux\n\n# Jupyter support\ngh codespace jupyter\n\n# Copy files to/from codespace\ngh codespace cp file.txt :/workspaces/file.txt\ngh codespace cp :/workspaces/file.txt ./file.txt\n```\n\n## Organizations (gh org)\n\n```bash\n# List organizations\ngh org list\n\n# List for user\ngh org list --user username\n\n# JSON output\ngh org list --json login,name,description\n\n# View organization\ngh org view orgname\n\n# View organization members\ngh org view orgname --json members --jq '.members[] | .login'\n```\n\n## Search (gh search)\n\n```bash\n# Search code\ngh search code \"TODO\"\n\n# Search in specific repository\ngh search code \"TODO\" --repo owner/repo\n\n# Search commits\ngh search commits \"fix bug\"\n\n# Search issues\ngh search issues \"label:bug state:open\"\n\n# Search PRs\ngh search prs \"is:open is:pr review:required\"\n\n# Search repositories\ngh search repos \"stars:>1000 language:python\"\n\n# Limit results\ngh search repos \"topic:api\" --limit 50\n\n# JSON output\ngh search repos \"stars:>100\" --json name,description,stargazers\n\n# Order results\ngh search repos \"language:rust\" --order desc --sort stars\n\n# Search with extensions\ngh search code \"import\" --extension py\n\n# Web search (open in browser)\ngh search prs \"is:open\" --web\n```\n\n## Labels (gh label)\n\n```bash\n# List labels\ngh label list\n\n# Create label\ngh label create bug --color \"d73a4a\" --description \"Something isn't working\"\n\n# Create with hex color\ngh label create enhancement --color \"#a2eeef\"\n\n# Edit label\ngh label edit bug --name \"bug-report\" --color \"ff0000\"\n\n# Delete label\ngh label delete bug\n\n# Clone labels from repository\ngh label clone owner/repo\n\n# Clone to specific repository\ngh label clone owner/repo --repo target/repo\n```\n\n## SSH Keys (gh ssh-key)\n\n```bash\n# List SSH keys\ngh ssh-key list\n\n# Add SSH key\ngh ssh-key add ~/.ssh/id_rsa.pub --title \"My laptop\"\n\n# Add key with type\ngh ssh-key add ~/.ssh/id_ed25519.pub --type \"authentication\"\n\n# Delete SSH key\ngh ssh-key delete 12345\n\n# Delete by title\ngh ssh-key delete --title \"My laptop\"\n```\n\n## GPG Keys (gh gpg-key)\n\n```bash\n# List GPG keys\ngh gpg-key list\n\n# Add GPG key\ngh gpg-key add ~/.ssh/id_rsa.pub\n\n# Delete GPG key\ngh gpg-key delete 12345\n\n# Delete by key ID\ngh gpg-key delete ABCD1234\n```\n\n## Status (gh status)\n\n```bash\n# Show status overview\ngh status\n\n# Status for specific repositories\ngh status --repo owner/repo\n\n# JSON output\ngh status --json\n```\n\n## Configuration (gh config)\n\n```bash\n# List all config\ngh config list\n\n# Get specific value\ngh config get editor\n\n# Set value\ngh config set editor vim\n\n# Set git protocol\ngh config set git_protocol ssh\n\n# Clear cache\ngh config clear-cache\n\n# Set prompt behavior\ngh config set prompt disabled\ngh config set prompt enabled\n```\n\n## Extensions (gh extension)\n\n```bash\n# List installed extensions\ngh extension list\n\n# Search extensions\ngh extension search github\n\n# Install extension\ngh extension install owner/extension-repo\n\n# Install from branch\ngh extension install owner/extension-repo --branch develop\n\n# Upgrade extension\ngh extension upgrade extension-name\n\n# Remove extension\ngh extension remove extension-name\n\n# Create new extension\ngh extension create my-extension\n\n# Browse extensions\ngh extension browse\n\n# Execute extension command\ngh extension exec my-extension --arg value\n```\n\n## Aliases (gh alias)\n\n```bash\n# List aliases\ngh alias list\n\n# Set alias\ngh alias set prview 'pr view --web'\n\n# Set shell alias\ngh alias set co 'pr checkout' --shell\n\n# Delete alias\ngh alias delete prview\n\n# Import aliases\ngh alias import ./aliases.sh\n```\n\n## API Requests (gh api)\n\n```bash\n# Make API request\ngh api /user\n\n# Request with method\ngh api --method POST /repos/owner/repo/issues \\\n  --field title=\"Issue title\" \\\n  --field body=\"Issue body\"\n\n# Request with headers\ngh api /user \\\n  --header \"Accept: application/vnd.github.v3+json\"\n\n# Request with pagination\ngh api /user/repos --paginate\n\n# Raw output (no formatting)\ngh api /user --raw\n\n# Include headers in output\ngh api /user --include\n\n# Silent mode (no progress output)\ngh api /user --silent\n\n# Input from file\ngh api --input request.json\n\n# jq query on response\ngh api /user --jq '.login'\n\n# Field from response\ngh api /repos/owner/repo --jq '.stargazers_count'\n\n# GitHub Enterprise\ngh api /user --hostname enterprise.internal\n\n# GraphQL query\ngh api graphql \\\n  -f query='\n  {\n    viewer {\n      login\n      repositories(first: 5) {\n        nodes {\n          name\n        }\n      }\n    }\n  }'\n```\n\n## Rulesets (gh ruleset)\n\n```bash\n# List rulesets\ngh ruleset list\n\n# View ruleset\ngh ruleset view 123\n\n# Check ruleset\ngh ruleset check --branch feature\n\n# Check specific repository\ngh ruleset check --repo owner/repo --branch main\n```\n\n## Attestations (gh attestation)\n\n```bash\n# Download attestation\ngh attestation download owner/repo \\\n  --artifact-id 123456\n\n# Verify attestation\ngh attestation verify owner/repo\n\n# Get trusted root\ngh attestation trusted-root\n```\n\n## Completion (gh completion)\n\n```bash\n# Generate shell completion\ngh completion -s bash > ~/.gh-complete.bash\ngh completion -s zsh > ~/.gh-complete.zsh\ngh completion -s fish > ~/.gh-complete.fish\ngh completion -s powershell > ~/.gh-complete.ps1\n\n# Shell-specific instructions\ngh completion --shell=bash\ngh completion --shell=zsh\n```\n\n## Preview (gh preview)\n\n```bash\n# List preview features\ngh preview\n\n# Run preview script\ngh preview prompter\n```\n\n## Agent Tasks (gh agent-task)\n\n```bash\n# List agent tasks\ngh agent-task list\n\n# View agent task\ngh agent-task view 123\n\n# Create agent task\ngh agent-task create --description \"My task\"\n```\n\n## Global Flags\n\n| Flag                       | Description                            |\n| -------------------------- | -------------------------------------- |\n| `--help` / `-h`            | Show help for command                  |\n| `--version`                | Show gh version                        |\n| `--repo [HOST/]OWNER/REPO` | Select another repository              |\n| `--hostname HOST`          | GitHub hostname                        |\n| `--jq EXPRESSION`          | Filter JSON output                     |\n| `--json FIELDS`            | Output JSON with specified fields      |\n| `--template STRING`        | Format JSON using Go template          |\n| `--web`                    | Open in browser                        |\n| `--paginate`               | Make additional API calls              |\n| `--verbose`                | Show verbose output                    |\n| `--debug`                  | Show debug output                      |\n| `--timeout SECONDS`        | Maximum API request duration           |\n| `--cache CACHE`            | Cache control (default, force, bypass) |\n\n## Output Formatting\n\n### JSON Output\n\n```bash\n# Basic JSON\ngh repo view --json name,description\n\n# Nested fields\ngh repo view --json owner,name --jq '.owner.login + \"/\" + .name'\n\n# Array operations\ngh pr list --json number,title --jq '.[] | select(.number > 100)'\n\n# Complex queries\ngh issue list --json number,title,labels \\\n  --jq '.[] | {number, title: .title, tags: [.labels[].name]}'\n```\n\n### Template Output\n\n```bash\n# Custom template\ngh repo view \\\n  --template '{{.name}}: {{.description}}'\n\n# Multiline template\ngh pr view 123 \\\n  --template 'Title: {{.title}}\nAuthor: {{.author.login}}\nState: {{.state}}\n'\n```\n\n## Common Workflows\n\n### Create PR from Issue\n\n```bash\n# Create branch from issue\ngh issue develop 123 --branch feature/issue-123\n\n# Make changes, commit, push\ngit add .\ngit commit -m \"Fix issue #123\"\ngit push\n\n# Create PR linking to issue\ngh pr create --title \"Fix #123\" --body \"Closes #123\"\n```\n\n### Bulk Operations\n\n```bash\n# Close multiple issues\ngh issue list --search \"label:stale\" \\\n  --json number \\\n  --jq '.[].number' | \\\n  xargs -I {} gh issue close {} --comment \"Closing as stale\"\n\n# Add label to multiple PRs\ngh pr list --search \"review:required\" \\\n  --json number \\\n  --jq '.[].number' | \\\n  xargs -I {} gh pr edit {} --add-label needs-review\n```\n\n### Repository Setup Workflow\n\n```bash\n# Create repository with initial setup\ngh repo create my-project --public \\\n  --description \"My awesome project\" \\\n  --clone \\\n  --gitignore python \\\n  --license mit\n\ncd my-project\n\n# Set up branches\ngit checkout -b develop\ngit push -u origin develop\n\n# Create labels\ngh label create bug --color \"d73a4a\" --description \"Bug report\"\ngh label create enhancement --color \"a2eeef\" --description \"Feature request\"\ngh label create documentation --color \"0075ca\" --description \"Documentation\"\n```\n\n### CI/CD Workflow\n\n```bash\n# Run workflow and wait\nRUN_ID=$(gh workflow run ci.yml --ref main --jq '.databaseId')\n\n# Watch the run\ngh run watch \"$RUN_ID\"\n\n# Download artifacts on completion\ngh run download \"$RUN_ID\" --dir ./artifacts\n```\n\n### Fork Sync Workflow\n\n```bash\n# Fork repository\ngh repo fork original/repo --clone\n\ncd repo\n\n# Add upstream remote\ngit remote add upstream https://github.com/original/repo.git\n\n# Sync fork\ngh repo sync\n\n# Or manual sync\ngit fetch upstream\ngit checkout main\ngit merge upstream/main\ngit push origin main\n```\n\n## Environment Setup\n\n### Shell Integration\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc\neval \"$(gh completion -s bash)\"  # or zsh/fish\n\n# Create useful aliases\nalias gs='gh status'\nalias gpr='gh pr view --web'\nalias gir='gh issue view --web'\nalias gco='gh pr checkout'\n```\n\n### Git Configuration\n\n```bash\n# Use gh as credential helper\ngh auth setup-git\n\n# Set gh as default for repo operations\ngit config --global credential.helper 'gh !gh auth setup-git'\n\n# Or manually\ngit config --global credential.helper github\n```\n\n## Best Practices\n\n1. **Authentication**: Use environment variables for automation\n\n   ```bash\n   export GH_TOKEN=$(gh auth token)\n   ```\n\n2. **Default Repository**: Set default to avoid repetition\n\n   ```bash\n   gh repo set-default owner/repo\n   ```\n\n3. **JSON Parsing**: Use jq for complex data extraction\n\n   ```bash\n   gh pr list --json number,title --jq '.[] | select(.title | contains(\"fix\"))'\n   ```\n\n4. **Pagination**: Use --paginate for large result sets\n\n   ```bash\n   gh issue list --state all --paginate\n   ```\n\n5. **Caching**: Use cache control for frequently accessed data\n   ```bash\n   gh api /user --cache force\n   ```\n\n## Getting Help\n\n```bash\n# General help\ngh --help\n\n# Command help\ngh pr --help\ngh issue create --help\n\n# Help topics\ngh help formatting\ngh help environment\ngh help exit-codes\ngh help accessibility\n```\n\n## References\n\n- Official Manual: https://cli.github.com/manual/\n- GitHub Docs: https://docs.github.com/en/github-cli\n- REST API: https://docs.github.com/en/rest\n- GraphQL API: https://docs.github.com/en/graphql\n"
  },
  {
    "path": ".agents/skills/postgres/SKILL.md",
    "content": "---\nname: postgres\ndescription: PostgreSQL best practices, query optimization, connection troubleshooting, and performance improvement. Load when working with Postgres databases.\nlicense: MIT\nmetadata:\n  author: planetscale\n  version: \"1.0.0\"\n---\n\n# PlanetScale Postgres\n\n## Generic Postgres\n\n| Topic                  | Reference                                                        | Use for                                                   |\n| ---------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |\n| Schema Design          | [references/schema-design.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/schema-design.md)           | Tables, primary keys, data types, foreign keys            |\n| Indexing               | [references/indexing.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/indexing.md)                      | Index types, composite indexes, performance               |\n| Index Optimization     | [references/index-optimization.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/index-optimization.md) | Unused/duplicate index queries, index audit               |\n| Partitioning           | [references/partitioning.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/partitioning.md)             | Large tables, time-series, data retention                 |\n| Query Patterns         | [references/query-patterns.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/query-patterns.md)         | SQL anti-patterns, JOINs, pagination, batch queries       |\n| Optimization Checklist | [references/optimization-checklist.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/optimization-checklist.md) | Pre-optimization audit, cleanup, readiness checks  |\n| MVCC and VACUUM        | [references/mvcc-vacuum.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/mvcc-vacuum.md)               | Dead tuples, long transactions, xid wraparound prevention |\n\n## Operations and Architecture\n\n| Topic                  | Reference                                                                    | Use for                                                         |\n| ---------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------- |\n| Process Architecture   | [references/process-architecture.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/process-architecture.md)     | Multi-process model, connection pooling, auxiliary processes     |\n| Memory Architecture    | [references/memory-management-ops.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/memory-management-ops.md)   | Shared/private memory layout, OS page cache, OOM prevention     |\n| MVCC Transactions      | [references/mvcc-transactions.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/mvcc-transactions.md)           | Isolation levels, XID wraparound, serialization errors          |\n| WAL and Checkpoints    | [references/wal-operations.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/wal-operations.md)                 | WAL internals, checkpoint tuning, durability, crash recovery    |\n| Replication            | [references/replication.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/replication.md)                       | Streaming replication, slots, sync commit, failover             |\n| Storage Layout         | [references/storage-layout.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/storage-layout.md)                | PGDATA structure, TOAST, fillfactor, tablespaces, disk mgmt     |\n| Monitoring             | [references/monitoring.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/monitoring.md)                         | pg_stat views, logging, pg_stat_statements, host metrics        |\n| Backup and Recovery    | [references/backup-recovery.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/backup-recovery.md)              | pg_dump, pg_basebackup, PITR, WAL archiving, backup tools      |\n\n## PlanetScale-Specific\n\n| Topic              | Reference                                                                    | Use for                                               |\n| ------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------- |\n| Connection Pooling | [references/ps-connection-pooling.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-connection-pooling.md)   | PgBouncer, pool sizing, pooled vs direct              |\n| Extensions         | [references/ps-extensions.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-extensions.md)                   | Supported extensions, compatibility                   |\n| Connections        | [references/ps-connections.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-connections.md)                 | Connection troubleshooting, drivers, SSL              |\n| Insights           | [references/ps-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-insights.md)                       | Slow queries, MCP server, pscale CLI                  |\n| CLI Commands       | [references/ps-cli-commands.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-commands.md)               | pscale CLI reference, branches, deploy requests, auth |\n| CLI API Insights   | [references/ps-cli-api-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-api-insights.md)       | Query insights via `pscale api`, schema analysis      |\n"
  },
  {
    "path": ".agents/skills/postgres/references/backup-recovery.md",
    "content": "---\ntitle: Backup and Recovery\ndescription: Logical/physical backups, PITR, WAL archiving, backup tools, and recovery strategies\ntags: postgres, backup, recovery, pitr, pg_dump, pg_basebackup, wal-archiving, operations\n---\n\n# Backup and Recovery\n\n**FUNDAMENTAL RULE: Backups are useless until you've successfully tested recovery.**\n\n## Logical Backups (pg_dump)\nExports as SQL or custom format; portable across PG versions and architectures. Formats: `-Fp` (plain SQL), `-Fc` (custom compressed, selective restore), `-Fd` (directory, parallel with `-j`), `-Ft` (tar, avoid). Use `-Fd -j 4` for large DBs. Restore: `pg_restore -d dbname file.dump`; add `-j` for parallel restore. Selective table restore: `pg_restore -t tablename`. Slow for large DBs; RPO = backup frequency (typically 24h).\n\n## Physical Backups (pg_basebackup)\nCopies raw PGDATA; same major version and platform required; cross-architecture works if same endianness (e.g., x86_64 ↔ ARM64). Faster for large clusters; includes all databases. Flags: `-Ft -z -P` for compressed tar with progress. Manual alternative: `pg_backup_start()` → copy PGDATA → `pg_backup_stop()` (complex; must write returned `backup_label`).\n\n## PITR (Point-in-Time Recovery)\nRequires base backup + continuous WAL archiving. Restores to any timestamp, transaction, or named restore point. Without PITR: restore only to backup time (potentially lose hours). With PITR: RPO = minutes. `archive_command` must return 0 ONLY when file is safely stored—premature 0 = data loss risk. `wal_level` must be `replica` or `logical` (not `minimal`).\n\n## WAL Archiving\n`archive_mode=on`, `archive_command='test ! -f /archive/%f && cp %p /archive/%f'`. **Test archive command as postgres user** (not root) since permission issues are common. Monitor `pg_stat_archiver` for `failed_count`, `last_archived_time`. Archive failures prevent WAL recycling → disk fills.\n\n## Tool Comparison\n| Tool | Use case |\n|------|----------|\n| pg_dump | Small DBs, migrations, selective restore |\n| pg_basebackup | Basic PITR, built-in |\n| pgBackRest | Production—parallel, incremental, S3/GCS/Azure, retention |\n| Barman | Enterprise PITR, retention policies |\n| WAL-G | Cloud-native, S3/GCS/Azure |\n\n## RPO/RTO\nLogical only: RPO = backup interval (hours); RTO = hours. PITR: RPO = minutes; RTO = hours. Synchronous replication: RPO = 0; RTO = seconds to minutes (failover).\n\n## Operational Rules\n- Verify integrity with `pg_verifybackup` (PG 13+)\n- Test recovery / PITR regularly\n- Take backups from standby to avoid impacting primary\n- Retention: 7 daily, 4 weekly, 12 monthly\n- Monitor archive growth and backup age\n- **Never assume backups work without testing**\n"
  },
  {
    "path": ".agents/skills/postgres/references/index-optimization.md",
    "content": "---\ntitle: Index Optimization Queries\ndescription: Index audit queries\ntags: postgres, indexes, unused-indexes, duplicate-indexes, optimization\n---\n\n# Index Optimization\n\n## Identify Unused Indexes\n\nQuery to find unused indexes:\n\n```sql\n-- indexes with 0 scans (check pg_stat_reset / pg_postmaster_start_time first)\nSELECT\n   s.schemaname,\n   s.relname AS table_name,\n   s.indexrelname AS index_name,\n   pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size\n FROM pg_catalog.pg_stat_user_indexes s\n JOIN pg_catalog.pg_index i ON s.indexrelid = i.indexrelid\n WHERE s.idx_scan = 0\n   AND 0 <> ALL (i.indkey)       -- exclude expression indexes\n   AND NOT i.indisunique          -- exclude UNIQUE indexes\n   AND NOT EXISTS (               -- exclude constraint-backing indexes\n     SELECT 1 FROM pg_catalog.pg_constraint c\n     WHERE c.conindid = s.indexrelid\n   )\n ORDER BY pg_relation_size(s.indexrelid) DESC;\n```\n\n## Indexes Per Table Guidelines\n\n- **< 5**: Normal\n- **5-10**: Monitor (Verify necessity)\n- **> 10**: Audit required (High write overhead)\n\n```sql\nSELECT relname AS table, count(*) as index_count\nFROM pg_stat_user_indexes\nGROUP BY relname\nORDER BY count(*) DESC;\n```\n\n## Identify Unused Indexes\n\nIndexes with identical definitions (after normalizing names) on the same table are duplicates:\n\n```sql\nSELECT\n  schemaname || '.' || tablename AS table,\n  array_agg(indexname) AS duplicate_indexes,\n  pg_size_pretty(sum(pg_relation_size((schemaname || '.' || indexname)::regclass))) AS total_size\nFROM pg_indexes\nWHERE schemaname NOT IN ('pg_catalog', 'information_schema')\nGROUP BY schemaname, tablename,\n  regexp_replace(indexdef, 'INDEX \\S+ ON ', 'INDEX ON ')\nHAVING count(*) > 1;\n```\n\n**Always confirm with a human before dropping or removing any indexes identified by the queries above.** Even indexes with 0 scans may be needed for infrequent but critical queries, and stats may have been reset recently.\n\n## Per-table Index Count Guidelines\n\n| Index Count | Recommendation                              |\n| ----------- | ------------------------------------------- |\n| <5          | Normal                                      |\n| 5-10        | Review for unused/duplicates                |\n| >10         | Audit required - significant write overhead |\n"
  },
  {
    "path": ".agents/skills/postgres/references/indexing.md",
    "content": "---\ntitle: Indexing Best Practices\ndescription: Index design guide\ntags: postgres, indexes, composite, partial, covering, gin, brin\n---\n\n# Indexing Best Practices\n\n## Core Rules\n\n1. **Always index foreign key columns** — PostgreSQL does not auto-create these\n2. **Index columns in WHERE, JOIN, and ORDER BY** clauses\n3. **Don't over-index** — each index slows writes and uses storage\n4. **Verify with EXPLAIN ANALYZE** — confirm indexes are actually used\n\n## Composite Indexes\n\nPut equality columns first, then range/sort columns:\n\n```sql\n-- WHERE status = 'active' AND created_at > '2026-01-01'\nCREATE INDEX order_status_created_idx ON order (status, created_at);\n```\n\nA composite index on `(a, b)` supports queries on `a` + `b` and `a` alone, but not `b` alone.\n\n## Partial Indexes\n\nReduce index size by filtering to common query patterns.\nOnly use if index size is problematic but the index is needed for performance.\n\n```sql\nCREATE INDEX order_active_idx ON order (customer_id)\n  WHERE status = 'active';\n```\n\n## Covering Indexes\n\nConsider creating covering indexes for commonly executed query patterns that return only 1 or a small number of columns.\n\n## Index Types\n\n| Type | Use Case | Example |\n| --- | --- | --- |\n| B-tree (default) | Equality, range, sorting | `WHERE id = 1`, `ORDER BY date` |\n| GIN | Arrays, JSONB, full-text | `WHERE tags @> ARRAY['x']` |\n| GiST | Geometric, range types, full-text | PostGIS, `tsrange`, `tsvector` |\n| BRIN | Large sequential/time-series | Append-only logs, events (requires physical row order correlation) |\n\n```sql\nCREATE INDEX metadata_idx ON order USING GIN (metadata);       -- JSONB\nCREATE INDEX event_created_idx ON event USING BRIN (created_at); -- time-series\n```\n\n## Guidelines\n\n- Name indexes consistently: `{table}_{column}_idx`\n- Review for unused indexes periodically\n- **Always confirm with a human before removing or dropping any indexes** — even unused ones may serve a purpose not reflected in recent stats\n- Use partial indexes for frequently filtered subsets\n- Use covering indexes on hot read paths\n"
  },
  {
    "path": ".agents/skills/postgres/references/memory-management-ops.md",
    "content": "---\ntitle: Memory Architecture and OOM Prevention\ndescription: PostgreSQL shared/private memory layout, OS page cache interaction, and OOM avoidance strategies\ntags: postgres, memory, shared_buffers, work_mem, oom, architecture, operations\n---\n\n# Memory Architecture and OOM Prevention\n\n## Memory Areas\n\n- **Shared memory**: `shared_buffers` — main data cache, all processes, requires restart to change.\n- **Private per backend**: `work_mem` (sorts/hashes/joins, per-operation); `maintenance_work_mem` (VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY); `temp_buffers` (8MB default).\n- **Planner hint only**: `effective_cache_size` is NOT allocated — set to ~50–75% of total RAM.\n- **Hash multiplier**: `hash_mem_multiplier` (default 2.0) means hash ops use up to 2× `work_mem`.\n\n## Memory Multiplication Danger\n\nMaximum potential: `work_mem × operations_per_query × (parallel_workers + 1) × connections` (leader participates by default via `parallel_leader_participation = on`; hash operations use up to `hash_mem_multiplier × work_mem`, default 2.0). Example: 128MB work_mem, 3 ops (2 sorts + 1 hash join), 2 parallel workers, 100 connections → 2 sorts at 128MB = 256MB, 1 hash join at 128MB × 2.0 = 256MB, per process = 512MB, × 3 processes (2 workers + leader) = 1536MB/query, × 100 connections = **~150GB** worst case. This case is rare.\nNot all queries hit limits at once, but high concurrency + large datasets approach it. This is a common cause of OOM in containerized/Kubernetes deployments. Plan capacity with a 1.5–2× safety margin.\n\n## OS Page Cache (Double Buffering)\n\nData exists in both `shared_buffers` and OS page cache. A miss in shared_buffers can still hit OS cache (avoiding disk I/O). Extremely large shared_buffers can hurt performance: less OS cache, slower startup, heavier checkpoints. Optimal split depends on workload (OLTP vs OLAP).\n\n## OOM Prevention\n\n- Implement connection pooling to reduce total backend count.\n- Reduce `work_mem` globally; use per-session overrides for heavy queries only.\n- Lower `max_parallel_workers_per_gather` in high-concurrency systems.\n- Set `statement_timeout` to kill runaway queries.\n- Monitor: `dmesg -T | grep \"killed process\"` and `temp_blks_written` in pg_stat_statements.\n\n## Operational Rules\n\n- Tune per-session first, global last.\n- Suspect OOM when memory spikes during high concurrency, dashboards, or large batch jobs.\n- Increase memory only after confirming spill behavior (`temp_blks_written > 0`).\n- `maintenance_work_mem` can be set much higher (1–2GB) — fewer processes use it. Cap autovacuum with `autovacuum_work_mem` to avoid `autovacuum_max_workers × maintenance_work_mem` memory spikes.\n- `shared_buffers` change requires full restart; `work_mem` is per-session changeable.\n"
  },
  {
    "path": ".agents/skills/postgres/references/monitoring.md",
    "content": "---\ntitle: Monitoring\ndescription: Essential PostgreSQL monitoring views, pg_stat_statements, logging, host metrics, and statistics management\ntags: postgres, monitoring, pg_stat_statements, logging, pgbadger, metrics, operations\n---\n\n# Monitoring\n\n## Essential Views\n\n- **pg_stat_activity**: First stop when something is wrong — running queries, states, wait events, locks.\n- **pg_stat_statements**: Execution stats for all SQL. Requires `shared_preload_libraries = 'pg_stat_statements'` and `CREATE EXTENSION pg_stat_statements`.\n- **pg_stat_database**: Cache hit ratio, temp files, deadlocks, connections per database.\n- **pg_stat_user_tables**: `seq_scan` vs `idx_scan`, dead tuples, last vacuum/analyze times.\n- **pg_stat_user_indexes**: Find unused indexes (`idx_scan = 0` with large size).\n- **pg_stat_bgwriter**: `buffers_clean`, `maxwritten_clean`, `buffers_alloc`. Pre-PG 17 also had `buffers_checkpoint`, `buffers_backend` (high = backends bypassing bgwriter). PG 17+ moved checkpoint stats to `pg_stat_checkpointer`.\n- **pg_stat_checkpointer** (PG 17+): Checkpoint frequency (`num_timed`, `num_requested`), write/sync time.\n\n## Key Queries\n\n```sql\n-- Slow queries (with cache hit ratio)\nSELECT query, calls, mean_exec_time,\n  100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS cache_hit_pct\nFROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;\n\n-- Connection counts / states\nSELECT state, count(*) FROM pg_stat_activity GROUP BY state;\n\n-- Dead tuples (vacuum candidates)\nSELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;\n-- last_autovacuum = <null> means autovacuum has not run on this table\n```\n\nBlocking: use `pg_blocking_pids(pid)` with `pg_stat_activity` to find blocked and blocking sessions.\n\n## Logging — First Line of Defense\n\nPostgreSQL is extremely vocal about problems. **Always check logs first**: `tail -f /var/log/postgresql/postgresql-*.log`.\n\nKey settings: `log_min_duration_statement` (OLTP: 1–3s, analytics: 30–60s, dev: 100–500ms). Enable `log_checkpoints=on`, `log_connections=on`, `log_disconnections=on`, `log_lock_waits=on`, `log_temp_files=0`. Use CSV log format for pgBadger analysis; pgBadger generates HTML reports with query stats and performance graphs.\n\n## pg_activity\n\nInteractive top-like tool (pip install pg_activity). Run on DB host for OS metrics alongside PG metrics. Combines `pg_stat_activity` with CPU/memory/I/O context.\n\n## Host Metrics — Critical\n\nPostgreSQL cannot report these. **Monitor them yourself:**\n\n- **CPU**: Steal time >10% in VMs bad; load average > core count; context switches >100k/sec.\n- **Memory**: Any swap = performance degradation. Check `dmesg` for OOM kills.\n- **Disk I/O**: `iostat -x` — `%util=100%` means saturated; `await` >10ms = high latency.\n- **Disk space**: >90% critical (VACUUM fails, writes fail). Check inode usage too.\n- **Network**: Packet loss >0% = problems; high retransmits = instability.\n\n## Statistics Management\n\nStats accumulate since last reset or restart; check `stats_reset` timestamp. `pg_stat_statements_reset()` clears query stats; `pg_stat_reset()` clears database stats. Reset after major maintenance, config changes, or perf testing — not routinely. Prefer snapshotting stats to external monitoring (Prometheus, Datadog) over resetting. **Always confirm with a human before resetting statistics** — resetting destroys historical performance baselines and can make it harder to identify unused indexes or regressions.\n"
  },
  {
    "path": ".agents/skills/postgres/references/mvcc-transactions.md",
    "content": "---\ntitle: MVCC Transactions and Concurrency\ndescription: Transaction isolation levels, XID wraparound prevention, serialization errors, and long-transaction impact\ntags: postgres, mvcc, transactions, isolation, xid-wraparound, concurrency, serialization\n---\n\n# MVCC Transactions and Concurrency\n\n## Transaction Isolation Levels\n\n- **READ UNCOMMITTED** — treated as READ COMMITTED in PostgreSQL; no dirty reads ever.\n- **READ COMMITTED** (default): new snapshot per statement; can see different data within same tx.\n- **REPEATABLE READ**: snapshot at first query; can cause serialization errors on write conflicts.\n- **SERIALIZABLE**: strongest; transactions appear serial; requires retry logic in app code.\n\nReaders never block writers; writers never block readers (only writer-writer conflicts on same row). No lock escalation — row locks never degrade to table locks.\n\n## XID Wraparound\n\n32-bit transaction IDs wrap at ~2 billion (2^31). `VACUUM FREEZE` replaces old XIDs with FrozenXID (value 2, always visible). Without freeze: after wraparound, old rows appear \"in the future\" and become **invisible**. Data physically exists but is invisible to all queries — looks like total data loss. PostgreSQL emergency shutdown at 2B XIDs to prevent this. XID wraparound should be avoided at all cost.\n\nWarning messages start at ~1.4B XIDs; shutdown at 2B. Recovery requires single-user mode VACUUM — can take hours to days on large DBs. **Never disable autovacuum** — it's your protection against wraparound.\n\n## XID Age Monitoring\n\n```sql\nSELECT datname, age(datfrozenxid),\n  ROUND(100.0 * age(datfrozenxid) / 2147483648, 2) AS pct\nFROM pg_database ORDER BY age(datfrozenxid) DESC;\n```\n\n## Long Transaction Impact\n\nA single long-running transaction blocks VACUUM from removing dead tuples across the **entire database**. Causes table bloat, increased disk, slower queries, cache pollution. `idle_in_transaction` connections are the #1 operational MVCC issue. Set `idle_in_transaction_session_timeout` (30s–5min). Dead tuples waste I/O on seq scans and cause useless heap lookups from indexes.\n\n## Serialization Errors\n\nApps **must** handle \"could not serialize access\" with retry logic. More common in REPEATABLE READ and SERIALIZABLE. Smaller, faster transactions reduce conflict frequency.\n"
  },
  {
    "path": ".agents/skills/postgres/references/mvcc-vacuum.md",
    "content": "---\ntitle: MVCC and VACUUM\ndescription: MVCC internals, VACUUM/autovacuum tuning, and bloat prevention\ntags: postgres, mvcc, vacuum, autovacuum, xid, bloat, dead-tuples\n---\n\n# MVCC and VACUUM\n\n## MVCC\n\nEvery `UPDATE` creates a new tuple and marks the old one dead; `DELETE` marks tuples dead. Dead tuples accumulate until `VACUUM` reclaims space. Each transaction gets a 32-bit XID (2^32 ≈ 4B values, but modular comparison means the effective danger zone is 2^31 ≈ 2B). VACUUM must freeze old XIDs to prevent wraparound.\n\n## VACUUM vs VACUUM FULL\n\n`VACUUM` is non-blocking (ShareUpdateExclusive lock) and marks dead space reusable. `VACUUM FULL` rewrites the table and requires an AccessExclusive lock — use only as a last resort. For online bloat reduction prefer `pg_squeeze` or `pg_repack`.\n\n## Autovacuum Tuning\n\nTriggers when dead tuples > `Min(autovacuum_vacuum_max_threshold, autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples)`. `autovacuum_vacuum_max_threshold` defaults to 100M (PG 18+), capping the threshold for very large tables. Also triggers on inserts exceeding `autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor * reltuples * pct_not_frozen` (ensures insert-only tables get frozen; PG 13+). For large/hot tables, set per-table overrides:\n\n- `autovacuum_vacuum_scale_factor` — default 0.2; lower to 0.01–0.05 for large tables.\n- `autovacuum_vacuum_cost_delay` — default 2 ms; set to 0 on fast storage.\n- `autovacuum_vacuum_cost_limit` — default -1 (uses `vacuum_cost_limit`, effectively 200); raise to 1000–2000 on fast storage.\n- `autovacuum_freeze_max_age` — default 200M; triggers anti-wraparound vacuum.\n- `vacuum_failsafe_age` — default 1.6B; last-resort mode (PG 14+) that disables throttling and skips index vacuuming when wraparound is imminent.\n\n## Key Monitoring Queries\n\nDead tuples: `SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;`\n\nXID age: `SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY xid_age DESC;`\n\nLong transactions: `SELECT pid, state, now() - xact_start AS tx_age FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start;`\n\n## Best Practices\n\n- Keep transactions short; set `idle_in_transaction_session_timeout` (30s–5min).\n- Alert when `age(datfrozenxid)` exceeds 40–50% of wraparound (~800M–1B).\n- Tune autovacuum per-table for write-heavy tables; don't change global defaults first.\n- Fix application transaction scope before adjusting vacuum parameters.\n- Never disable autovacuum globally.\n"
  },
  {
    "path": ".agents/skills/postgres/references/optimization-checklist.md",
    "content": "---\ntitle: Database Optimization Checklist\ndescription: Optimize checklist\ntags: postgres, optimization, indexes, partitioning, maintenance\n---\n\n# Optimization Checklist\n\nWhen optimizing performance, check the following:\n\n- Look for unused indexes (0 scans; exclude unique/primary indexes and verify stats age first)\n- Look for duplicate indexes\n- Archive audit/log tables >10GB\n- Review tables >500GB for partitioning (>100GB for time-series/logs)\n- Verify all extensions are supported\n- Check for circular foreign key dependencies\n- Consider alternatives to UUID primary keys for large tables\n- Configure connection pooling for OLTP workloads\n- **Always confirm with a human before removing any indexes, dropping partitions, archiving tables, or performing other destructive actions**\n"
  },
  {
    "path": ".agents/skills/postgres/references/partitioning.md",
    "content": "---\ntitle: Table Partitioning Guide\ndescription: Partition guide\ntags: postgres, partitioning, range, list, pg_partman, data-retention\n---\n\n# Table Partitioning\n\nPlan partitioning upfront for tables expected to grow large. Retrofitting later requires a migration.\n\n## When to Partition\n\nPartitioning benefits maintenance (vacuum, index builds) and data retention more than pure query speed.\n\n| Table Type | Size Threshold | Row Threshold |\n| --- | --- | --- |\n| General tables | >100 GB (or >RAM) | >20M rows |\n| Time-series / logs | >50 GB | >10M rows |\n\nUse the lower thresholds for append-heavy, time-ordered data with retention needs (logs, events, audit trails, metrics).\n\n## Range Partitioning (Most Common)\n\n```sql\n-- EXAMPLE\nCREATE TABLE event (\n  id BIGINT GENERATED ALWAYS AS IDENTITY,\n  event_type TEXT NOT NULL,\n  payload JSONB,\n  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n  PRIMARY KEY (id, created_at) -- Partition key MUST be part of PK\n) PARTITION BY RANGE (created_at);\n\nCREATE TABLE event_2026_01 PARTITION OF event\n  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');\n\nCREATE TABLE event_2026_02 PARTITION OF event\n  FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');\n```\n\n## List Partitioning\n\nUseful for partitioning by region, tenant, or category:\n\n```sql\n-- EXAMPLE\nCREATE TABLE order (\n  id BIGINT GENERATED ALWAYS AS IDENTITY,\n  region TEXT NOT NULL,\n  total NUMERIC(10,2),\n  PRIMARY KEY (id, region) -- Partition key MUST be part of PK\n) PARTITION BY LIST (region);\n\nCREATE TABLE order_us PARTITION OF order FOR VALUES IN ('us');\nCREATE TABLE order_eu PARTITION OF order FOR VALUES IN ('eu');\nCREATE TABLE order_default PARTITION OF order DEFAULT;  -- catches unmatched values\n```\n\n## Partition Management\n\n- Use `pg_partman` (extension) to automate partition creation and cleanup.\n- Use `DETACH PARTITION` to remove a partition while retaining it as a standalone table (e.g., for archiving).\n- Use `DETACH PARTITION ... CONCURRENTLY` (PG 14+) to avoid `ACCESS EXCLUSIVE` locks on the parent table.\n- Drop old partitions for data retention instead of `DELETE` to avoid vacuum overhead and bloat.\n- Create future partitions ahead of time to avoid insert failures.\n- **Always confirm with a human before detaching or dropping partitions.** These are destructive actions — detaching removes data from the partitioned table, and dropping permanently deletes the data.\n\n```sql\n-- DESTRUCTIVE: confirm with a human before executing\nALTER TABLE event DETACH PARTITION event_2025_01 CONCURRENTLY;\nDROP TABLE event_2025_01;\n```\n\n## Guidelines & Limitations\n\n- **Primary Keys**: Partition key columns MUST be included in the `PRIMARY KEY` and any `UNIQUE` constraints.\n- **Global Uniqueness**: Global unique constraints on non-partition columns are NOT supported.\n- **Indexes**: Indexes defined on the parent are automatically created on all partitions (and future ones).\n- **Pruning**: Ensure queries filter by the partition key to enable \"partition pruning\" (skipping unrelated partitions).\n"
  },
  {
    "path": ".agents/skills/postgres/references/process-architecture.md",
    "content": "---\ntitle: Process Architecture\ndescription: PostgreSQL multi-process model, connection management, and auxiliary processes\ntags: postgres, processes, connections, pooling, memory, operations\n---\n\n# Process Architecture\n\nPostgreSQL uses a **multi-process** model, not multi-threaded: one OS process per client connection. The postmaster is the parent; it spawns backend processes per connection. Each backend has some private memory (`work_mem`, temp buffers). 1000 connections = 1000 processes (~5–10MB base + query memory each). There is also a large buffer shared amongst all.\n\n## Auxiliary Processes\n\nWAL Writer, Background Writer, Checkpointer, Autovacuum Launcher/Workers, Archiver, WAL Summarizer (PG 17+). These run alongside backends and are not spawned per connection.\n\n## Memory Risk\n\n`work_mem` is per-operation, not per-query. Estimate: `work_mem × operations_per_query × parallel_workers × connections` can grow very large at high concurrency. Scale connections and parallelism before raising `work_mem`.\n\n## Connection Pooling (Critical)\n\nEach connection = OS process (fork overhead, context switching, memory). PgBouncer can multiplex many app connections to fewer DB connections. Typical: 1000 app connections → pooler → 20–50 backends. Implement pooling before raising `max_connections`; `max_connections` requires a full restart to change (default 100). Note: `superuser_reserved_connections` (default 3) reserves slots for emergency superuser access, so non-superusers are rejected before `max_connections` is fully reached.\n\n## Monitoring\n\n```sql\nSELECT state, count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY state;\n```\n\n```sql\n-- Show used and free connection slots\nSELECT count(*) AS used, max(max_conn) - count(*) AS free\nFROM pg_stat_activity, (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') s\nWHERE backend_type = 'client backend';\n```\n\nUse `pg_activity` for interactive top-like monitoring. Alert at 80% connection usage, critical at 95%. Count by state to find idle-in-transaction leaks — these hold locks and **block VACUUM** from reclaiming dead tuples.\n\n## Common Problems\n\n| Problem | Fix |\n| ------- | --- |\n| `too many clients already` | Implement pooling; find idle connections; check for connection leaks |\n| High memory / OOM | Reduce `work_mem`; add pooling; set `statement_timeout` |\n| Stuck process | `SELECT pg_cancel_backend(pid);` then `SELECT pg_terminate_backend(pid);` — **always confirm with a human before terminating backends**, as this may abort in-flight transactions and cause data issues for the application |\n\nPrefer pooling + conservative `max_connections` over raising limits reactively.\n"
  },
  {
    "path": ".agents/skills/postgres/references/ps-cli-api-insights.md",
    "content": "---\ntitle: CLI Query Insights API\ndescription: CLI insights usage\ntags: postgres, planetscale, cli, insights, query-patterns, api\n---\n\n# Query Insights via pscale CLI\n\nAnalyze slow queries and missing indexes using `pscale api`. Endpoints may change—see https://planetscale.com/docs/api/reference/getting-started-with-planetscale-api for current API docs.\n\n## Using pscale api\n\nThe `pscale api` command makes authenticated API calls using your current login or service token (see [ps-cli-commands.md](ps-cli-commands.md#service-token-cicd) for auth setup). No need to manage auth headers manually.\n\n```bash\npscale api \"<endpoint>\" [--method POST] [--field key=value] [--org <org>]\n```\n\n## Query Patterns Reports\n\n```bash\n# Create a new report\npscale api \"organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports\" \\\n  --method POST --org my-org\n\n# Check status (poll until state=complete)\npscale api \"organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}/status\"\n\n# Download completed report\npscale api \"organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}\"\n\n# List all reports\npscale api \"organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports\"\n```\n\n## Schema Analysis\n\n```bash\n# Get branch schema\npscale api \"organizations/{org}/databases/{db}/branches/{branch}/schema\"\n\n# Lint schema for issues\npscale api \"organizations/{org}/databases/{db}/branches/{branch}/schema/lint\"\n```\n\n## What to Look For\n\n| Metric                           | Indicates             | Action                          |\n| -------------------------------- | --------------------- | ------------------------------- |\n| High `rows_read / rows_returned` | Missing or poor index | Add index on WHERE/JOIN columns |\n| High `total_time_s`              | Heavy query           | Optimize or cache               |\n| High `count` with same pattern   | N+1 queries           | Batch or eager-load             |\n| `indexed: false`                 | Full table scan       | Add index                       |\n"
  },
  {
    "path": ".agents/skills/postgres/references/ps-cli-commands.md",
    "content": "---\ntitle: PlanetScale CLI Reference\ndescription: CLI command guide\ntags: planetscale, cli, branches, deploy-requests, authentication\n---\n\n# pscale CLI Commands\n\nFull CLI reference: https://planetscale.com/docs/cli. Use `pscale <command> --help` for subcommands and flags.\n\n## Authentication\n\n```bash\npscale auth login                    # Opens browser\npscale auth logout\npscale org list\npscale org switch <name>\n```\n\n### Service Token (CI/CD)\n\n```bash\n# Create and configure\npscale service-token create\npscale service-token add-access <id> read_branch --database <db>\n# Use in CI/CD\nexport PLANETSCALE_SERVICE_TOKEN_ID=\"<id>\"\nexport PLANETSCALE_SERVICE_TOKEN=\"<token>\"\n```\n\n## Core Commands\n\n```bash\n# Databases\npscale database list\npscale database create <name>\n\n# Branches\npscale branch list <db>\npscale branch create <db> <branch> [--from <parent>]\npscale branch delete <db> <branch>    # DESTRUCTIVE — always confirm with a human first\npscale branch schema <db> <branch>\n\n# Deploy requests (schema changes) — Vitess only\npscale deploy-request create <db> <branch>\npscale deploy-request list <db>\npscale deploy-request deploy <db> <number>\n\n# Connect\npscale shell <db> <branch>           # Opens psql (Postgres) or mysql (Vitess)\npscale connect <db> <branch>         # Proxy for GUI tools (secure tunnel) — Vitess only\n\n# Credentials\npscale role create <db> <branch> <name>      # Postgres\npscale password create <db> <branch> <name>  # Vitess\n\n# Other\npscale ping              # Check latency to regions\npscale region list       # Available regions\npscale backup list <db> <branch>\npscale backup create <db> <branch>\n```\n\n## Useful Flags\n\n```bash\n--format json    # Output as JSON (also: csv, human)\n--org <name>     # Specify organization\n--debug          # Debug output\n```\n\nFor API calls via CLI, see [ps-cli-api-insights.md](ps-cli-api-insights.md).\n"
  },
  {
    "path": ".agents/skills/postgres/references/ps-connection-pooling.md",
    "content": "---\ntitle: PgBouncer Connection Pooling\ndescription: Pooling setup guide\ntags: postgres, pgbouncer, connection-pooling, performance, transactions\n---\n\n# Connection Pooling with PgBouncer\n\nPlanetScale provides PgBouncer for connection pooling. Connect on port `6432` instead of `5432`.\n\n## When to Use PgBouncer (Port 6432)\n\nAll OLTP application workloads: web apps, APIs, high-concurrency read/write operations.\n\n## When to Use Direct Connections (Port 5432)\n\n- Schema changes (DDL)\n- Analytics, reporting, batch processing\n- Session-specific features (temp tables, session variables)\n- ETL, data streaming, `pg_dump`\n- Long-running admin transactions\n\n## PgBouncer Types\n\nPlanetScale offers three PgBouncer options. All use port `6432`.\n\n| Type | Runs On | Routes To | Key Trait |\n| ---- | ------- | --------- | --------- |\n| **Local** | Same node as primary | Primary only | Included with every database; no replica routing |\n| **Dedicated Primary** | Separate node | Primary | Connections persist through resizes, upgrades, and most failovers |\n| **Dedicated Replica** | Separate node | Replicas | Read-only traffic; supports AZ affinity for lower latency |\n\n- **Local PgBouncer** — use same credentials as direct, just change port to `6432`. Always routes to primary regardless of username.\n- **Dedicated Primary** — runs off-server for improved HA. Use for production OLTP write traffic.\n- **Dedicated Replica** — runs off-server for read-heavy workloads. Supports AZ affinity to prefer same-zone replicas. Multiple can be created for capacity or per-app isolation.\n\nTo connect to a dedicated PgBouncer, append `|pgbouncer-name` to the username (e.g., `postgres.xxx|write-pool` or `postgres.xxx|read-bouncer`).\n\n## Transaction Pooling Limitations\n\nPlanetScale PgBouncer uses **transaction pooling mode**. These features are unavailable:\n\n- Prepared statements that persist across transactions\n- Temporary tables\n- `LISTEN`/`NOTIFY`\n- Session-level advisory locks\n- `SET` commands persisting beyond a transaction\n\n## Recommended Patterns\n\n- Size pools from observed concurrency, query memory behavior, and connection limits.\n- Keep pooled app traffic on `6432` and reserve direct connections for DDL/admin/long-running jobs.\n\n## Avoid Patterns\n\n- Avoid setting pool size with only `CPU_cores * N` while ignoring query-memory amplification.\n- Avoid running session-dependent workflows through transaction pooling.\n\n## Connecting\n\n```bash\n# Local PgBouncer (same credentials, port 6432)\npsql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'\n\n# Dedicated primary PgBouncer (append |pgbouncer-name to user)\npsql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx|write-pool password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'\n\n# Dedicated replica PgBouncer (append |pgbouncer-name to user)\npsql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx|read-bouncer password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'\n```\n\nDocs: https://planetscale.com/docs/postgres/connecting/pgbouncer\n"
  },
  {
    "path": ".agents/skills/postgres/references/ps-connections.md",
    "content": "---\ntitle: PlanetScale Postgres Connections\ndescription: Connection guide for PlanetScale Postgres\ntags: planetscale, postgres, connections, ssl, troubleshooting\n---\n\n# PlanetScale Postgres Connections\n\nPostgres docs: https://planetscale.com/docs/postgres/connecting\n\n| Protocol | Standard Port | Pooled Port | SSL      |\n| -------- | ------------- | ----------------------- | -------- |\n| Postgres | 5432          | 6432 (PgBouncer)        | Required |\n\nCredentials (roles) are branch-specific and cannot be recovered after creation.\n\n## Connection String\n\n```\npostgresql://<user>:<password>@<host>.horizon.psdb.cloud:5432/<database>?sslmode=verify-full&sslrootcert=system&sslnegotiation=direct\n```\n\nUse port **6432** for PgBouncer (applications/OLTP).\nUse port **5432** for DDL, admin tasks, and migrations.\n\n## Troubleshooting\n\n| Error | Fix |\n| -------------------------------- | --------------------------------------- |\n| `password authentication failed` | Check role format: `<role>.<branch_id>` |\n| `too many clients already`       | Use PgBouncer (port 6432)               |\n| `SSL connection is required`     | Add `sslmode=verify-full&sslrootcert=system` |\n\n**Best practices:**\n- Use the PlanetScale Postgres metrics page to monitor direct and PgBouncer connections\n- Route OLTP traffic to port 6432 and reserve 5432 for admin/migrations.\n- Avoid raising `max_connections` reactively instead of pooling.\n"
  },
  {
    "path": ".agents/skills/postgres/references/ps-extensions.md",
    "content": "---\ntitle: PlanetScale PostgreSQL Extensions\ndescription: Extension reference\ntags: postgres, extensions\n---\n\n# PostgreSQL Extensions on PlanetScale\n\nOnly use PlanetScale-supported extensions. For the complete and up-to-date list of available extensions, see: https://planetscale.com/docs/postgres/extensions\n\nDo not rely on hard-coded extension lists — always check the documentation above for current availability.\n\n## Enabling Extensions\n\nSome extensions must first be **enabled in the PlanetScale Dashboard** (Clusters > Extensions) before they can be created in SQL. This often requires a database restart.\n\nOnce enabled in the dashboard, create the extension in SQL:\n\n```sql\nCREATE EXTENSION IF NOT EXISTS <extension_name>;\n```\n\n## Recommended Patterns\n\n- Always check the [PlanetScale extensions docs](https://planetscale.com/docs/postgres/extensions) before assuming an extension is available.\n- Verify extension availability in PlanetScale configuration and docs before schema design depends on it.\n- Enable `pg_stat_statements` early for baseline query telemetry.\n"
  },
  {
    "path": ".agents/skills/postgres/references/ps-insights.md",
    "content": "---\ntitle: PlanetScale Query Insights\ndescription: Query insights guide\ntags: postgres, planetscale, insights, monitoring, optimization\n---\n\n# PlanetScale Insights\n\n## Fetch current documentation first\n\nPrefer retrieval over pre-training knowledge. Docs: https://planetscale.com/docs\n\n## MCP Server (Preferred)\n\nWhen the PlanetScale MCP server is configured in your environment, prefer it over CLI. Key tools:\n\n- `planetscale_get_branch_schema` — Get schema for a branch\n- `planetscale_execute_read_query` — Run SELECT, SHOW, DESCRIBE, EXPLAIN\n- `planetscale_get_insights` — Query performance insights\n- `planetscale_list_schema_recommendations` — Index and schema suggestions\n- `planetscale_search_documentation` — Search PlanetScale docs\n\nMCP setup: https://planetscale.com/docs/connect/mcp\n\nThe MCP server is the ideal way to interact with insights from an AI agent.\nIf not installed, prompt the user to install it to make the agent more effective.\n\n## Query Insights (CLI)\n\nGenerating reports via CLI is a multi-step process (create → wait → download).\n\nSee [ps-cli-api-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-api-insights.md) for how to use.\n\nWhat to look for:\n\n- High `rows_read / rows_returned` ratio → missing index\n- High `total_time_s` → optimization target\n\n## Insights UI (Dashboard)\n\nIn the [PlanetScale dashboard](https://app.planetscale.com/), select your database and click **Insights**.\n\n- **Filtering** — Pick a branch, choose primary or replica, and scroll through the last 7 days. Click-and-drag on graphs to zoom into a time window.\n- **Graphs** — Four tabs: Query latency (p50/p95/p99/p99.9), Queries per second, Rows read/s, and Rows written/s.\n- **Queries table** — All queries in the selected timeframe, normalized into patterns. Sortable and filterable by SQL, schema, table, latency, index usage, and more. Customizable columns (count, total time, latency percentiles, rows read/returned/affected, CPU/IO time, cache hit ratio, etc.). Enable sparklines for inline trend graphs. Orange icons flag full table scans.\n- **Query deep dive** — Click any query to see per-pattern graphs, summary stats, index usage breakdown, and a table of notable executions (>1 s, >10k rows read, or errors). Use \"Summarize query\" for an LLM-generated plain-English description.\n- **Anomalies tab** — Flags periods with elevated slow-running queries and surfaces the responsible patterns.\n- **Errors tab** — Surfaces queries that produced errors.\n- **pginsights settings** — `pginsights.raw_queries` enables full query text collection for notable queries; `pginsights.normalize_schema_names` groups identical patterns across schemas (useful for schema-per-tenant designs). Both configurable in the Extensions tab on the Clusters page.\n\nMore: [PlanetScale Insights docs](https://planetscale.com/docs/postgres/monitoring/query-insights)\n\n## Optimization Checklist\n\n- Remove unused indexes (0 scans)\n- Remove duplicate indexes\n- Archive audit/log tables >10 GB\n- Review tables >100 GB for partitioning\n\n**Always confirm with a human before removing indexes, dropping tables/partitions, or archiving data.** These are destructive actions that cannot be easily undone.\n\nMore: [optimization-checklist.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/optimization-checklist.md)\n"
  },
  {
    "path": ".agents/skills/postgres/references/query-patterns.md",
    "content": "---\ntitle: SQL Query Patterns\ndescription: Common SQL anti-patterns and optimized alternatives\ntags: postgres, sql, query-optimization, n-plus-one, pagination\n---\n\n# SQL Query Patterns\n\n## Query Structure\n\n**SELECT specific columns** — avoids fetching unnecessary data and enables covering indexes:\n```sql\n-- Bad:\nSELECT * FROM user WHERE status = 'active';\n-- Good:\nSELECT id, name, email FROM user WHERE status = 'active';\n```\n\n**Subqueries → JOINs** — correlated subqueries re-execute per row:\n```sql\n-- Bad\nSELECT id, (SELECT COUNT(*) FROM order WHERE order.user_id = user.id) FROM user;\n-- Good\nSELECT u.id, COUNT(o.id) FROM user u LEFT JOIN order o ON o.user_id = u.id GROUP BY u.id;\n```\n\n**Always LIMIT unbounded queries** — prevent runaway result sets:\n```sql\nSELECT id, message FROM log WHERE level = 'error' ORDER BY created_at DESC LIMIT 100;\n```\n\n**Avoid functions on indexed columns (SARGable)** — functions prevent index usage unless a functional index exists:\n```sql\n-- Bad: Full table scan\nSELECT * FROM user WHERE date_trunc('day', created_at) = '2023-01-01';\n-- Good: Index scan\nSELECT * FROM user WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';\n```\n\n## N+1 Detection\n\n**Queries inside loops → batch with ANY/IN:**\n```python\n# Bad\nfor uid in user_ids:\n    cursor.execute(\"SELECT name FROM user WHERE id = %s\", (uid,))\n# Good (Postgres specific)\ncursor.execute(\"SELECT id, name FROM user WHERE id = ANY(%s)\", (list(user_ids),))\n# Good (Standard SQL)\n# cursor.execute(\"SELECT id, name FROM user WHERE id IN %s\", (tuple(user_ids),))\n```\n\n**ORM lazy loading → eager loading:**\n```python\n# Bad: N+1 — each iteration fires a query\nfor user in User.query.all():\n    print(user.posts)\n# Good\nusers = User.query.options(joinedload(User.posts)).all()\n```\n\n## Query Rewrites\n\n**UNION → UNION ALL** — skip deduplication when duplicates are impossible or acceptable.\n\n**IN subquery → EXISTS** — EXISTS short-circuits on first match:\n```sql\nSELECT id, name FROM user u\nWHERE EXISTS (SELECT 1 FROM order o WHERE o.user_id = u.id AND o.total > 100);\n```\n\n**OFFSET → cursor pagination** — OFFSET scans and discards rows, degrading at depth:\n```sql\n-- Bad: OFFSET 10000 scans 10020 rows\nSELECT id, title FROM article ORDER BY created_at DESC LIMIT 20 OFFSET 10000;\n-- Good: cursor-based (requires index on (created_at DESC, id DESC))\nSELECT id, title FROM article\nWHERE (created_at, id) < ('2025-06-15T12:00:00Z', 987654)\nORDER BY created_at DESC, id DESC LIMIT 20;\n```\n"
  },
  {
    "path": ".agents/skills/postgres/references/replication.md",
    "content": "---\ntitle: Replication\ndescription: Streaming replication, replication slots, synchronous commit levels, failover, and standby management\ntags: postgres, replication, streaming, slots, synchronous, failover, standby, operations\n---\n\n# Replication\n\n## Streaming Replication for followers\n\nUse physical (byte-for-byte) replication via WAL stream from primary to standbys. Standbys are read-only (hot standby); same major PG version and architecture required (same minor recommended). Without replication slots, the primary may recycle WAL before the standby receives it → standby needs full resync via `pg_basebackup`. Use replication slots to guarantee WAL retention for specific standbys.\n\n## Replication Slots\n\nPostgres supports Physical slots (streaming) and logical slots (logical replication). Slots prevent WAL deletion even if standby is offline — can exhaust `pg_wal/` disk. Use `max_slot_wal_keep_size` to cap retained WAL per slot. Use `idle_replication_slot_timeout` (PG 17+) to auto-invalidate idle slots. `wal_keep_size` is a simpler alternative to slots for WAL retention. Drop inactive slots immediately to prevent disk exhaustion.\n\nSlot lag (MB behind): `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)/1024/1024 AS mb_behind FROM pg_replication_slots;`\n\nDrop inactive slot: `SELECT pg_drop_replication_slot('slot_name');`\n\n**Always confirm with a human before dropping replication slots.** Dropping an active or needed slot can cause downstream issues.\n\n## Synchronous Commit Levels\n\n| Level | Behavior | Use Case |\n|-------|----------|----------|\n| `off` | Returns immediately, no wait | Non-critical writes; risks losing ~600ms of commits on crash (no inconsistency) |\n| `local` | Waits for local WAL fsync only | Local durability only; no standby wait |\n| `remote_write` | Waits for standby OS buffer | Data loss on standby OS crash |\n| `on` | Waits for standby WAL to disk when `synchronous_standby_names` is set; otherwise same as `local` | **Default. This level or higher recommended for HA** |\n| `remote_apply` | Waits for standby to apply WAL | Strongest; read-your-writes |\n\nConfigure with `synchronous_standby_names`. Use `ANY N` for quorum or `FIRST N` for priority-based sync.\n\n## Quorum and Failure\n\n`FIRST 2 (s1, s2, s3)` is priority-based: waits for the 2 highest-priority connected standbys (s1+s2; s3 takes over only if one disconnects). `ANY 2 (s1, s2, s3)` is quorum-based: waits for any 2. With either, if only 1 is healthy, commits hang. Provision at least N+1 standbys: need 2 confirmations → provision 3. PostgreSQL never commits unless required standbys confirm — no inconsistency, but clients may timeout.\n\n## Failover\n\n`pg_ctl promote` or `SELECT pg_promote()` (SQL function, PG 12+) converts standby to primary. One-way: promoted standby cannot rejoin as standby without rebuild. `pg_rewind` can resync old primary to new primary (requires `wal_log_hints=on` or data checksums) — faster than full rebuild. After promotion: update connection strings, rebuild old primary as standby, reconfigure other standbys.\n\n## Monitoring\n\nOn the primary, query `pg_stat_replication` for each connected standby's `state` (`streaming` = healthy, `catchup` = behind), `sync_state` (`sync`/`async`), and LSN positions (`sent_lsn`, `write_lsn`, `flush_lsn`, `replay_lsn`) to compute lag. On standbys, `pg_stat_wal_receiver` shows the receiver process status and `flushed_lsn`; compare `pg_last_wal_receive_lsn()` vs `pg_last_wal_replay_lsn()` for local replay lag.\n\nReplication lag (MB): `SELECT application_name, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)/1024/1024 AS lag_mb FROM pg_stat_replication;`\n\nEnable `wal_compression` (`pglz`, `lz4`, or `zstd`) to compress full page images in WAL (not all WAL data) — reduces WAL size for bandwidth-limited replication.\n"
  },
  {
    "path": ".agents/skills/postgres/references/schema-design.md",
    "content": "---\ntitle: PostgreSQL Schema Design\ndescription: Schema design guide\ntags: postgres, schema, primary-keys, data-types, foreign-keys, naming\n---\n\n# Schema Design\n\n## Primary Keys\n\nPrefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs.\n\n```sql\nCREATE TABLE user (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  email TEXT NOT NULL UNIQUE\n);\n```\n\nRandom UUID PKs (v4) can cause index fragmentation; UUIDs are also larger (16 vs 8 bytes for BIGINT) and can slow joins.\n\n## Data Types\n\n| Use | Avoid |\n| --- | --- |\n| `TEXT`, `VARCHAR` | Extension-specific types |\n| `JSONB` | Custom ENUMs (use CHECK instead) |\n| `TIMESTAMPTZ` | `TIMESTAMP` without time zone |\n| `BIGINT`, `INTEGER` | Platform-specific types |\n\nPrefer CHECK constraints over ENUM types — they're easier to modify:\n\n```sql\nCREATE TABLE order (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  status TEXT NOT NULL CHECK (status IN ('pending', 'shipped', 'delivered'))\n);\n```\n\n## Foreign Keys\n\n- Always index FK columns (PostgreSQL does not auto-create these)\n- Avoid circular FK dependencies\n- Suggestion: use `ON DELETE CASCADE` or `ON DELETE SET NULL` explicitly\n\n```sql\nCREATE TABLE order (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  customer_id BIGINT NOT NULL REFERENCES customer(id) ON DELETE CASCADE\n);\nCREATE INDEX order_customer_id_idx ON order (customer_id);\n```\n\n## Naming Conventions\n\n- Tables: singular snake_case (`user_account`, `order_item`)\n- Columns: singular snake_case (`created_at`, `user_id`)\n- Indexes: `{table}_{column}_idx`\n- Constraints: `{table}_{column}_{type}` (e.g., `order_status_check`)\n\n## General Guidelines\n\n- Add `NOT NULL` to as many columns as possible\n- Add `created_at TIMESTAMPTZ DEFAULT NOW()` to all tables\n- Use `BIGINT` for all IDs and foreign keys, even on small tables\n- Keep tables normalized; denormalize only for proven hot read paths\n"
  },
  {
    "path": ".agents/skills/postgres/references/storage-layout.md",
    "content": "---\ntitle: Storage Layout and Tablespaces\ndescription: PGDATA directory structure, TOAST, fillfactor, tablespaces, and disk management\ntags: postgres, storage, pgdata, toast, fillfactor, tablespaces, disk, operations\n---\n\n# Storage Layout and Tablespaces\n\n## PGDATA Structure\n\n- **base/** — database files (one subdirectory per database, named by OID)\n- **global/** — cluster-wide shared catalogs (pg_database, pg_authid, pg_tablespace)\n- **pg_wal/** — WAL files\n- **pg_xact/** — transaction commit status\n\n\"Cluster\" in PostgreSQL = single instance with one PGDATA, not an HA cluster. Each table/index = one or more files, split into 1GB segments. Tables have companion **_fsm** (free space map) and **_vm** (visibility map); indexes have **_fsm** only (no _vm), except hash indexes.\n\n## Visibility Map and Free Space Map\n\n- **_vm** tracks all-visible pages — VACUUM skips these\n- **_fsm** tracks free space per page — INSERT uses this to find pages with room\n- Both are small files but critical for performance\n\n## TOAST\n\nTOAST triggers when a **row** exceeds ~2KB. Large values are compressed and/or moved out-of-line to `pg_toast.pg_toast_<oid>` tables. **Strategies:** PLAIN (no TOAST), EXTENDED (compress+out-of-line, default for text/bytea), EXTERNAL (out-of-line, no compression — use for pre-compressed data), MAIN (compress, avoid out-of-line). TOAST tables bloat like regular tables — they need VACUUM. `SELECT *` fetches all TOAST columns; always SELECT only needed columns. Move large rarely-accessed columns to separate tables.\n\n## Fillfactor\n\nControls how full pages are packed (default 100%). Lower fillfactor (70–80%) leaves room for HOT (Heap-Only Tuple) updates, which avoid index entries and reduce bloat on UPDATE-heavy tables. Keep 100% for insert-only or read-mostly tables. `ALTER TABLE t SET (fillfactor = 70);`\n\n## Tablespaces\n\n`pg_default` (base/), `pg_global` (global/) are built-in. Custom tablespaces: symbolic links in **pg_tblspc/** to other filesystem locations. Use for separating hot data (SSD) from archives (HDD). Moving tablespaces requires exclusive lock on affected tables.\n\n## Disk Monitoring\n\n- `pg_database_size('dbname')`, `pg_total_relation_size('tablename')`, `pg_relation_size('tablename')`\n- Monitor disk usage: >80% = at risk; >90% = critical (VACUUM may fail if disk capacity is insufficient)\n- Check inode usage (`df -i`) — can run out even with free space\n- `pg_wal/` suddenly large = check replication slots and archiving\n"
  },
  {
    "path": ".agents/skills/postgres/references/wal-operations.md",
    "content": "---\ntitle: WAL and Checkpoint Operations\ndescription: Write-ahead log internals, checkpoint tuning, durability guarantees, and WAL disk management\ntags: postgres, wal, checkpoints, durability, crash-recovery, fsync, operations\n---\n\n# WAL and Checkpoint Operations\n\n## WAL Fundamentals\n\nWrite-Ahead Logging: logs changes to `pg_wal/` **before** modifying data files. WAL segments are 16MB (fixed at initdb). On COMMIT, PostgreSQL fsyncs WAL to disk and returns SUCCESS — data files are updated lazily. WAL records are written for all changes (including uncommitted transactions and rollbacks). **Never disable `fsync` in production** — power loss without fsync risks unrecoverable data loss.\n\n`wal_level`: `minimal` (crash recovery only), `replica` (default; replication + archiving), `logical` (logical replication).\n\n## Dirty Pages and Checkpoints\n\nA dirty page is modified in shared_buffers but not yet written to data files. A checkpoint flushes all dirty pages to disk and writes a checkpoint record to WAL; recovery only replays WAL since the last checkpoint.\n\n- `checkpoint_timeout` (default 5 min) and `max_wal_size` (default 1GB) — checkpoint on whichever triggers first.\n- `checkpoint_completion_target=0.9` spreads I/O over 90% of the interval; avoid spikes.\n- \"Checkpoints are occurring too frequently\" in logs → increase `max_wal_size`.\n- **Target: >90% of checkpoints should be time-based** (`num_timed` in `pg_stat_checkpointer`), not size-based (`num_requested`). If num_requested/(num_timed+num_requested) > 10%, tune `max_wal_size` up.\n\n## WAL Disk Management\n\nReplication slots prevent WAL deletion even when standbys are offline — they can fill disk. WAL archiving failures also block recycling. `max_wal_size` is a *soft* limit; WAL can grow beyond it under heavy load.\n\nWAL size: `SELECT count(*) AS files, pg_size_pretty(sum(size)) AS total FROM pg_ls_waldir();`\n\nSlot lag: `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes FROM pg_replication_slots;`\n\n## Checkpoint Monitoring\n\nPG17+ moved checkpoint stats from `pg_stat_bgwriter` to `pg_stat_checkpointer` and renamed columns.\n\n`SELECT num_timed, num_requested, write_time, sync_time, buffers_written FROM pg_stat_checkpointer;`\n\nBackend-direct writes (formerly `buffers_backend` in `pg_stat_bgwriter`) are now tracked in `pg_stat_io`: `SELECT writes FROM pg_stat_io WHERE backend_type = 'client backend' AND object = 'relation';`\n\n## Crash Recovery\n\nOn crash, PostgreSQL replays WAL from the last checkpoint. Longer checkpoint intervals → more WAL to replay → longer recovery. Trade-off: frequent checkpoints (faster recovery, more I/O) vs infrequent (less I/O, slower recovery). For most workloads, `checkpoint_timeout=5min` and `max_wal_size` tuned to keep checkpoints time-based is the right balance.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/AGENTS.md",
    "content": "# React Best Practices\n\n**Version 1.0.0**  \nVercel Engineering  \nJanuary 2026\n\n> **Note:**  \n> This document is mainly for agents and LLMs to follow when maintaining,  \n> generating, or refactoring React and Next.js codebases. Humans  \n> may also find it useful, but guidance here is optimized for automation  \n> and consistency by AI-assisted workflows.\n\n---\n\n## Abstract\n\nComprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.\n\n---\n\n## Table of Contents\n\n1. [Eliminating Waterfalls](#1-eliminating-waterfalls) — **CRITICAL**\n   - 1.1 [Defer Await Until Needed](#11-defer-await-until-needed)\n   - 1.2 [Dependency-Based Parallelization](#12-dependency-based-parallelization)\n   - 1.3 [Prevent Waterfall Chains in API Routes](#13-prevent-waterfall-chains-in-api-routes)\n   - 1.4 [Promise.all() for Independent Operations](#14-promiseall-for-independent-operations)\n   - 1.5 [Strategic Suspense Boundaries](#15-strategic-suspense-boundaries)\n2. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL**\n   - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports)\n   - 2.2 [Conditional Module Loading](#22-conditional-module-loading)\n   - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries)\n   - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components)\n   - 2.5 [Preload Based on User Intent](#25-preload-based-on-user-intent)\n3. [Server-Side Performance](#3-server-side-performance) — **HIGH**\n   - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes)\n   - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props)\n   - 3.3 [Cross-Request LRU Caching](#33-cross-request-lru-caching)\n   - 3.4 [Minimize Serialization at RSC Boundaries](#34-minimize-serialization-at-rsc-boundaries)\n   - 3.5 [Parallel Data Fetching with Component Composition](#35-parallel-data-fetching-with-component-composition)\n   - 3.6 [Per-Request Deduplication with React.cache()](#36-per-request-deduplication-with-reactcache)\n   - 3.7 [Use after() for Non-Blocking Operations](#37-use-after-for-non-blocking-operations)\n4. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH**\n   - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners)\n   - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance)\n   - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication)\n   - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data)\n5. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM**\n   - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering)\n   - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point)\n   - 5.3 [Do not wrap a simple expression with a primitive result type in useMemo](#53-do-not-wrap-a-simple-expression-with-a-primitive-result-type-in-usememo)\n   - 5.4 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#54-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant)\n   - 5.5 [Extract to Memoized Components](#55-extract-to-memoized-components)\n   - 5.6 [Narrow Effect Dependencies](#56-narrow-effect-dependencies)\n   - 5.7 [Put Interaction Logic in Event Handlers](#57-put-interaction-logic-in-event-handlers)\n   - 5.8 [Subscribe to Derived State](#58-subscribe-to-derived-state)\n   - 5.9 [Use Functional setState Updates](#59-use-functional-setstate-updates)\n   - 5.10 [Use Lazy State Initialization](#510-use-lazy-state-initialization)\n   - 5.11 [Use Transitions for Non-Urgent Updates](#511-use-transitions-for-non-urgent-updates)\n   - 5.12 [Use useRef for Transient Values](#512-use-useref-for-transient-values)\n6. [Rendering Performance](#6-rendering-performance) — **MEDIUM**\n   - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element)\n   - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists)\n   - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements)\n   - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision)\n   - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering)\n   - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches)\n   - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide)\n   - 6.8 [Use Explicit Conditional Rendering](#68-use-explicit-conditional-rendering)\n   - 6.9 [Use useTransition Over Manual Loading States](#69-use-usetransition-over-manual-loading-states)\n7. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM**\n   - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing)\n   - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups)\n   - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops)\n   - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls)\n   - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls)\n   - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations)\n   - 7.7 [Early Length Check for Array Comparisons](#77-early-length-check-for-array-comparisons)\n   - 7.8 [Early Return from Functions](#78-early-return-from-functions)\n   - 7.9 [Hoist RegExp Creation](#79-hoist-regexp-creation)\n   - 7.10 [Use Loop for Min/Max Instead of Sort](#710-use-loop-for-minmax-instead-of-sort)\n   - 7.11 [Use Set/Map for O(1) Lookups](#711-use-setmap-for-o1-lookups)\n   - 7.12 [Use toSorted() Instead of sort() for Immutability](#712-use-tosorted-instead-of-sort-for-immutability)\n8. [Advanced Patterns](#8-advanced-patterns) — **LOW**\n   - 8.1 [Initialize App Once, Not Per Mount](#81-initialize-app-once-not-per-mount)\n   - 8.2 [Store Event Handlers in Refs](#82-store-event-handlers-in-refs)\n   - 8.3 [useEffectEvent for Stable Callback Refs](#83-useeffectevent-for-stable-callback-refs)\n\n---\n\n## 1. Eliminating Waterfalls\n\n**Impact: CRITICAL**\n\nWaterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.\n\n### 1.1 Defer Await Until Needed\n\n**Impact: HIGH (avoids blocking unused code paths)**\n\nMove `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.\n\n**Incorrect: blocks both branches**\n\n```typescript\nasync function handleRequest(userId: string, skipProcessing: boolean) {\n  const userData = await fetchUserData(userId)\n  \n  if (skipProcessing) {\n    // Returns immediately but still waited for userData\n    return { skipped: true }\n  }\n  \n  // Only this branch uses userData\n  return processUserData(userData)\n}\n```\n\n**Correct: only blocks when needed**\n\n```typescript\nasync function handleRequest(userId: string, skipProcessing: boolean) {\n  if (skipProcessing) {\n    // Returns immediately without waiting\n    return { skipped: true }\n  }\n  \n  // Fetch only when needed\n  const userData = await fetchUserData(userId)\n  return processUserData(userData)\n}\n```\n\n**Another example: early return optimization**\n\n```typescript\n// Incorrect: always fetches permissions\nasync function updateResource(resourceId: string, userId: string) {\n  const permissions = await fetchPermissions(userId)\n  const resource = await getResource(resourceId)\n  \n  if (!resource) {\n    return { error: 'Not found' }\n  }\n  \n  if (!permissions.canEdit) {\n    return { error: 'Forbidden' }\n  }\n  \n  return await updateResourceData(resource, permissions)\n}\n\n// Correct: fetches only when needed\nasync function updateResource(resourceId: string, userId: string) {\n  const resource = await getResource(resourceId)\n  \n  if (!resource) {\n    return { error: 'Not found' }\n  }\n  \n  const permissions = await fetchPermissions(userId)\n  \n  if (!permissions.canEdit) {\n    return { error: 'Forbidden' }\n  }\n  \n  return await updateResourceData(resource, permissions)\n}\n```\n\nThis optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.\n\n### 1.2 Dependency-Based Parallelization\n\n**Impact: CRITICAL (2-10× improvement)**\n\nFor operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.\n\n**Incorrect: profile waits for config unnecessarily**\n\n```typescript\nconst [user, config] = await Promise.all([\n  fetchUser(),\n  fetchConfig()\n])\nconst profile = await fetchProfile(user.id)\n```\n\n**Correct: config and profile run in parallel**\n\n```typescript\nimport { all } from 'better-all'\n\nconst { user, config, profile } = await all({\n  async user() { return fetchUser() },\n  async config() { return fetchConfig() },\n  async profile() {\n    return fetchProfile((await this.$.user).id)\n  }\n})\n```\n\n**Alternative without extra dependencies:**\n\n```typescript\nconst userPromise = fetchUser()\nconst profilePromise = userPromise.then(user => fetchProfile(user.id))\n\nconst [user, config, profile] = await Promise.all([\n  userPromise,\n  fetchConfig(),\n  profilePromise\n])\n```\n\nWe can also create all the promises first, and do `Promise.all()` at the end.\n\nReference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)\n\n### 1.3 Prevent Waterfall Chains in API Routes\n\n**Impact: CRITICAL (2-10× improvement)**\n\nIn API routes and Server Actions, start independent operations immediately, even if you don't await them yet.\n\n**Incorrect: config waits for auth, data waits for both**\n\n```typescript\nexport async function GET(request: Request) {\n  const session = await auth()\n  const config = await fetchConfig()\n  const data = await fetchData(session.user.id)\n  return Response.json({ data, config })\n}\n```\n\n**Correct: auth and config start immediately**\n\n```typescript\nexport async function GET(request: Request) {\n  const sessionPromise = auth()\n  const configPromise = fetchConfig()\n  const session = await sessionPromise\n  const [config, data] = await Promise.all([\n    configPromise,\n    fetchData(session.user.id)\n  ])\n  return Response.json({ data, config })\n}\n```\n\nFor operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).\n\n### 1.4 Promise.all() for Independent Operations\n\n**Impact: CRITICAL (2-10× improvement)**\n\nWhen async operations have no interdependencies, execute them concurrently using `Promise.all()`.\n\n**Incorrect: sequential execution, 3 round trips**\n\n```typescript\nconst user = await fetchUser()\nconst posts = await fetchPosts()\nconst comments = await fetchComments()\n```\n\n**Correct: parallel execution, 1 round trip**\n\n```typescript\nconst [user, posts, comments] = await Promise.all([\n  fetchUser(),\n  fetchPosts(),\n  fetchComments()\n])\n```\n\n### 1.5 Strategic Suspense Boundaries\n\n**Impact: HIGH (faster initial paint)**\n\nInstead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.\n\n**Incorrect: wrapper blocked by data fetching**\n\n```tsx\nasync function Page() {\n  const data = await fetchData() // Blocks entire page\n  \n  return (\n    <div>\n      <div>Sidebar</div>\n      <div>Header</div>\n      <div>\n        <DataDisplay data={data} />\n      </div>\n      <div>Footer</div>\n    </div>\n  )\n}\n```\n\nThe entire layout waits for data even though only the middle section needs it.\n\n**Correct: wrapper shows immediately, data streams in**\n\n```tsx\nfunction Page() {\n  return (\n    <div>\n      <div>Sidebar</div>\n      <div>Header</div>\n      <div>\n        <Suspense fallback={<Skeleton />}>\n          <DataDisplay />\n        </Suspense>\n      </div>\n      <div>Footer</div>\n    </div>\n  )\n}\n\nasync function DataDisplay() {\n  const data = await fetchData() // Only blocks this component\n  return <div>{data.content}</div>\n}\n```\n\nSidebar, Header, and Footer render immediately. Only DataDisplay waits for data.\n\n**Alternative: share promise across components**\n\n```tsx\nfunction Page() {\n  // Start fetch immediately, but don't await\n  const dataPromise = fetchData()\n  \n  return (\n    <div>\n      <div>Sidebar</div>\n      <div>Header</div>\n      <Suspense fallback={<Skeleton />}>\n        <DataDisplay dataPromise={dataPromise} />\n        <DataSummary dataPromise={dataPromise} />\n      </Suspense>\n      <div>Footer</div>\n    </div>\n  )\n}\n\nfunction DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {\n  const data = use(dataPromise) // Unwraps the promise\n  return <div>{data.content}</div>\n}\n\nfunction DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {\n  const data = use(dataPromise) // Reuses the same promise\n  return <div>{data.summary}</div>\n}\n```\n\nBoth components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.\n\n**When NOT to use this pattern:**\n\n- Critical data needed for layout decisions (affects positioning)\n\n- SEO-critical content above the fold\n\n- Small, fast queries where suspense overhead isn't worth it\n\n- When you want to avoid layout shift (loading → content jump)\n\n**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.\n\n---\n\n## 2. Bundle Size Optimization\n\n**Impact: CRITICAL**\n\nReducing initial bundle size improves Time to Interactive and Largest Contentful Paint.\n\n### 2.1 Avoid Barrel File Imports\n\n**Impact: CRITICAL (200-800ms import cost, slow builds)**\n\nImport directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).\n\nPopular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.\n\n**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.\n\n**Incorrect: imports entire library**\n\n```tsx\nimport { Check, X, Menu } from 'lucide-react'\n// Loads 1,583 modules, takes ~2.8s extra in dev\n// Runtime cost: 200-800ms on every cold start\n\nimport { Button, TextField } from '@mui/material'\n// Loads 2,225 modules, takes ~4.2s extra in dev\n```\n\n**Correct: imports only what you need**\n\n```tsx\nimport Check from 'lucide-react/dist/esm/icons/check'\nimport X from 'lucide-react/dist/esm/icons/x'\nimport Menu from 'lucide-react/dist/esm/icons/menu'\n// Loads only 3 modules (~2KB vs ~1MB)\n\nimport Button from '@mui/material/Button'\nimport TextField from '@mui/material/TextField'\n// Loads only what you use\n```\n\n**Alternative: Next.js 13.5+**\n\n```js\n// next.config.js - use optimizePackageImports\nmodule.exports = {\n  experimental: {\n    optimizePackageImports: ['lucide-react', '@mui/material']\n  }\n}\n\n// Then you can keep the ergonomic barrel imports:\nimport { Check, X, Menu } from 'lucide-react'\n// Automatically transformed to direct imports at build time\n```\n\nDirect imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.\n\nLibraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.\n\nReference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)\n\n### 2.2 Conditional Module Loading\n\n**Impact: HIGH (loads large data only when needed)**\n\nLoad large data or modules only when a feature is activated.\n\n**Example: lazy-load animation frames**\n\n```tsx\nfunction AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {\n  const [frames, setFrames] = useState<Frame[] | null>(null)\n\n  useEffect(() => {\n    if (enabled && !frames && typeof window !== 'undefined') {\n      import('./animation-frames.js')\n        .then(mod => setFrames(mod.frames))\n        .catch(() => setEnabled(false))\n    }\n  }, [enabled, frames, setEnabled])\n\n  if (!frames) return <Skeleton />\n  return <Canvas frames={frames} />\n}\n```\n\nThe `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.\n\n### 2.3 Defer Non-Critical Third-Party Libraries\n\n**Impact: MEDIUM (loads after hydration)**\n\nAnalytics, logging, and error tracking don't block user interaction. Load them after hydration.\n\n**Incorrect: blocks initial bundle**\n\n```tsx\nimport { Analytics } from '@vercel/analytics/react'\n\nexport default function RootLayout({ children }) {\n  return (\n    <html>\n      <body>\n        {children}\n        <Analytics />\n      </body>\n    </html>\n  )\n}\n```\n\n**Correct: loads after hydration**\n\n```tsx\nimport dynamic from 'next/dynamic'\n\nconst Analytics = dynamic(\n  () => import('@vercel/analytics/react').then(m => m.Analytics),\n  { ssr: false }\n)\n\nexport default function RootLayout({ children }) {\n  return (\n    <html>\n      <body>\n        {children}\n        <Analytics />\n      </body>\n    </html>\n  )\n}\n```\n\n### 2.4 Dynamic Imports for Heavy Components\n\n**Impact: CRITICAL (directly affects TTI and LCP)**\n\nUse `next/dynamic` to lazy-load large components not needed on initial render.\n\n**Incorrect: Monaco bundles with main chunk ~300KB**\n\n```tsx\nimport { MonacoEditor } from './monaco-editor'\n\nfunction CodePanel({ code }: { code: string }) {\n  return <MonacoEditor value={code} />\n}\n```\n\n**Correct: Monaco loads on demand**\n\n```tsx\nimport dynamic from 'next/dynamic'\n\nconst MonacoEditor = dynamic(\n  () => import('./monaco-editor').then(m => m.MonacoEditor),\n  { ssr: false }\n)\n\nfunction CodePanel({ code }: { code: string }) {\n  return <MonacoEditor value={code} />\n}\n```\n\n### 2.5 Preload Based on User Intent\n\n**Impact: MEDIUM (reduces perceived latency)**\n\nPreload heavy bundles before they're needed to reduce perceived latency.\n\n**Example: preload on hover/focus**\n\n```tsx\nfunction EditorButton({ onClick }: { onClick: () => void }) {\n  const preload = () => {\n    if (typeof window !== 'undefined') {\n      void import('./monaco-editor')\n    }\n  }\n\n  return (\n    <button\n      onMouseEnter={preload}\n      onFocus={preload}\n      onClick={onClick}\n    >\n      Open Editor\n    </button>\n  )\n}\n```\n\n**Example: preload when feature flag is enabled**\n\n```tsx\nfunction FlagsProvider({ children, flags }: Props) {\n  useEffect(() => {\n    if (flags.editorEnabled && typeof window !== 'undefined') {\n      void import('./monaco-editor').then(mod => mod.init())\n    }\n  }, [flags.editorEnabled])\n\n  return <FlagsContext.Provider value={flags}>\n    {children}\n  </FlagsContext.Provider>\n}\n```\n\nThe `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.\n\n---\n\n## 3. Server-Side Performance\n\n**Impact: HIGH**\n\nOptimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.\n\n### 3.1 Authenticate Server Actions Like API Routes\n\n**Impact: CRITICAL (prevents unauthorized access to server mutations)**\n\nServer Actions (functions with `\"use server\"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.\n\nNext.js documentation explicitly states: \"Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation.\"\n\n**Incorrect: no authentication check**\n\n```typescript\n'use server'\n\nexport async function deleteUser(userId: string) {\n  // Anyone can call this! No auth check\n  await db.user.delete({ where: { id: userId } })\n  return { success: true }\n}\n```\n\n**Correct: authentication inside the action**\n\n```typescript\n'use server'\n\nimport { verifySession } from '@/lib/auth'\nimport { unauthorized } from '@/lib/errors'\n\nexport async function deleteUser(userId: string) {\n  // Always check auth inside the action\n  const session = await verifySession()\n  \n  if (!session) {\n    throw unauthorized('Must be logged in')\n  }\n  \n  // Check authorization too\n  if (session.user.role !== 'admin' && session.user.id !== userId) {\n    throw unauthorized('Cannot delete other users')\n  }\n  \n  await db.user.delete({ where: { id: userId } })\n  return { success: true }\n}\n```\n\n**With input validation:**\n\n```typescript\n'use server'\n\nimport { verifySession } from '@/lib/auth'\nimport { z } from 'zod'\n\nconst updateProfileSchema = z.object({\n  userId: z.string().uuid(),\n  name: z.string().min(1).max(100),\n  email: z.string().email()\n})\n\nexport async function updateProfile(data: unknown) {\n  // Validate input first\n  const validated = updateProfileSchema.parse(data)\n  \n  // Then authenticate\n  const session = await verifySession()\n  if (!session) {\n    throw new Error('Unauthorized')\n  }\n  \n  // Then authorize\n  if (session.user.id !== validated.userId) {\n    throw new Error('Can only update own profile')\n  }\n  \n  // Finally perform the mutation\n  await db.user.update({\n    where: { id: validated.userId },\n    data: {\n      name: validated.name,\n      email: validated.email\n    }\n  })\n  \n  return { success: true }\n}\n```\n\nReference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)\n\n### 3.2 Avoid Duplicate Serialization in RSC Props\n\n**Impact: LOW (reduces network payload by avoiding duplicate serialization)**\n\nRSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.\n\n**Incorrect: duplicates array**\n\n```tsx\n// RSC: sends 6 strings (2 arrays × 3 items)\n<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />\n```\n\n**Correct: sends 3 strings**\n\n```tsx\n// RSC: send once\n<ClientList usernames={usernames} />\n\n// Client: transform there\n'use client'\nconst sorted = useMemo(() => [...usernames].sort(), [usernames])\n```\n\n**Nested deduplication behavior:**\n\n```tsx\n// string[] - duplicates everything\nusernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings\n\n// object[] - duplicates array structure only\nusers={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)\n```\n\nDeduplication works recursively. Impact varies by data type:\n\n- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated\n\n- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference\n\n**Operations breaking deduplication: create new references**\n\n- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`\n\n- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`\n\n**More examples:**\n\n```tsx\n// ❌ Bad\n<C users={users} active={users.filter(u => u.active)} />\n<C product={product} productName={product.name} />\n\n// ✅ Good\n<C users={users} />\n<C product={product} />\n// Do filtering/destructuring in client\n```\n\n**Exception:** Pass derived data when transformation is expensive or client doesn't need original.\n\n### 3.3 Cross-Request LRU Caching\n\n**Impact: HIGH (caches across requests)**\n\n`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.\n\n**Implementation:**\n\n```typescript\nimport { LRUCache } from 'lru-cache'\n\nconst cache = new LRUCache<string, any>({\n  max: 1000,\n  ttl: 5 * 60 * 1000  // 5 minutes\n})\n\nexport async function getUser(id: string) {\n  const cached = cache.get(id)\n  if (cached) return cached\n\n  const user = await db.user.findUnique({ where: { id } })\n  cache.set(id, user)\n  return user\n}\n\n// Request 1: DB query, result cached\n// Request 2: cache hit, no DB query\n```\n\nUse when sequential user actions hit multiple endpoints needing the same data within seconds.\n\n**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.\n\n**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.\n\nReference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)\n\n### 3.4 Minimize Serialization at RSC Boundaries\n\n**Impact: HIGH (reduces data transfer size)**\n\nThe React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.\n\n**Incorrect: serializes all 50 fields**\n\n```tsx\nasync function Page() {\n  const user = await fetchUser()  // 50 fields\n  return <Profile user={user} />\n}\n\n'use client'\nfunction Profile({ user }: { user: User }) {\n  return <div>{user.name}</div>  // uses 1 field\n}\n```\n\n**Correct: serializes only 1 field**\n\n```tsx\nasync function Page() {\n  const user = await fetchUser()\n  return <Profile name={user.name} />\n}\n\n'use client'\nfunction Profile({ name }: { name: string }) {\n  return <div>{name}</div>\n}\n```\n\n### 3.5 Parallel Data Fetching with Component Composition\n\n**Impact: CRITICAL (eliminates server-side waterfalls)**\n\nReact Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.\n\n**Incorrect: Sidebar waits for Page's fetch to complete**\n\n```tsx\nexport default async function Page() {\n  const header = await fetchHeader()\n  return (\n    <div>\n      <div>{header}</div>\n      <Sidebar />\n    </div>\n  )\n}\n\nasync function Sidebar() {\n  const items = await fetchSidebarItems()\n  return <nav>{items.map(renderItem)}</nav>\n}\n```\n\n**Correct: both fetch simultaneously**\n\n```tsx\nasync function Header() {\n  const data = await fetchHeader()\n  return <div>{data}</div>\n}\n\nasync function Sidebar() {\n  const items = await fetchSidebarItems()\n  return <nav>{items.map(renderItem)}</nav>\n}\n\nexport default function Page() {\n  return (\n    <div>\n      <Header />\n      <Sidebar />\n    </div>\n  )\n}\n```\n\n**Alternative with children prop:**\n\n```tsx\nasync function Header() {\n  const data = await fetchHeader()\n  return <div>{data}</div>\n}\n\nasync function Sidebar() {\n  const items = await fetchSidebarItems()\n  return <nav>{items.map(renderItem)}</nav>\n}\n\nfunction Layout({ children }: { children: ReactNode }) {\n  return (\n    <div>\n      <Header />\n      {children}\n    </div>\n  )\n}\n\nexport default function Page() {\n  return (\n    <Layout>\n      <Sidebar />\n    </Layout>\n  )\n}\n```\n\n### 3.6 Per-Request Deduplication with React.cache()\n\n**Impact: MEDIUM (deduplicates within request)**\n\nUse `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.\n\n**Usage:**\n\n```typescript\nimport { cache } from 'react'\n\nexport const getCurrentUser = cache(async () => {\n  const session = await auth()\n  if (!session?.user?.id) return null\n  return await db.user.findUnique({\n    where: { id: session.user.id }\n  })\n})\n```\n\nWithin a single request, multiple calls to `getCurrentUser()` execute the query only once.\n\n**Avoid inline objects as arguments:**\n\n`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.\n\n**Incorrect: always cache miss**\n\n```typescript\nconst getUser = cache(async (params: { uid: number }) => {\n  return await db.user.findUnique({ where: { id: params.uid } })\n})\n\n// Each call creates new object, never hits cache\ngetUser({ uid: 1 })\ngetUser({ uid: 1 })  // Cache miss, runs query again\n```\n\n**Correct: cache hit**\n\n```typescript\nconst params = { uid: 1 }\ngetUser(params)  // Query runs\ngetUser(params)  // Cache hit (same reference)\n```\n\nIf you must pass objects, pass the same reference:\n\n**Next.js-Specific Note:**\n\nIn Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:\n\n- Database queries (Prisma, Drizzle, etc.)\n\n- Heavy computations\n\n- Authentication checks\n\n- File system operations\n\n- Any non-fetch async work\n\nUse `React.cache()` to deduplicate these operations across your component tree.\n\nReference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache)\n\n### 3.7 Use after() for Non-Blocking Operations\n\n**Impact: MEDIUM (faster response times)**\n\nUse Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.\n\n**Incorrect: blocks response**\n\n```tsx\nimport { logUserAction } from '@/app/utils'\n\nexport async function POST(request: Request) {\n  // Perform mutation\n  await updateDatabase(request)\n  \n  // Logging blocks the response\n  const userAgent = request.headers.get('user-agent') || 'unknown'\n  await logUserAction({ userAgent })\n  \n  return new Response(JSON.stringify({ status: 'success' }), {\n    status: 200,\n    headers: { 'Content-Type': 'application/json' }\n  })\n}\n```\n\n**Correct: non-blocking**\n\n```tsx\nimport { after } from 'next/server'\nimport { headers, cookies } from 'next/headers'\nimport { logUserAction } from '@/app/utils'\n\nexport async function POST(request: Request) {\n  // Perform mutation\n  await updateDatabase(request)\n  \n  // Log after response is sent\n  after(async () => {\n    const userAgent = (await headers()).get('user-agent') || 'unknown'\n    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'\n    \n    logUserAction({ sessionCookie, userAgent })\n  })\n  \n  return new Response(JSON.stringify({ status: 'success' }), {\n    status: 200,\n    headers: { 'Content-Type': 'application/json' }\n  })\n}\n```\n\nThe response is sent immediately while logging happens in the background.\n\n**Common use cases:**\n\n- Analytics tracking\n\n- Audit logging\n\n- Sending notifications\n\n- Cache invalidation\n\n- Cleanup tasks\n\n**Important notes:**\n\n- `after()` runs even if the response fails or redirects\n\n- Works in Server Actions, Route Handlers, and Server Components\n\nReference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)\n\n---\n\n## 4. Client-Side Data Fetching\n\n**Impact: MEDIUM-HIGH**\n\nAutomatic deduplication and efficient data fetching patterns reduce redundant network requests.\n\n### 4.1 Deduplicate Global Event Listeners\n\n**Impact: LOW (single listener for N components)**\n\nUse `useSWRSubscription()` to share global event listeners across component instances.\n\n**Incorrect: N instances = N listeners**\n\n```tsx\nfunction useKeyboardShortcut(key: string, callback: () => void) {\n  useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      if (e.metaKey && e.key === key) {\n        callback()\n      }\n    }\n    window.addEventListener('keydown', handler)\n    return () => window.removeEventListener('keydown', handler)\n  }, [key, callback])\n}\n```\n\nWhen using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.\n\n**Correct: N instances = 1 listener**\n\n```tsx\nimport useSWRSubscription from 'swr/subscription'\n\n// Module-level Map to track callbacks per key\nconst keyCallbacks = new Map<string, Set<() => void>>()\n\nfunction useKeyboardShortcut(key: string, callback: () => void) {\n  // Register this callback in the Map\n  useEffect(() => {\n    if (!keyCallbacks.has(key)) {\n      keyCallbacks.set(key, new Set())\n    }\n    keyCallbacks.get(key)!.add(callback)\n\n    return () => {\n      const set = keyCallbacks.get(key)\n      if (set) {\n        set.delete(callback)\n        if (set.size === 0) {\n          keyCallbacks.delete(key)\n        }\n      }\n    }\n  }, [key, callback])\n\n  useSWRSubscription('global-keydown', () => {\n    const handler = (e: KeyboardEvent) => {\n      if (e.metaKey && keyCallbacks.has(e.key)) {\n        keyCallbacks.get(e.key)!.forEach(cb => cb())\n      }\n    }\n    window.addEventListener('keydown', handler)\n    return () => window.removeEventListener('keydown', handler)\n  })\n}\n\nfunction Profile() {\n  // Multiple shortcuts will share the same listener\n  useKeyboardShortcut('p', () => { /* ... */ }) \n  useKeyboardShortcut('k', () => { /* ... */ })\n  // ...\n}\n```\n\n### 4.2 Use Passive Event Listeners for Scrolling Performance\n\n**Impact: MEDIUM (eliminates scroll delay caused by event listeners)**\n\nAdd `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.\n\n**Incorrect:**\n\n```typescript\nuseEffect(() => {\n  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)\n  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)\n  \n  document.addEventListener('touchstart', handleTouch)\n  document.addEventListener('wheel', handleWheel)\n  \n  return () => {\n    document.removeEventListener('touchstart', handleTouch)\n    document.removeEventListener('wheel', handleWheel)\n  }\n}, [])\n```\n\n**Correct:**\n\n```typescript\nuseEffect(() => {\n  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)\n  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)\n  \n  document.addEventListener('touchstart', handleTouch, { passive: true })\n  document.addEventListener('wheel', handleWheel, { passive: true })\n  \n  return () => {\n    document.removeEventListener('touchstart', handleTouch)\n    document.removeEventListener('wheel', handleWheel)\n  }\n}, [])\n```\n\n**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.\n\n**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.\n\n### 4.3 Use SWR for Automatic Deduplication\n\n**Impact: MEDIUM-HIGH (automatic deduplication)**\n\nSWR enables request deduplication, caching, and revalidation across component instances.\n\n**Incorrect: no deduplication, each instance fetches**\n\n```tsx\nfunction UserList() {\n  const [users, setUsers] = useState([])\n  useEffect(() => {\n    fetch('/api/users')\n      .then(r => r.json())\n      .then(setUsers)\n  }, [])\n}\n```\n\n**Correct: multiple instances share one request**\n\n```tsx\nimport useSWR from 'swr'\n\nfunction UserList() {\n  const { data: users } = useSWR('/api/users', fetcher)\n}\n```\n\n**For immutable data:**\n\n```tsx\nimport { useImmutableSWR } from '@/lib/swr'\n\nfunction StaticContent() {\n  const { data } = useImmutableSWR('/api/config', fetcher)\n}\n```\n\n**For mutations:**\n\n```tsx\nimport { useSWRMutation } from 'swr/mutation'\n\nfunction UpdateButton() {\n  const { trigger } = useSWRMutation('/api/user', updateUser)\n  return <button onClick={() => trigger()}>Update</button>\n}\n```\n\nReference: [https://swr.vercel.app](https://swr.vercel.app)\n\n### 4.4 Version and Minimize localStorage Data\n\n**Impact: MEDIUM (prevents schema conflicts, reduces storage size)**\n\nAdd version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.\n\n**Incorrect:**\n\n```typescript\n// No version, stores everything, no error handling\nlocalStorage.setItem('userConfig', JSON.stringify(fullUserObject))\nconst data = localStorage.getItem('userConfig')\n```\n\n**Correct:**\n\n```typescript\nconst VERSION = 'v2'\n\nfunction saveConfig(config: { theme: string; language: string }) {\n  try {\n    localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))\n  } catch {\n    // Throws in incognito/private browsing, quota exceeded, or disabled\n  }\n}\n\nfunction loadConfig() {\n  try {\n    const data = localStorage.getItem(`userConfig:${VERSION}`)\n    return data ? JSON.parse(data) : null\n  } catch {\n    return null\n  }\n}\n\n// Migration from v1 to v2\nfunction migrate() {\n  try {\n    const v1 = localStorage.getItem('userConfig:v1')\n    if (v1) {\n      const old = JSON.parse(v1)\n      saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })\n      localStorage.removeItem('userConfig:v1')\n    }\n  } catch {}\n}\n```\n\n**Store minimal fields from server responses:**\n\n```typescript\n// User object has 20+ fields, only store what UI needs\nfunction cachePrefs(user: FullUser) {\n  try {\n    localStorage.setItem('prefs:v1', JSON.stringify({\n      theme: user.preferences.theme,\n      notifications: user.preferences.notifications\n    }))\n  } catch {}\n}\n```\n\n**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.\n\n**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.\n\n---\n\n## 5. Re-render Optimization\n\n**Impact: MEDIUM**\n\nReducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.\n\n### 5.1 Calculate Derived State During Rendering\n\n**Impact: MEDIUM (avoids redundant renders and state drift)**\n\nIf a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.\n\n**Incorrect: redundant state and effect**\n\n```tsx\nfunction Form() {\n  const [firstName, setFirstName] = useState('First')\n  const [lastName, setLastName] = useState('Last')\n  const [fullName, setFullName] = useState('')\n\n  useEffect(() => {\n    setFullName(firstName + ' ' + lastName)\n  }, [firstName, lastName])\n\n  return <p>{fullName}</p>\n}\n```\n\n**Correct: derive during render**\n\n```tsx\nfunction Form() {\n  const [firstName, setFirstName] = useState('First')\n  const [lastName, setLastName] = useState('Last')\n  const fullName = firstName + ' ' + lastName\n\n  return <p>{fullName}</p>\n}\n```\n\nReference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect)\n\n### 5.2 Defer State Reads to Usage Point\n\n**Impact: MEDIUM (avoids unnecessary subscriptions)**\n\nDon't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.\n\n**Incorrect: subscribes to all searchParams changes**\n\n```tsx\nfunction ShareButton({ chatId }: { chatId: string }) {\n  const searchParams = useSearchParams()\n\n  const handleShare = () => {\n    const ref = searchParams.get('ref')\n    shareChat(chatId, { ref })\n  }\n\n  return <button onClick={handleShare}>Share</button>\n}\n```\n\n**Correct: reads on demand, no subscription**\n\n```tsx\nfunction ShareButton({ chatId }: { chatId: string }) {\n  const handleShare = () => {\n    const params = new URLSearchParams(window.location.search)\n    const ref = params.get('ref')\n    shareChat(chatId, { ref })\n  }\n\n  return <button onClick={handleShare}>Share</button>\n}\n```\n\n### 5.3 Do not wrap a simple expression with a primitive result type in useMemo\n\n**Impact: LOW-MEDIUM (wasted computation on every render)**\n\nWhen an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.\n\nCalling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.\n\n**Incorrect:**\n\n```tsx\nfunction Header({ user, notifications }: Props) {\n  const isLoading = useMemo(() => {\n    return user.isLoading || notifications.isLoading\n  }, [user.isLoading, notifications.isLoading])\n\n  if (isLoading) return <Skeleton />\n  // return some markup\n}\n```\n\n**Correct:**\n\n```tsx\nfunction Header({ user, notifications }: Props) {\n  const isLoading = user.isLoading || notifications.isLoading\n\n  if (isLoading) return <Skeleton />\n  // return some markup\n}\n```\n\n### 5.4 Extract Default Non-primitive Parameter Value from Memoized Component to Constant\n\n**Impact: MEDIUM (restores memoization by using a constant for default value)**\n\nWhen memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.\n\nTo address this issue, extract the default value into a constant.\n\n**Incorrect: `onClick` has different values on every rerender**\n\n```tsx\nconst UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {\n  // ...\n})\n\n// Used without optional onClick\n<UserAvatar />\n```\n\n**Correct: stable default value**\n\n```tsx\nconst NOOP = () => {};\n\nconst UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {\n  // ...\n})\n\n// Used without optional onClick\n<UserAvatar />\n```\n\n### 5.5 Extract to Memoized Components\n\n**Impact: MEDIUM (enables early returns)**\n\nExtract expensive work into memoized components to enable early returns before computation.\n\n**Incorrect: computes avatar even when loading**\n\n```tsx\nfunction Profile({ user, loading }: Props) {\n  const avatar = useMemo(() => {\n    const id = computeAvatarId(user)\n    return <Avatar id={id} />\n  }, [user])\n\n  if (loading) return <Skeleton />\n  return <div>{avatar}</div>\n}\n```\n\n**Correct: skips computation when loading**\n\n```tsx\nconst UserAvatar = memo(function UserAvatar({ user }: { user: User }) {\n  const id = useMemo(() => computeAvatarId(user), [user])\n  return <Avatar id={id} />\n})\n\nfunction Profile({ user, loading }: Props) {\n  if (loading) return <Skeleton />\n  return (\n    <div>\n      <UserAvatar user={user} />\n    </div>\n  )\n}\n```\n\n**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.\n\n### 5.6 Narrow Effect Dependencies\n\n**Impact: LOW (minimizes effect re-runs)**\n\nSpecify primitive dependencies instead of objects to minimize effect re-runs.\n\n**Incorrect: re-runs on any user field change**\n\n```tsx\nuseEffect(() => {\n  console.log(user.id)\n}, [user])\n```\n\n**Correct: re-runs only when id changes**\n\n```tsx\nuseEffect(() => {\n  console.log(user.id)\n}, [user.id])\n```\n\n**For derived state, compute outside effect:**\n\n```tsx\n// Incorrect: runs on width=767, 766, 765...\nuseEffect(() => {\n  if (width < 768) {\n    enableMobileMode()\n  }\n}, [width])\n\n// Correct: runs only on boolean transition\nconst isMobile = width < 768\nuseEffect(() => {\n  if (isMobile) {\n    enableMobileMode()\n  }\n}, [isMobile])\n```\n\n### 5.7 Put Interaction Logic in Event Handlers\n\n**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)**\n\nIf a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.\n\n**Incorrect: event modeled as state + effect**\n\n```tsx\nfunction Form() {\n  const [submitted, setSubmitted] = useState(false)\n  const theme = useContext(ThemeContext)\n\n  useEffect(() => {\n    if (submitted) {\n      post('/api/register')\n      showToast('Registered', theme)\n    }\n  }, [submitted, theme])\n\n  return <button onClick={() => setSubmitted(true)}>Submit</button>\n}\n```\n\n**Correct: do it in the handler**\n\n```tsx\nfunction Form() {\n  const theme = useContext(ThemeContext)\n\n  function handleSubmit() {\n    post('/api/register')\n    showToast('Registered', theme)\n  }\n\n  return <button onClick={handleSubmit}>Submit</button>\n}\n```\n\nReference: [https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)\n\n### 5.8 Subscribe to Derived State\n\n**Impact: MEDIUM (reduces re-render frequency)**\n\nSubscribe to derived boolean state instead of continuous values to reduce re-render frequency.\n\n**Incorrect: re-renders on every pixel change**\n\n```tsx\nfunction Sidebar() {\n  const width = useWindowWidth()  // updates continuously\n  const isMobile = width < 768\n  return <nav className={isMobile ? 'mobile' : 'desktop'} />\n}\n```\n\n**Correct: re-renders only when boolean changes**\n\n```tsx\nfunction Sidebar() {\n  const isMobile = useMediaQuery('(max-width: 767px)')\n  return <nav className={isMobile ? 'mobile' : 'desktop'} />\n}\n```\n\n### 5.9 Use Functional setState Updates\n\n**Impact: MEDIUM (prevents stale closures and unnecessary callback recreations)**\n\nWhen updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.\n\n**Incorrect: requires state as dependency**\n\n```tsx\nfunction TodoList() {\n  const [items, setItems] = useState(initialItems)\n  \n  // Callback must depend on items, recreated on every items change\n  const addItems = useCallback((newItems: Item[]) => {\n    setItems([...items, ...newItems])\n  }, [items])  // ❌ items dependency causes recreations\n  \n  // Risk of stale closure if dependency is forgotten\n  const removeItem = useCallback((id: string) => {\n    setItems(items.filter(item => item.id !== id))\n  }, [])  // ❌ Missing items dependency - will use stale items!\n  \n  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />\n}\n```\n\nThe first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.\n\n**Correct: stable callbacks, no stale closures**\n\n```tsx\nfunction TodoList() {\n  const [items, setItems] = useState(initialItems)\n  \n  // Stable callback, never recreated\n  const addItems = useCallback((newItems: Item[]) => {\n    setItems(curr => [...curr, ...newItems])\n  }, [])  // ✅ No dependencies needed\n  \n  // Always uses latest state, no stale closure risk\n  const removeItem = useCallback((id: string) => {\n    setItems(curr => curr.filter(item => item.id !== id))\n  }, [])  // ✅ Safe and stable\n  \n  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />\n}\n```\n\n**Benefits:**\n\n1. **Stable callback references** - Callbacks don't need to be recreated when state changes\n\n2. **No stale closures** - Always operates on the latest state value\n\n3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks\n\n4. **Prevents bugs** - Eliminates the most common source of React closure bugs\n\n**When to use functional updates:**\n\n- Any setState that depends on the current state value\n\n- Inside useCallback/useMemo when state is needed\n\n- Event handlers that reference state\n\n- Async operations that update state\n\n**When direct updates are fine:**\n\n- Setting state to a static value: `setCount(0)`\n\n- Setting state from props/arguments only: `setName(newName)`\n\n- State doesn't depend on previous value\n\n**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.\n\n### 5.10 Use Lazy State Initialization\n\n**Impact: MEDIUM (wasted computation on every render)**\n\nPass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.\n\n**Incorrect: runs on every render**\n\n```tsx\nfunction FilteredList({ items }: { items: Item[] }) {\n  // buildSearchIndex() runs on EVERY render, even after initialization\n  const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))\n  const [query, setQuery] = useState('')\n  \n  // When query changes, buildSearchIndex runs again unnecessarily\n  return <SearchResults index={searchIndex} query={query} />\n}\n\nfunction UserProfile() {\n  // JSON.parse runs on every render\n  const [settings, setSettings] = useState(\n    JSON.parse(localStorage.getItem('settings') || '{}')\n  )\n  \n  return <SettingsForm settings={settings} onChange={setSettings} />\n}\n```\n\n**Correct: runs only once**\n\n```tsx\nfunction FilteredList({ items }: { items: Item[] }) {\n  // buildSearchIndex() runs ONLY on initial render\n  const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))\n  const [query, setQuery] = useState('')\n  \n  return <SearchResults index={searchIndex} query={query} />\n}\n\nfunction UserProfile() {\n  // JSON.parse runs only on initial render\n  const [settings, setSettings] = useState(() => {\n    const stored = localStorage.getItem('settings')\n    return stored ? JSON.parse(stored) : {}\n  })\n  \n  return <SettingsForm settings={settings} onChange={setSettings} />\n}\n```\n\nUse lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.\n\nFor simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.\n\n### 5.11 Use Transitions for Non-Urgent Updates\n\n**Impact: MEDIUM (maintains UI responsiveness)**\n\nMark frequent, non-urgent state updates as transitions to maintain UI responsiveness.\n\n**Incorrect: blocks UI on every scroll**\n\n```tsx\nfunction ScrollTracker() {\n  const [scrollY, setScrollY] = useState(0)\n  useEffect(() => {\n    const handler = () => setScrollY(window.scrollY)\n    window.addEventListener('scroll', handler, { passive: true })\n    return () => window.removeEventListener('scroll', handler)\n  }, [])\n}\n```\n\n**Correct: non-blocking updates**\n\n```tsx\nimport { startTransition } from 'react'\n\nfunction ScrollTracker() {\n  const [scrollY, setScrollY] = useState(0)\n  useEffect(() => {\n    const handler = () => {\n      startTransition(() => setScrollY(window.scrollY))\n    }\n    window.addEventListener('scroll', handler, { passive: true })\n    return () => window.removeEventListener('scroll', handler)\n  }, [])\n}\n```\n\n### 5.12 Use useRef for Transient Values\n\n**Impact: MEDIUM (avoids unnecessary re-renders on frequent updates)**\n\nWhen a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.\n\n**Incorrect: renders every update**\n\n```tsx\nfunction Tracker() {\n  const [lastX, setLastX] = useState(0)\n\n  useEffect(() => {\n    const onMove = (e: MouseEvent) => setLastX(e.clientX)\n    window.addEventListener('mousemove', onMove)\n    return () => window.removeEventListener('mousemove', onMove)\n  }, [])\n\n  return (\n    <div\n      style={{\n        position: 'fixed',\n        top: 0,\n        left: lastX,\n        width: 8,\n        height: 8,\n        background: 'black',\n      }}\n    />\n  )\n}\n```\n\n**Correct: no re-render for tracking**\n\n```tsx\nfunction Tracker() {\n  const lastXRef = useRef(0)\n  const dotRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    const onMove = (e: MouseEvent) => {\n      lastXRef.current = e.clientX\n      const node = dotRef.current\n      if (node) {\n        node.style.transform = `translateX(${e.clientX}px)`\n      }\n    }\n    window.addEventListener('mousemove', onMove)\n    return () => window.removeEventListener('mousemove', onMove)\n  }, [])\n\n  return (\n    <div\n      ref={dotRef}\n      style={{\n        position: 'fixed',\n        top: 0,\n        left: 0,\n        width: 8,\n        height: 8,\n        background: 'black',\n        transform: 'translateX(0px)',\n      }}\n    />\n  )\n}\n```\n\n---\n\n## 6. Rendering Performance\n\n**Impact: MEDIUM**\n\nOptimizing the rendering process reduces the work the browser needs to do.\n\n### 6.1 Animate SVG Wrapper Instead of SVG Element\n\n**Impact: LOW (enables hardware acceleration)**\n\nMany browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.\n\n**Incorrect: animating SVG directly - no hardware acceleration**\n\n```tsx\nfunction LoadingSpinner() {\n  return (\n    <svg \n      className=\"animate-spin\"\n      width=\"24\" \n      height=\"24\" \n      viewBox=\"0 0 24 24\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" />\n    </svg>\n  )\n}\n```\n\n**Correct: animating wrapper div - hardware accelerated**\n\n```tsx\nfunction LoadingSpinner() {\n  return (\n    <div className=\"animate-spin\">\n      <svg \n        width=\"24\" \n        height=\"24\" \n        viewBox=\"0 0 24 24\"\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" />\n      </svg>\n    </div>\n  )\n}\n```\n\nThis applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.\n\n### 6.2 CSS content-visibility for Long Lists\n\n**Impact: HIGH (faster initial render)**\n\nApply `content-visibility: auto` to defer off-screen rendering.\n\n**CSS:**\n\n```css\n.message-item {\n  content-visibility: auto;\n  contain-intrinsic-size: 0 80px;\n}\n```\n\n**Example:**\n\n```tsx\nfunction MessageList({ messages }: { messages: Message[] }) {\n  return (\n    <div className=\"overflow-y-auto h-screen\">\n      {messages.map(msg => (\n        <div key={msg.id} className=\"message-item\">\n          <Avatar user={msg.author} />\n          <div>{msg.content}</div>\n        </div>\n      ))}\n    </div>\n  )\n}\n```\n\nFor 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).\n\n### 6.3 Hoist Static JSX Elements\n\n**Impact: LOW (avoids re-creation)**\n\nExtract static JSX outside components to avoid re-creation.\n\n**Incorrect: recreates element every render**\n\n```tsx\nfunction LoadingSkeleton() {\n  return <div className=\"animate-pulse h-20 bg-gray-200\" />\n}\n\nfunction Container() {\n  return (\n    <div>\n      {loading && <LoadingSkeleton />}\n    </div>\n  )\n}\n```\n\n**Correct: reuses same element**\n\n```tsx\nconst loadingSkeleton = (\n  <div className=\"animate-pulse h-20 bg-gray-200\" />\n)\n\nfunction Container() {\n  return (\n    <div>\n      {loading && loadingSkeleton}\n    </div>\n  )\n}\n```\n\nThis is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.\n\n**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.\n\n### 6.4 Optimize SVG Precision\n\n**Impact: LOW (reduces file size)**\n\nReduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.\n\n**Incorrect: excessive precision**\n\n```svg\n<path d=\"M 10.293847 20.847362 L 30.938472 40.192837\" />\n```\n\n**Correct: 1 decimal place**\n\n```svg\n<path d=\"M 10.3 20.8 L 30.9 40.2\" />\n```\n\n**Automate with SVGO:**\n\n```bash\nnpx svgo --precision=1 --multipass icon.svg\n```\n\n### 6.5 Prevent Hydration Mismatch Without Flickering\n\n**Impact: MEDIUM (avoids visual flicker and hydration errors)**\n\nWhen rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.\n\n**Incorrect: breaks SSR**\n\n```tsx\nfunction ThemeWrapper({ children }: { children: ReactNode }) {\n  // localStorage is not available on server - throws error\n  const theme = localStorage.getItem('theme') || 'light'\n  \n  return (\n    <div className={theme}>\n      {children}\n    </div>\n  )\n}\n```\n\nServer-side rendering will fail because `localStorage` is undefined.\n\n**Incorrect: visual flickering**\n\n```tsx\nfunction ThemeWrapper({ children }: { children: ReactNode }) {\n  const [theme, setTheme] = useState('light')\n  \n  useEffect(() => {\n    // Runs after hydration - causes visible flash\n    const stored = localStorage.getItem('theme')\n    if (stored) {\n      setTheme(stored)\n    }\n  }, [])\n  \n  return (\n    <div className={theme}>\n      {children}\n    </div>\n  )\n}\n```\n\nComponent first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.\n\n**Correct: no flicker, no hydration mismatch**\n\n```tsx\nfunction ThemeWrapper({ children }: { children: ReactNode }) {\n  return (\n    <>\n      <div id=\"theme-wrapper\">\n        {children}\n      </div>\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n            (function() {\n              try {\n                var theme = localStorage.getItem('theme') || 'light';\n                var el = document.getElementById('theme-wrapper');\n                if (el) el.className = theme;\n              } catch (e) {}\n            })();\n          `,\n        }}\n      />\n    </>\n  )\n}\n```\n\nThe inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.\n\nThis pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.\n\n### 6.6 Suppress Expected Hydration Mismatches\n\n**Impact: LOW-MEDIUM (avoids noisy hydration warnings for known differences)**\n\nIn SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.\n\n**Incorrect: known mismatch warnings**\n\n```tsx\nfunction Timestamp() {\n  return <span>{new Date().toLocaleString()}</span>\n}\n```\n\n**Correct: suppress expected mismatch only**\n\n```tsx\nfunction Timestamp() {\n  return (\n    <span suppressHydrationWarning>\n      {new Date().toLocaleString()}\n    </span>\n  )\n}\n```\n\n### 6.7 Use Activity Component for Show/Hide\n\n**Impact: MEDIUM (preserves state/DOM)**\n\nUse React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.\n\n**Usage:**\n\n```tsx\nimport { Activity } from 'react'\n\nfunction Dropdown({ isOpen }: Props) {\n  return (\n    <Activity mode={isOpen ? 'visible' : 'hidden'}>\n      <ExpensiveMenu />\n    </Activity>\n  )\n}\n```\n\nAvoids expensive re-renders and state loss.\n\n### 6.8 Use Explicit Conditional Rendering\n\n**Impact: LOW (prevents rendering 0 or NaN)**\n\nUse explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.\n\n**Incorrect: renders \"0\" when count is 0**\n\n```tsx\nfunction Badge({ count }: { count: number }) {\n  return (\n    <div>\n      {count && <span className=\"badge\">{count}</span>}\n    </div>\n  )\n}\n\n// When count = 0, renders: <div>0</div>\n// When count = 5, renders: <div><span class=\"badge\">5</span></div>\n```\n\n**Correct: renders nothing when count is 0**\n\n```tsx\nfunction Badge({ count }: { count: number }) {\n  return (\n    <div>\n      {count > 0 ? <span className=\"badge\">{count}</span> : null}\n    </div>\n  )\n}\n\n// When count = 0, renders: <div></div>\n// When count = 5, renders: <div><span class=\"badge\">5</span></div>\n```\n\n### 6.9 Use useTransition Over Manual Loading States\n\n**Impact: LOW (reduces re-renders and improves code clarity)**\n\nUse `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.\n\n**Incorrect: manual loading state**\n\n```tsx\nfunction SearchResults() {\n  const [query, setQuery] = useState('')\n  const [results, setResults] = useState([])\n  const [isLoading, setIsLoading] = useState(false)\n\n  const handleSearch = async (value: string) => {\n    setIsLoading(true)\n    setQuery(value)\n    const data = await fetchResults(value)\n    setResults(data)\n    setIsLoading(false)\n  }\n\n  return (\n    <>\n      <input onChange={(e) => handleSearch(e.target.value)} />\n      {isLoading && <Spinner />}\n      <ResultsList results={results} />\n    </>\n  )\n}\n```\n\n**Correct: useTransition with built-in pending state**\n\n```tsx\nimport { useTransition, useState } from 'react'\n\nfunction SearchResults() {\n  const [query, setQuery] = useState('')\n  const [results, setResults] = useState([])\n  const [isPending, startTransition] = useTransition()\n\n  const handleSearch = (value: string) => {\n    setQuery(value) // Update input immediately\n    \n    startTransition(async () => {\n      // Fetch and update results\n      const data = await fetchResults(value)\n      setResults(data)\n    })\n  }\n\n  return (\n    <>\n      <input onChange={(e) => handleSearch(e.target.value)} />\n      {isPending && <Spinner />}\n      <ResultsList results={results} />\n    </>\n  )\n}\n```\n\n**Benefits:**\n\n- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`\n\n- **Error resilience**: Pending state correctly resets even if the transition throws\n\n- **Better responsiveness**: Keeps the UI responsive during updates\n\n- **Interrupt handling**: New transitions automatically cancel pending ones\n\nReference: [https://react.dev/reference/react/useTransition](https://react.dev/reference/react/useTransition)\n\n---\n\n## 7. JavaScript Performance\n\n**Impact: LOW-MEDIUM**\n\nMicro-optimizations for hot paths can add up to meaningful improvements.\n\n### 7.1 Avoid Layout Thrashing\n\n**Impact: MEDIUM (prevents forced synchronous layouts and reduces performance bottlenecks)**\n\nAvoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.\n\n**This is OK: browser batches style changes**\n\n```typescript\nfunction updateElementStyles(element: HTMLElement) {\n  // Each line invalidates style, but browser batches the recalculation\n  element.style.width = '100px'\n  element.style.height = '200px'\n  element.style.backgroundColor = 'blue'\n  element.style.border = '1px solid black'\n}\n```\n\n**Incorrect: interleaved reads and writes force reflows**\n\n```typescript\nfunction layoutThrashing(element: HTMLElement) {\n  element.style.width = '100px'\n  const width = element.offsetWidth  // Forces reflow\n  element.style.height = '200px'\n  const height = element.offsetHeight  // Forces another reflow\n}\n```\n\n**Correct: batch writes, then read once**\n\n```typescript\nfunction updateElementStyles(element: HTMLElement) {\n  // Batch all writes together\n  element.style.width = '100px'\n  element.style.height = '200px'\n  element.style.backgroundColor = 'blue'\n  element.style.border = '1px solid black'\n  \n  // Read after all writes are done (single reflow)\n  const { width, height } = element.getBoundingClientRect()\n}\n```\n\n**Correct: batch reads, then writes**\n\n```typescript\nfunction updateElementStyles(element: HTMLElement) {\n  element.classList.add('highlighted-box')\n  \n  const { width, height } = element.getBoundingClientRect()\n}\n```\n\n**Better: use CSS classes**\n\n**React example:**\n\n```tsx\n// Incorrect: interleaving style changes with layout queries\nfunction Box({ isHighlighted }: { isHighlighted: boolean }) {\n  const ref = useRef<HTMLDivElement>(null)\n  \n  useEffect(() => {\n    if (ref.current && isHighlighted) {\n      ref.current.style.width = '100px'\n      const width = ref.current.offsetWidth // Forces layout\n      ref.current.style.height = '200px'\n    }\n  }, [isHighlighted])\n  \n  return <div ref={ref}>Content</div>\n}\n\n// Correct: toggle class\nfunction Box({ isHighlighted }: { isHighlighted: boolean }) {\n  return (\n    <div className={isHighlighted ? 'highlighted-box' : ''}>\n      Content\n    </div>\n  )\n}\n```\n\nPrefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.\n\nSee [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.\n\n### 7.2 Build Index Maps for Repeated Lookups\n\n**Impact: LOW-MEDIUM (1M ops to 2K ops)**\n\nMultiple `.find()` calls by the same key should use a Map.\n\n**Incorrect (O(n) per lookup):**\n\n```typescript\nfunction processOrders(orders: Order[], users: User[]) {\n  return orders.map(order => ({\n    ...order,\n    user: users.find(u => u.id === order.userId)\n  }))\n}\n```\n\n**Correct (O(1) per lookup):**\n\n```typescript\nfunction processOrders(orders: Order[], users: User[]) {\n  const userById = new Map(users.map(u => [u.id, u]))\n\n  return orders.map(order => ({\n    ...order,\n    user: userById.get(order.userId)\n  }))\n}\n```\n\nBuild map once (O(n)), then all lookups are O(1).\n\nFor 1000 orders × 1000 users: 1M ops → 2K ops.\n\n### 7.3 Cache Property Access in Loops\n\n**Impact: LOW-MEDIUM (reduces lookups)**\n\nCache object property lookups in hot paths.\n\n**Incorrect: 3 lookups × N iterations**\n\n```typescript\nfor (let i = 0; i < arr.length; i++) {\n  process(obj.config.settings.value)\n}\n```\n\n**Correct: 1 lookup total**\n\n```typescript\nconst value = obj.config.settings.value\nconst len = arr.length\nfor (let i = 0; i < len; i++) {\n  process(value)\n}\n```\n\n### 7.4 Cache Repeated Function Calls\n\n**Impact: MEDIUM (avoid redundant computation)**\n\nUse a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.\n\n**Incorrect: redundant computation**\n\n```typescript\nfunction ProjectList({ projects }: { projects: Project[] }) {\n  return (\n    <div>\n      {projects.map(project => {\n        // slugify() called 100+ times for same project names\n        const slug = slugify(project.name)\n        \n        return <ProjectCard key={project.id} slug={slug} />\n      })}\n    </div>\n  )\n}\n```\n\n**Correct: cached results**\n\n```typescript\n// Module-level cache\nconst slugifyCache = new Map<string, string>()\n\nfunction cachedSlugify(text: string): string {\n  if (slugifyCache.has(text)) {\n    return slugifyCache.get(text)!\n  }\n  const result = slugify(text)\n  slugifyCache.set(text, result)\n  return result\n}\n\nfunction ProjectList({ projects }: { projects: Project[] }) {\n  return (\n    <div>\n      {projects.map(project => {\n        // Computed only once per unique project name\n        const slug = cachedSlugify(project.name)\n        \n        return <ProjectCard key={project.id} slug={slug} />\n      })}\n    </div>\n  )\n}\n```\n\n**Simpler pattern for single-value functions:**\n\n```typescript\nlet isLoggedInCache: boolean | null = null\n\nfunction isLoggedIn(): boolean {\n  if (isLoggedInCache !== null) {\n    return isLoggedInCache\n  }\n  \n  isLoggedInCache = document.cookie.includes('auth=')\n  return isLoggedInCache\n}\n\n// Clear cache when auth changes\nfunction onAuthChange() {\n  isLoggedInCache = null\n}\n```\n\nUse a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.\n\nReference: [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)\n\n### 7.5 Cache Storage API Calls\n\n**Impact: LOW-MEDIUM (reduces expensive I/O)**\n\n`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.\n\n**Incorrect: reads storage on every call**\n\n```typescript\nfunction getTheme() {\n  return localStorage.getItem('theme') ?? 'light'\n}\n// Called 10 times = 10 storage reads\n```\n\n**Correct: Map cache**\n\n```typescript\nconst storageCache = new Map<string, string | null>()\n\nfunction getLocalStorage(key: string) {\n  if (!storageCache.has(key)) {\n    storageCache.set(key, localStorage.getItem(key))\n  }\n  return storageCache.get(key)\n}\n\nfunction setLocalStorage(key: string, value: string) {\n  localStorage.setItem(key, value)\n  storageCache.set(key, value)  // keep cache in sync\n}\n```\n\nUse a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.\n\n**Cookie caching:**\n\n```typescript\nlet cookieCache: Record<string, string> | null = null\n\nfunction getCookie(name: string) {\n  if (!cookieCache) {\n    cookieCache = Object.fromEntries(\n      document.cookie.split('; ').map(c => c.split('='))\n    )\n  }\n  return cookieCache[name]\n}\n```\n\n**Important: invalidate on external changes**\n\n```typescript\nwindow.addEventListener('storage', (e) => {\n  if (e.key) storageCache.delete(e.key)\n})\n\ndocument.addEventListener('visibilitychange', () => {\n  if (document.visibilityState === 'visible') {\n    storageCache.clear()\n  }\n})\n```\n\nIf storage can change externally (another tab, server-set cookies), invalidate cache:\n\n### 7.6 Combine Multiple Array Iterations\n\n**Impact: LOW-MEDIUM (reduces iterations)**\n\nMultiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.\n\n**Incorrect: 3 iterations**\n\n```typescript\nconst admins = users.filter(u => u.isAdmin)\nconst testers = users.filter(u => u.isTester)\nconst inactive = users.filter(u => !u.isActive)\n```\n\n**Correct: 1 iteration**\n\n```typescript\nconst admins: User[] = []\nconst testers: User[] = []\nconst inactive: User[] = []\n\nfor (const user of users) {\n  if (user.isAdmin) admins.push(user)\n  if (user.isTester) testers.push(user)\n  if (!user.isActive) inactive.push(user)\n}\n```\n\n### 7.7 Early Length Check for Array Comparisons\n\n**Impact: MEDIUM-HIGH (avoids expensive operations when lengths differ)**\n\nWhen comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.\n\nIn real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).\n\n**Incorrect: always runs expensive comparison**\n\n```typescript\nfunction hasChanges(current: string[], original: string[]) {\n  // Always sorts and joins, even when lengths differ\n  return current.sort().join() !== original.sort().join()\n}\n```\n\nTwo O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.\n\n**Correct (O(1) length check first):**\n\n```typescript\nfunction hasChanges(current: string[], original: string[]) {\n  // Early return if lengths differ\n  if (current.length !== original.length) {\n    return true\n  }\n  // Only sort when lengths match\n  const currentSorted = current.toSorted()\n  const originalSorted = original.toSorted()\n  for (let i = 0; i < currentSorted.length; i++) {\n    if (currentSorted[i] !== originalSorted[i]) {\n      return true\n    }\n  }\n  return false\n}\n```\n\nThis new approach is more efficient because:\n\n- It avoids the overhead of sorting and joining the arrays when lengths differ\n\n- It avoids consuming memory for the joined strings (especially important for large arrays)\n\n- It avoids mutating the original arrays\n\n- It returns early when a difference is found\n\n### 7.8 Early Return from Functions\n\n**Impact: LOW-MEDIUM (avoids unnecessary computation)**\n\nReturn early when result is determined to skip unnecessary processing.\n\n**Incorrect: processes all items even after finding answer**\n\n```typescript\nfunction validateUsers(users: User[]) {\n  let hasError = false\n  let errorMessage = ''\n  \n  for (const user of users) {\n    if (!user.email) {\n      hasError = true\n      errorMessage = 'Email required'\n    }\n    if (!user.name) {\n      hasError = true\n      errorMessage = 'Name required'\n    }\n    // Continues checking all users even after error found\n  }\n  \n  return hasError ? { valid: false, error: errorMessage } : { valid: true }\n}\n```\n\n**Correct: returns immediately on first error**\n\n```typescript\nfunction validateUsers(users: User[]) {\n  for (const user of users) {\n    if (!user.email) {\n      return { valid: false, error: 'Email required' }\n    }\n    if (!user.name) {\n      return { valid: false, error: 'Name required' }\n    }\n  }\n\n  return { valid: true }\n}\n```\n\n### 7.9 Hoist RegExp Creation\n\n**Impact: LOW-MEDIUM (avoids recreation)**\n\nDon't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.\n\n**Incorrect: new RegExp every render**\n\n```tsx\nfunction Highlighter({ text, query }: Props) {\n  const regex = new RegExp(`(${query})`, 'gi')\n  const parts = text.split(regex)\n  return <>{parts.map((part, i) => ...)}</>\n}\n```\n\n**Correct: memoize or hoist**\n\n```tsx\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction Highlighter({ text, query }: Props) {\n  const regex = useMemo(\n    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),\n    [query]\n  )\n  const parts = text.split(regex)\n  return <>{parts.map((part, i) => ...)}</>\n}\n```\n\n**Warning: global regex has mutable state**\n\n```typescript\nconst regex = /foo/g\nregex.test('foo')  // true, lastIndex = 3\nregex.test('foo')  // false, lastIndex = 0\n```\n\nGlobal regex (`/g`) has mutable `lastIndex` state:\n\n### 7.10 Use Loop for Min/Max Instead of Sort\n\n**Impact: LOW (O(n) instead of O(n log n))**\n\nFinding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.\n\n**Incorrect (O(n log n) - sort to find latest):**\n\n```typescript\ninterface Project {\n  id: string\n  name: string\n  updatedAt: number\n}\n\nfunction getLatestProject(projects: Project[]) {\n  const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)\n  return sorted[0]\n}\n```\n\nSorts the entire array just to find the maximum value.\n\n**Incorrect (O(n log n) - sort for oldest and newest):**\n\n```typescript\nfunction getOldestAndNewest(projects: Project[]) {\n  const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)\n  return { oldest: sorted[0], newest: sorted[sorted.length - 1] }\n}\n```\n\nStill sorts unnecessarily when only min/max are needed.\n\n**Correct (O(n) - single loop):**\n\n```typescript\nfunction getLatestProject(projects: Project[]) {\n  if (projects.length === 0) return null\n  \n  let latest = projects[0]\n  \n  for (let i = 1; i < projects.length; i++) {\n    if (projects[i].updatedAt > latest.updatedAt) {\n      latest = projects[i]\n    }\n  }\n  \n  return latest\n}\n\nfunction getOldestAndNewest(projects: Project[]) {\n  if (projects.length === 0) return { oldest: null, newest: null }\n  \n  let oldest = projects[0]\n  let newest = projects[0]\n  \n  for (let i = 1; i < projects.length; i++) {\n    if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]\n    if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]\n  }\n  \n  return { oldest, newest }\n}\n```\n\nSingle pass through the array, no copying, no sorting.\n\n**Alternative: Math.min/Math.max for small arrays**\n\n```typescript\nconst numbers = [5, 2, 8, 1, 9]\nconst min = Math.min(...numbers)\nconst max = Math.max(...numbers)\n```\n\nThis works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.\n\n### 7.11 Use Set/Map for O(1) Lookups\n\n**Impact: LOW-MEDIUM (O(n) to O(1))**\n\nConvert arrays to Set/Map for repeated membership checks.\n\n**Incorrect (O(n) per check):**\n\n```typescript\nconst allowedIds = ['a', 'b', 'c', ...]\nitems.filter(item => allowedIds.includes(item.id))\n```\n\n**Correct (O(1) per check):**\n\n```typescript\nconst allowedIds = new Set(['a', 'b', 'c', ...])\nitems.filter(item => allowedIds.has(item.id))\n```\n\n### 7.12 Use toSorted() Instead of sort() for Immutability\n\n**Impact: MEDIUM-HIGH (prevents mutation bugs in React state)**\n\n`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.\n\n**Incorrect: mutates original array**\n\n```typescript\nfunction UserList({ users }: { users: User[] }) {\n  // Mutates the users prop array!\n  const sorted = useMemo(\n    () => users.sort((a, b) => a.name.localeCompare(b.name)),\n    [users]\n  )\n  return <div>{sorted.map(renderUser)}</div>\n}\n```\n\n**Correct: creates new array**\n\n```typescript\nfunction UserList({ users }: { users: User[] }) {\n  // Creates new sorted array, original unchanged\n  const sorted = useMemo(\n    () => users.toSorted((a, b) => a.name.localeCompare(b.name)),\n    [users]\n  )\n  return <div>{sorted.map(renderUser)}</div>\n}\n```\n\n**Why this matters in React:**\n\n1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only\n\n2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior\n\n**Browser support: fallback for older browsers**\n\n```typescript\n// Fallback for older browsers\nconst sorted = [...items].sort((a, b) => a.value - b.value)\n```\n\n`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:\n\n**Other immutable array methods:**\n\n- `.toSorted()` - immutable sort\n\n- `.toReversed()` - immutable reverse\n\n- `.toSpliced()` - immutable splice\n\n- `.with()` - immutable element replacement\n\n---\n\n## 8. Advanced Patterns\n\n**Impact: LOW**\n\nAdvanced patterns for specific cases that require careful implementation.\n\n### 8.1 Initialize App Once, Not Per Mount\n\n**Impact: LOW-MEDIUM (avoids duplicate init in development)**\n\nDo not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.\n\n**Incorrect: runs twice in dev, re-runs on remount**\n\n```tsx\nfunction Comp() {\n  useEffect(() => {\n    loadFromStorage()\n    checkAuthToken()\n  }, [])\n\n  // ...\n}\n```\n\n**Correct: once per app load**\n\n```tsx\nlet didInit = false\n\nfunction Comp() {\n  useEffect(() => {\n    if (didInit) return\n    didInit = true\n    loadFromStorage()\n    checkAuthToken()\n  }, [])\n\n  // ...\n}\n```\n\nReference: [https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)\n\n### 8.2 Store Event Handlers in Refs\n\n**Impact: LOW (stable subscriptions)**\n\nStore callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.\n\n**Incorrect: re-subscribes on every render**\n\n```tsx\nfunction useWindowEvent(event: string, handler: (e) => void) {\n  useEffect(() => {\n    window.addEventListener(event, handler)\n    return () => window.removeEventListener(event, handler)\n  }, [event, handler])\n}\n```\n\n**Correct: stable subscription**\n\n```tsx\nimport { useEffectEvent } from 'react'\n\nfunction useWindowEvent(event: string, handler: (e) => void) {\n  const onEvent = useEffectEvent(handler)\n\n  useEffect(() => {\n    window.addEventListener(event, onEvent)\n    return () => window.removeEventListener(event, onEvent)\n  }, [event])\n}\n```\n\n**Alternative: use `useEffectEvent` if you're on latest React:**\n\n`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.\n\n### 8.3 useEffectEvent for Stable Callback Refs\n\n**Impact: LOW (prevents effect re-runs)**\n\nAccess latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.\n\n**Incorrect: effect re-runs on every callback change**\n\n```tsx\nfunction SearchInput({ onSearch }: { onSearch: (q: string) => void }) {\n  const [query, setQuery] = useState('')\n\n  useEffect(() => {\n    const timeout = setTimeout(() => onSearch(query), 300)\n    return () => clearTimeout(timeout)\n  }, [query, onSearch])\n}\n```\n\n**Correct: using React's useEffectEvent**\n\n```tsx\nimport { useEffectEvent } from 'react';\n\nfunction SearchInput({ onSearch }: { onSearch: (q: string) => void }) {\n  const [query, setQuery] = useState('')\n  const onSearchEvent = useEffectEvent(onSearch)\n\n  useEffect(() => {\n    const timeout = setTimeout(() => onSearchEvent(query), 300)\n    return () => clearTimeout(timeout)\n  }, [query])\n}\n```\n\n---\n\n## References\n\n1. [https://react.dev](https://react.dev)\n2. [https://nextjs.org](https://nextjs.org)\n3. [https://swr.vercel.app](https://swr.vercel.app)\n4. [https://github.com/shuding/better-all](https://github.com/shuding/better-all)\n5. [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)\n6. [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)\n7. [https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/SKILL.md",
    "content": "---\nname: vercel-react-best-practices\ndescription: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.\nlicense: MIT\nmetadata:\n  author: vercel\n  version: \"1.0.0\"\n---\n\n# Vercel React Best Practices\n\nComprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.\n\n## When to Apply\n\nReference these guidelines when:\n- Writing new React components or Next.js pages\n- Implementing data fetching (client or server-side)\n- Reviewing code for performance issues\n- Refactoring existing React/Next.js code\n- Optimizing bundle size or load times\n\n## Rule Categories by Priority\n\n| Priority | Category | Impact | Prefix |\n|----------|----------|--------|--------|\n| 1 | Eliminating Waterfalls | CRITICAL | `async-` |\n| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |\n| 3 | Server-Side Performance | HIGH | `server-` |\n| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |\n| 5 | Re-render Optimization | MEDIUM | `rerender-` |\n| 6 | Rendering Performance | MEDIUM | `rendering-` |\n| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |\n| 8 | Advanced Patterns | LOW | `advanced-` |\n\n## Quick Reference\n\n### 1. Eliminating Waterfalls (CRITICAL)\n\n- `async-defer-await` - Move await into branches where actually used\n- `async-parallel` - Use Promise.all() for independent operations\n- `async-dependencies` - Use better-all for partial dependencies\n- `async-api-routes` - Start promises early, await late in API routes\n- `async-suspense-boundaries` - Use Suspense to stream content\n\n### 2. Bundle Size Optimization (CRITICAL)\n\n- `bundle-barrel-imports` - Import directly, avoid barrel files\n- `bundle-dynamic-imports` - Use next/dynamic for heavy components\n- `bundle-defer-third-party` - Load analytics/logging after hydration\n- `bundle-conditional` - Load modules only when feature is activated\n- `bundle-preload` - Preload on hover/focus for perceived speed\n\n### 3. Server-Side Performance (HIGH)\n\n- `server-auth-actions` - Authenticate server actions like API routes\n- `server-cache-react` - Use React.cache() for per-request deduplication\n- `server-cache-lru` - Use LRU cache for cross-request caching\n- `server-dedup-props` - Avoid duplicate serialization in RSC props\n- `server-serialization` - Minimize data passed to client components\n- `server-parallel-fetching` - Restructure components to parallelize fetches\n- `server-after-nonblocking` - Use after() for non-blocking operations\n\n### 4. Client-Side Data Fetching (MEDIUM-HIGH)\n\n- `client-swr-dedup` - Use SWR for automatic request deduplication\n- `client-event-listeners` - Deduplicate global event listeners\n- `client-passive-event-listeners` - Use passive listeners for scroll\n- `client-localstorage-schema` - Version and minimize localStorage data\n\n### 5. Re-render Optimization (MEDIUM)\n\n- `rerender-defer-reads` - Don't subscribe to state only used in callbacks\n- `rerender-memo` - Extract expensive work into memoized components\n- `rerender-memo-with-default-value` - Hoist default non-primitive props\n- `rerender-dependencies` - Use primitive dependencies in effects\n- `rerender-derived-state` - Subscribe to derived booleans, not raw values\n- `rerender-derived-state-no-effect` - Derive state during render, not effects\n- `rerender-functional-setstate` - Use functional setState for stable callbacks\n- `rerender-lazy-state-init` - Pass function to useState for expensive values\n- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives\n- `rerender-move-effect-to-event` - Put interaction logic in event handlers\n- `rerender-transitions` - Use startTransition for non-urgent updates\n- `rerender-use-ref-transient-values` - Use refs for transient frequent values\n\n### 6. Rendering Performance (MEDIUM)\n\n- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element\n- `rendering-content-visibility` - Use content-visibility for long lists\n- `rendering-hoist-jsx` - Extract static JSX outside components\n- `rendering-svg-precision` - Reduce SVG coordinate precision\n- `rendering-hydration-no-flicker` - Use inline script for client-only data\n- `rendering-hydration-suppress-warning` - Suppress expected mismatches\n- `rendering-activity` - Use Activity component for show/hide\n- `rendering-conditional-render` - Use ternary, not && for conditionals\n- `rendering-usetransition-loading` - Prefer useTransition for loading state\n\n### 7. JavaScript Performance (LOW-MEDIUM)\n\n- `js-batch-dom-css` - Group CSS changes via classes or cssText\n- `js-index-maps` - Build Map for repeated lookups\n- `js-cache-property-access` - Cache object properties in loops\n- `js-cache-function-results` - Cache function results in module-level Map\n- `js-cache-storage` - Cache localStorage/sessionStorage reads\n- `js-combine-iterations` - Combine multiple filter/map into one loop\n- `js-length-check-first` - Check array length before expensive comparison\n- `js-early-exit` - Return early from functions\n- `js-hoist-regexp` - Hoist RegExp creation outside loops\n- `js-min-max-loop` - Use loop for min/max instead of sort\n- `js-set-map-lookups` - Use Set/Map for O(1) lookups\n- `js-tosorted-immutable` - Use toSorted() for immutability\n\n### 8. Advanced Patterns (LOW)\n\n- `advanced-event-handler-refs` - Store event handlers in refs\n- `advanced-init-once` - Initialize app once per app load\n- `advanced-use-latest` - useLatest for stable callback refs\n\n## How to Use\n\nRead individual rule files for detailed explanations and code examples:\n\n```\nrules/async-parallel.md\nrules/bundle-barrel-imports.md\n```\n\nEach rule file contains:\n- Brief explanation of why it matters\n- Incorrect code example with explanation\n- Correct code example with explanation\n- Additional context and references\n\n## Full Compiled Document\n\nFor the complete guide with all rules expanded: `AGENTS.md`\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md",
    "content": "---\ntitle: Store Event Handlers in Refs\nimpact: LOW\nimpactDescription: stable subscriptions\ntags: advanced, hooks, refs, event-handlers, optimization\n---\n\n## Store Event Handlers in Refs\n\nStore callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.\n\n**Incorrect (re-subscribes on every render):**\n\n```tsx\nfunction useWindowEvent(event: string, handler: (e) => void) {\n  useEffect(() => {\n    window.addEventListener(event, handler)\n    return () => window.removeEventListener(event, handler)\n  }, [event, handler])\n}\n```\n\n**Correct (stable subscription):**\n\n```tsx\nfunction useWindowEvent(event: string, handler: (e) => void) {\n  const handlerRef = useRef(handler)\n  useEffect(() => {\n    handlerRef.current = handler\n  }, [handler])\n\n  useEffect(() => {\n    const listener = (e) => handlerRef.current(e)\n    window.addEventListener(event, listener)\n    return () => window.removeEventListener(event, listener)\n  }, [event])\n}\n```\n\n**Alternative: use `useEffectEvent` if you're on latest React:**\n\n```tsx\nimport { useEffectEvent } from 'react'\n\nfunction useWindowEvent(event: string, handler: (e) => void) {\n  const onEvent = useEffectEvent(handler)\n\n  useEffect(() => {\n    window.addEventListener(event, onEvent)\n    return () => window.removeEventListener(event, onEvent)\n  }, [event])\n}\n```\n\n`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/advanced-init-once.md",
    "content": "---\ntitle: Initialize App Once, Not Per Mount\nimpact: LOW-MEDIUM\nimpactDescription: avoids duplicate init in development\ntags: initialization, useEffect, app-startup, side-effects\n---\n\n## Initialize App Once, Not Per Mount\n\nDo not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.\n\n**Incorrect (runs twice in dev, re-runs on remount):**\n\n```tsx\nfunction Comp() {\n  useEffect(() => {\n    loadFromStorage()\n    checkAuthToken()\n  }, [])\n\n  // ...\n}\n```\n\n**Correct (once per app load):**\n\n```tsx\nlet didInit = false\n\nfunction Comp() {\n  useEffect(() => {\n    if (didInit) return\n    didInit = true\n    loadFromStorage()\n    checkAuthToken()\n  }, [])\n\n  // ...\n}\n```\n\nReference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md",
    "content": "---\ntitle: useEffectEvent for Stable Callback Refs\nimpact: LOW\nimpactDescription: prevents effect re-runs\ntags: advanced, hooks, useEffectEvent, refs, optimization\n---\n\n## useEffectEvent for Stable Callback Refs\n\nAccess latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.\n\n**Incorrect (effect re-runs on every callback change):**\n\n```tsx\nfunction SearchInput({ onSearch }: { onSearch: (q: string) => void }) {\n  const [query, setQuery] = useState('')\n\n  useEffect(() => {\n    const timeout = setTimeout(() => onSearch(query), 300)\n    return () => clearTimeout(timeout)\n  }, [query, onSearch])\n}\n```\n\n**Correct (using React's useEffectEvent):**\n\n```tsx\nimport { useEffectEvent } from 'react';\n\nfunction SearchInput({ onSearch }: { onSearch: (q: string) => void }) {\n  const [query, setQuery] = useState('')\n  const onSearchEvent = useEffectEvent(onSearch)\n\n  useEffect(() => {\n    const timeout = setTimeout(() => onSearchEvent(query), 300)\n    return () => clearTimeout(timeout)\n  }, [query])\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/async-api-routes.md",
    "content": "---\ntitle: Prevent Waterfall Chains in API Routes\nimpact: CRITICAL\nimpactDescription: 2-10× improvement\ntags: api-routes, server-actions, waterfalls, parallelization\n---\n\n## Prevent Waterfall Chains in API Routes\n\nIn API routes and Server Actions, start independent operations immediately, even if you don't await them yet.\n\n**Incorrect (config waits for auth, data waits for both):**\n\n```typescript\nexport async function GET(request: Request) {\n  const session = await auth()\n  const config = await fetchConfig()\n  const data = await fetchData(session.user.id)\n  return Response.json({ data, config })\n}\n```\n\n**Correct (auth and config start immediately):**\n\n```typescript\nexport async function GET(request: Request) {\n  const sessionPromise = auth()\n  const configPromise = fetchConfig()\n  const session = await sessionPromise\n  const [config, data] = await Promise.all([\n    configPromise,\n    fetchData(session.user.id)\n  ])\n  return Response.json({ data, config })\n}\n```\n\nFor operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/async-defer-await.md",
    "content": "---\ntitle: Defer Await Until Needed\nimpact: HIGH\nimpactDescription: avoids blocking unused code paths\ntags: async, await, conditional, optimization\n---\n\n## Defer Await Until Needed\n\nMove `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.\n\n**Incorrect (blocks both branches):**\n\n```typescript\nasync function handleRequest(userId: string, skipProcessing: boolean) {\n  const userData = await fetchUserData(userId)\n  \n  if (skipProcessing) {\n    // Returns immediately but still waited for userData\n    return { skipped: true }\n  }\n  \n  // Only this branch uses userData\n  return processUserData(userData)\n}\n```\n\n**Correct (only blocks when needed):**\n\n```typescript\nasync function handleRequest(userId: string, skipProcessing: boolean) {\n  if (skipProcessing) {\n    // Returns immediately without waiting\n    return { skipped: true }\n  }\n  \n  // Fetch only when needed\n  const userData = await fetchUserData(userId)\n  return processUserData(userData)\n}\n```\n\n**Another example (early return optimization):**\n\n```typescript\n// Incorrect: always fetches permissions\nasync function updateResource(resourceId: string, userId: string) {\n  const permissions = await fetchPermissions(userId)\n  const resource = await getResource(resourceId)\n  \n  if (!resource) {\n    return { error: 'Not found' }\n  }\n  \n  if (!permissions.canEdit) {\n    return { error: 'Forbidden' }\n  }\n  \n  return await updateResourceData(resource, permissions)\n}\n\n// Correct: fetches only when needed\nasync function updateResource(resourceId: string, userId: string) {\n  const resource = await getResource(resourceId)\n  \n  if (!resource) {\n    return { error: 'Not found' }\n  }\n  \n  const permissions = await fetchPermissions(userId)\n  \n  if (!permissions.canEdit) {\n    return { error: 'Forbidden' }\n  }\n  \n  return await updateResourceData(resource, permissions)\n}\n```\n\nThis optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/async-dependencies.md",
    "content": "---\ntitle: Dependency-Based Parallelization\nimpact: CRITICAL\nimpactDescription: 2-10× improvement\ntags: async, parallelization, dependencies, better-all\n---\n\n## Dependency-Based Parallelization\n\nFor operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.\n\n**Incorrect (profile waits for config unnecessarily):**\n\n```typescript\nconst [user, config] = await Promise.all([\n  fetchUser(),\n  fetchConfig()\n])\nconst profile = await fetchProfile(user.id)\n```\n\n**Correct (config and profile run in parallel):**\n\n```typescript\nimport { all } from 'better-all'\n\nconst { user, config, profile } = await all({\n  async user() { return fetchUser() },\n  async config() { return fetchConfig() },\n  async profile() {\n    return fetchProfile((await this.$.user).id)\n  }\n})\n```\n\n**Alternative without extra dependencies:**\n\nWe can also create all the promises first, and do `Promise.all()` at the end.\n\n```typescript\nconst userPromise = fetchUser()\nconst profilePromise = userPromise.then(user => fetchProfile(user.id))\n\nconst [user, config, profile] = await Promise.all([\n  userPromise,\n  fetchConfig(),\n  profilePromise\n])\n```\n\nReference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/async-parallel.md",
    "content": "---\ntitle: Promise.all() for Independent Operations\nimpact: CRITICAL\nimpactDescription: 2-10× improvement\ntags: async, parallelization, promises, waterfalls\n---\n\n## Promise.all() for Independent Operations\n\nWhen async operations have no interdependencies, execute them concurrently using `Promise.all()`.\n\n**Incorrect (sequential execution, 3 round trips):**\n\n```typescript\nconst user = await fetchUser()\nconst posts = await fetchPosts()\nconst comments = await fetchComments()\n```\n\n**Correct (parallel execution, 1 round trip):**\n\n```typescript\nconst [user, posts, comments] = await Promise.all([\n  fetchUser(),\n  fetchPosts(),\n  fetchComments()\n])\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md",
    "content": "---\ntitle: Strategic Suspense Boundaries\nimpact: HIGH\nimpactDescription: faster initial paint\ntags: async, suspense, streaming, layout-shift\n---\n\n## Strategic Suspense Boundaries\n\nInstead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads.\n\n**Incorrect (wrapper blocked by data fetching):**\n\n```tsx\nasync function Page() {\n  const data = await fetchData() // Blocks entire page\n  \n  return (\n    <div>\n      <div>Sidebar</div>\n      <div>Header</div>\n      <div>\n        <DataDisplay data={data} />\n      </div>\n      <div>Footer</div>\n    </div>\n  )\n}\n```\n\nThe entire layout waits for data even though only the middle section needs it.\n\n**Correct (wrapper shows immediately, data streams in):**\n\n```tsx\nfunction Page() {\n  return (\n    <div>\n      <div>Sidebar</div>\n      <div>Header</div>\n      <div>\n        <Suspense fallback={<Skeleton />}>\n          <DataDisplay />\n        </Suspense>\n      </div>\n      <div>Footer</div>\n    </div>\n  )\n}\n\nasync function DataDisplay() {\n  const data = await fetchData() // Only blocks this component\n  return <div>{data.content}</div>\n}\n```\n\nSidebar, Header, and Footer render immediately. Only DataDisplay waits for data.\n\n**Alternative (share promise across components):**\n\n```tsx\nfunction Page() {\n  // Start fetch immediately, but don't await\n  const dataPromise = fetchData()\n  \n  return (\n    <div>\n      <div>Sidebar</div>\n      <div>Header</div>\n      <Suspense fallback={<Skeleton />}>\n        <DataDisplay dataPromise={dataPromise} />\n        <DataSummary dataPromise={dataPromise} />\n      </Suspense>\n      <div>Footer</div>\n    </div>\n  )\n}\n\nfunction DataDisplay({ dataPromise }: { dataPromise: Promise<Data> }) {\n  const data = use(dataPromise) // Unwraps the promise\n  return <div>{data.content}</div>\n}\n\nfunction DataSummary({ dataPromise }: { dataPromise: Promise<Data> }) {\n  const data = use(dataPromise) // Reuses the same promise\n  return <div>{data.summary}</div>\n}\n```\n\nBoth components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together.\n\n**When NOT to use this pattern:**\n\n- Critical data needed for layout decisions (affects positioning)\n- SEO-critical content above the fold\n- Small, fast queries where suspense overhead isn't worth it\n- When you want to avoid layout shift (loading → content jump)\n\n**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md",
    "content": "---\ntitle: Avoid Barrel File Imports\nimpact: CRITICAL\nimpactDescription: 200-800ms import cost, slow builds\ntags: bundle, imports, tree-shaking, barrel-files, performance\n---\n\n## Avoid Barrel File Imports\n\nImport directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`).\n\nPopular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts.\n\n**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph.\n\n**Incorrect (imports entire library):**\n\n```tsx\nimport { Check, X, Menu } from 'lucide-react'\n// Loads 1,583 modules, takes ~2.8s extra in dev\n// Runtime cost: 200-800ms on every cold start\n\nimport { Button, TextField } from '@mui/material'\n// Loads 2,225 modules, takes ~4.2s extra in dev\n```\n\n**Correct (imports only what you need):**\n\n```tsx\nimport Check from 'lucide-react/dist/esm/icons/check'\nimport X from 'lucide-react/dist/esm/icons/x'\nimport Menu from 'lucide-react/dist/esm/icons/menu'\n// Loads only 3 modules (~2KB vs ~1MB)\n\nimport Button from '@mui/material/Button'\nimport TextField from '@mui/material/TextField'\n// Loads only what you use\n```\n\n**Alternative (Next.js 13.5+):**\n\n```js\n// next.config.js - use optimizePackageImports\nmodule.exports = {\n  experimental: {\n    optimizePackageImports: ['lucide-react', '@mui/material']\n  }\n}\n\n// Then you can keep the ergonomic barrel imports:\nimport { Check, X, Menu } from 'lucide-react'\n// Automatically transformed to direct imports at build time\n```\n\nDirect imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR.\n\nLibraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`.\n\nReference: [How we optimized package imports in Next.js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/bundle-conditional.md",
    "content": "---\ntitle: Conditional Module Loading\nimpact: HIGH\nimpactDescription: loads large data only when needed\ntags: bundle, conditional-loading, lazy-loading\n---\n\n## Conditional Module Loading\n\nLoad large data or modules only when a feature is activated.\n\n**Example (lazy-load animation frames):**\n\n```tsx\nfunction AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch<React.SetStateAction<boolean>> }) {\n  const [frames, setFrames] = useState<Frame[] | null>(null)\n\n  useEffect(() => {\n    if (enabled && !frames && typeof window !== 'undefined') {\n      import('./animation-frames.js')\n        .then(mod => setFrames(mod.frames))\n        .catch(() => setEnabled(false))\n    }\n  }, [enabled, frames, setEnabled])\n\n  if (!frames) return <Skeleton />\n  return <Canvas frames={frames} />\n}\n```\n\nThe `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md",
    "content": "---\ntitle: Defer Non-Critical Third-Party Libraries\nimpact: MEDIUM\nimpactDescription: loads after hydration\ntags: bundle, third-party, analytics, defer\n---\n\n## Defer Non-Critical Third-Party Libraries\n\nAnalytics, logging, and error tracking don't block user interaction. Load them after hydration.\n\n**Incorrect (blocks initial bundle):**\n\n```tsx\nimport { Analytics } from '@vercel/analytics/react'\n\nexport default function RootLayout({ children }) {\n  return (\n    <html>\n      <body>\n        {children}\n        <Analytics />\n      </body>\n    </html>\n  )\n}\n```\n\n**Correct (loads after hydration):**\n\n```tsx\nimport dynamic from 'next/dynamic'\n\nconst Analytics = dynamic(\n  () => import('@vercel/analytics/react').then(m => m.Analytics),\n  { ssr: false }\n)\n\nexport default function RootLayout({ children }) {\n  return (\n    <html>\n      <body>\n        {children}\n        <Analytics />\n      </body>\n    </html>\n  )\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md",
    "content": "---\ntitle: Dynamic Imports for Heavy Components\nimpact: CRITICAL\nimpactDescription: directly affects TTI and LCP\ntags: bundle, dynamic-import, code-splitting, next-dynamic\n---\n\n## Dynamic Imports for Heavy Components\n\nUse `next/dynamic` to lazy-load large components not needed on initial render.\n\n**Incorrect (Monaco bundles with main chunk ~300KB):**\n\n```tsx\nimport { MonacoEditor } from './monaco-editor'\n\nfunction CodePanel({ code }: { code: string }) {\n  return <MonacoEditor value={code} />\n}\n```\n\n**Correct (Monaco loads on demand):**\n\n```tsx\nimport dynamic from 'next/dynamic'\n\nconst MonacoEditor = dynamic(\n  () => import('./monaco-editor').then(m => m.MonacoEditor),\n  { ssr: false }\n)\n\nfunction CodePanel({ code }: { code: string }) {\n  return <MonacoEditor value={code} />\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/bundle-preload.md",
    "content": "---\ntitle: Preload Based on User Intent\nimpact: MEDIUM\nimpactDescription: reduces perceived latency\ntags: bundle, preload, user-intent, hover\n---\n\n## Preload Based on User Intent\n\nPreload heavy bundles before they're needed to reduce perceived latency.\n\n**Example (preload on hover/focus):**\n\n```tsx\nfunction EditorButton({ onClick }: { onClick: () => void }) {\n  const preload = () => {\n    if (typeof window !== 'undefined') {\n      void import('./monaco-editor')\n    }\n  }\n\n  return (\n    <button\n      onMouseEnter={preload}\n      onFocus={preload}\n      onClick={onClick}\n    >\n      Open Editor\n    </button>\n  )\n}\n```\n\n**Example (preload when feature flag is enabled):**\n\n```tsx\nfunction FlagsProvider({ children, flags }: Props) {\n  useEffect(() => {\n    if (flags.editorEnabled && typeof window !== 'undefined') {\n      void import('./monaco-editor').then(mod => mod.init())\n    }\n  }, [flags.editorEnabled])\n\n  return <FlagsContext.Provider value={flags}>\n    {children}\n  </FlagsContext.Provider>\n}\n```\n\nThe `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/client-event-listeners.md",
    "content": "---\ntitle: Deduplicate Global Event Listeners\nimpact: LOW\nimpactDescription: single listener for N components\ntags: client, swr, event-listeners, subscription\n---\n\n## Deduplicate Global Event Listeners\n\nUse `useSWRSubscription()` to share global event listeners across component instances.\n\n**Incorrect (N instances = N listeners):**\n\n```tsx\nfunction useKeyboardShortcut(key: string, callback: () => void) {\n  useEffect(() => {\n    const handler = (e: KeyboardEvent) => {\n      if (e.metaKey && e.key === key) {\n        callback()\n      }\n    }\n    window.addEventListener('keydown', handler)\n    return () => window.removeEventListener('keydown', handler)\n  }, [key, callback])\n}\n```\n\nWhen using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener.\n\n**Correct (N instances = 1 listener):**\n\n```tsx\nimport useSWRSubscription from 'swr/subscription'\n\n// Module-level Map to track callbacks per key\nconst keyCallbacks = new Map<string, Set<() => void>>()\n\nfunction useKeyboardShortcut(key: string, callback: () => void) {\n  // Register this callback in the Map\n  useEffect(() => {\n    if (!keyCallbacks.has(key)) {\n      keyCallbacks.set(key, new Set())\n    }\n    keyCallbacks.get(key)!.add(callback)\n\n    return () => {\n      const set = keyCallbacks.get(key)\n      if (set) {\n        set.delete(callback)\n        if (set.size === 0) {\n          keyCallbacks.delete(key)\n        }\n      }\n    }\n  }, [key, callback])\n\n  useSWRSubscription('global-keydown', () => {\n    const handler = (e: KeyboardEvent) => {\n      if (e.metaKey && keyCallbacks.has(e.key)) {\n        keyCallbacks.get(e.key)!.forEach(cb => cb())\n      }\n    }\n    window.addEventListener('keydown', handler)\n    return () => window.removeEventListener('keydown', handler)\n  })\n}\n\nfunction Profile() {\n  // Multiple shortcuts will share the same listener\n  useKeyboardShortcut('p', () => { /* ... */ }) \n  useKeyboardShortcut('k', () => { /* ... */ })\n  // ...\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md",
    "content": "---\ntitle: Version and Minimize localStorage Data\nimpact: MEDIUM\nimpactDescription: prevents schema conflicts, reduces storage size\ntags: client, localStorage, storage, versioning, data-minimization\n---\n\n## Version and Minimize localStorage Data\n\nAdd version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data.\n\n**Incorrect:**\n\n```typescript\n// No version, stores everything, no error handling\nlocalStorage.setItem('userConfig', JSON.stringify(fullUserObject))\nconst data = localStorage.getItem('userConfig')\n```\n\n**Correct:**\n\n```typescript\nconst VERSION = 'v2'\n\nfunction saveConfig(config: { theme: string; language: string }) {\n  try {\n    localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config))\n  } catch {\n    // Throws in incognito/private browsing, quota exceeded, or disabled\n  }\n}\n\nfunction loadConfig() {\n  try {\n    const data = localStorage.getItem(`userConfig:${VERSION}`)\n    return data ? JSON.parse(data) : null\n  } catch {\n    return null\n  }\n}\n\n// Migration from v1 to v2\nfunction migrate() {\n  try {\n    const v1 = localStorage.getItem('userConfig:v1')\n    if (v1) {\n      const old = JSON.parse(v1)\n      saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang })\n      localStorage.removeItem('userConfig:v1')\n    }\n  } catch {}\n}\n```\n\n**Store minimal fields from server responses:**\n\n```typescript\n// User object has 20+ fields, only store what UI needs\nfunction cachePrefs(user: FullUser) {\n  try {\n    localStorage.setItem('prefs:v1', JSON.stringify({\n      theme: user.preferences.theme,\n      notifications: user.preferences.notifications\n    }))\n  } catch {}\n}\n```\n\n**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled.\n\n**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md",
    "content": "---\ntitle: Use Passive Event Listeners for Scrolling Performance\nimpact: MEDIUM\nimpactDescription: eliminates scroll delay caused by event listeners\ntags: client, event-listeners, scrolling, performance, touch, wheel\n---\n\n## Use Passive Event Listeners for Scrolling Performance\n\nAdd `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay.\n\n**Incorrect:**\n\n```typescript\nuseEffect(() => {\n  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)\n  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)\n  \n  document.addEventListener('touchstart', handleTouch)\n  document.addEventListener('wheel', handleWheel)\n  \n  return () => {\n    document.removeEventListener('touchstart', handleTouch)\n    document.removeEventListener('wheel', handleWheel)\n  }\n}, [])\n```\n\n**Correct:**\n\n```typescript\nuseEffect(() => {\n  const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX)\n  const handleWheel = (e: WheelEvent) => console.log(e.deltaY)\n  \n  document.addEventListener('touchstart', handleTouch, { passive: true })\n  document.addEventListener('wheel', handleWheel, { passive: true })\n  \n  return () => {\n    document.removeEventListener('touchstart', handleTouch)\n    document.removeEventListener('wheel', handleWheel)\n  }\n}, [])\n```\n\n**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`.\n\n**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md",
    "content": "---\ntitle: Use SWR for Automatic Deduplication\nimpact: MEDIUM-HIGH\nimpactDescription: automatic deduplication\ntags: client, swr, deduplication, data-fetching\n---\n\n## Use SWR for Automatic Deduplication\n\nSWR enables request deduplication, caching, and revalidation across component instances.\n\n**Incorrect (no deduplication, each instance fetches):**\n\n```tsx\nfunction UserList() {\n  const [users, setUsers] = useState([])\n  useEffect(() => {\n    fetch('/api/users')\n      .then(r => r.json())\n      .then(setUsers)\n  }, [])\n}\n```\n\n**Correct (multiple instances share one request):**\n\n```tsx\nimport useSWR from 'swr'\n\nfunction UserList() {\n  const { data: users } = useSWR('/api/users', fetcher)\n}\n```\n\n**For immutable data:**\n\n```tsx\nimport { useImmutableSWR } from '@/lib/swr'\n\nfunction StaticContent() {\n  const { data } = useImmutableSWR('/api/config', fetcher)\n}\n```\n\n**For mutations:**\n\n```tsx\nimport { useSWRMutation } from 'swr/mutation'\n\nfunction UpdateButton() {\n  const { trigger } = useSWRMutation('/api/user', updateUser)\n  return <button onClick={() => trigger()}>Update</button>\n}\n```\n\nReference: [https://swr.vercel.app](https://swr.vercel.app)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md",
    "content": "---\ntitle: Avoid Layout Thrashing\nimpact: MEDIUM\nimpactDescription: prevents forced synchronous layouts and reduces performance bottlenecks\ntags: javascript, dom, css, performance, reflow, layout-thrashing\n---\n\n## Avoid Layout Thrashing\n\nAvoid interleaving style writes with layout reads. When you read a layout property (like `offsetWidth`, `getBoundingClientRect()`, or `getComputedStyle()`) between style changes, the browser is forced to trigger a synchronous reflow.\n\n**This is OK (browser batches style changes):**\n```typescript\nfunction updateElementStyles(element: HTMLElement) {\n  // Each line invalidates style, but browser batches the recalculation\n  element.style.width = '100px'\n  element.style.height = '200px'\n  element.style.backgroundColor = 'blue'\n  element.style.border = '1px solid black'\n}\n```\n\n**Incorrect (interleaved reads and writes force reflows):**\n```typescript\nfunction layoutThrashing(element: HTMLElement) {\n  element.style.width = '100px'\n  const width = element.offsetWidth  // Forces reflow\n  element.style.height = '200px'\n  const height = element.offsetHeight  // Forces another reflow\n}\n```\n\n**Correct (batch writes, then read once):**\n```typescript\nfunction updateElementStyles(element: HTMLElement) {\n  // Batch all writes together\n  element.style.width = '100px'\n  element.style.height = '200px'\n  element.style.backgroundColor = 'blue'\n  element.style.border = '1px solid black'\n  \n  // Read after all writes are done (single reflow)\n  const { width, height } = element.getBoundingClientRect()\n}\n```\n\n**Correct (batch reads, then writes):**\n```typescript\nfunction avoidThrashing(element: HTMLElement) {\n  // Read phase - all layout queries first\n  const rect1 = element.getBoundingClientRect()\n  const offsetWidth = element.offsetWidth\n  const offsetHeight = element.offsetHeight\n  \n  // Write phase - all style changes after\n  element.style.width = '100px'\n  element.style.height = '200px'\n}\n```\n\n**Better: use CSS classes**\n```css\n.highlighted-box {\n  width: 100px;\n  height: 200px;\n  background-color: blue;\n  border: 1px solid black;\n}\n```\n```typescript\nfunction updateElementStyles(element: HTMLElement) {\n  element.classList.add('highlighted-box')\n  \n  const { width, height } = element.getBoundingClientRect()\n}\n```\n\n**React example:**\n```tsx\n// Incorrect: interleaving style changes with layout queries\nfunction Box({ isHighlighted }: { isHighlighted: boolean }) {\n  const ref = useRef<HTMLDivElement>(null)\n  \n  useEffect(() => {\n    if (ref.current && isHighlighted) {\n      ref.current.style.width = '100px'\n      const width = ref.current.offsetWidth // Forces layout\n      ref.current.style.height = '200px'\n    }\n  }, [isHighlighted])\n  \n  return <div ref={ref}>Content</div>\n}\n\n// Correct: toggle class\nfunction Box({ isHighlighted }: { isHighlighted: boolean }) {\n  return (\n    <div className={isHighlighted ? 'highlighted-box' : ''}>\n      Content\n    </div>\n  )\n}\n```\n\nPrefer CSS classes over inline styles when possible. CSS files are cached by the browser, and classes provide better separation of concerns and are easier to maintain.\n\nSee [this gist](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) and [CSS Triggers](https://csstriggers.com/) for more information on layout-forcing operations.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md",
    "content": "---\ntitle: Cache Repeated Function Calls\nimpact: MEDIUM\nimpactDescription: avoid redundant computation\ntags: javascript, cache, memoization, performance\n---\n\n## Cache Repeated Function Calls\n\nUse a module-level Map to cache function results when the same function is called repeatedly with the same inputs during render.\n\n**Incorrect (redundant computation):**\n\n```typescript\nfunction ProjectList({ projects }: { projects: Project[] }) {\n  return (\n    <div>\n      {projects.map(project => {\n        // slugify() called 100+ times for same project names\n        const slug = slugify(project.name)\n        \n        return <ProjectCard key={project.id} slug={slug} />\n      })}\n    </div>\n  )\n}\n```\n\n**Correct (cached results):**\n\n```typescript\n// Module-level cache\nconst slugifyCache = new Map<string, string>()\n\nfunction cachedSlugify(text: string): string {\n  if (slugifyCache.has(text)) {\n    return slugifyCache.get(text)!\n  }\n  const result = slugify(text)\n  slugifyCache.set(text, result)\n  return result\n}\n\nfunction ProjectList({ projects }: { projects: Project[] }) {\n  return (\n    <div>\n      {projects.map(project => {\n        // Computed only once per unique project name\n        const slug = cachedSlugify(project.name)\n        \n        return <ProjectCard key={project.id} slug={slug} />\n      })}\n    </div>\n  )\n}\n```\n\n**Simpler pattern for single-value functions:**\n\n```typescript\nlet isLoggedInCache: boolean | null = null\n\nfunction isLoggedIn(): boolean {\n  if (isLoggedInCache !== null) {\n    return isLoggedInCache\n  }\n  \n  isLoggedInCache = document.cookie.includes('auth=')\n  return isLoggedInCache\n}\n\n// Clear cache when auth changes\nfunction onAuthChange() {\n  isLoggedInCache = null\n}\n```\n\nUse a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.\n\nReference: [How we made the Vercel Dashboard twice as fast](https://vercel.com/blog/how-we-made-the-vercel-dashboard-twice-as-fast)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md",
    "content": "---\ntitle: Cache Property Access in Loops\nimpact: LOW-MEDIUM\nimpactDescription: reduces lookups\ntags: javascript, loops, optimization, caching\n---\n\n## Cache Property Access in Loops\n\nCache object property lookups in hot paths.\n\n**Incorrect (3 lookups × N iterations):**\n\n```typescript\nfor (let i = 0; i < arr.length; i++) {\n  process(obj.config.settings.value)\n}\n```\n\n**Correct (1 lookup total):**\n\n```typescript\nconst value = obj.config.settings.value\nconst len = arr.length\nfor (let i = 0; i < len; i++) {\n  process(value)\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-cache-storage.md",
    "content": "---\ntitle: Cache Storage API Calls\nimpact: LOW-MEDIUM\nimpactDescription: reduces expensive I/O\ntags: javascript, localStorage, storage, caching, performance\n---\n\n## Cache Storage API Calls\n\n`localStorage`, `sessionStorage`, and `document.cookie` are synchronous and expensive. Cache reads in memory.\n\n**Incorrect (reads storage on every call):**\n\n```typescript\nfunction getTheme() {\n  return localStorage.getItem('theme') ?? 'light'\n}\n// Called 10 times = 10 storage reads\n```\n\n**Correct (Map cache):**\n\n```typescript\nconst storageCache = new Map<string, string | null>()\n\nfunction getLocalStorage(key: string) {\n  if (!storageCache.has(key)) {\n    storageCache.set(key, localStorage.getItem(key))\n  }\n  return storageCache.get(key)\n}\n\nfunction setLocalStorage(key: string, value: string) {\n  localStorage.setItem(key, value)\n  storageCache.set(key, value)  // keep cache in sync\n}\n```\n\nUse a Map (not a hook) so it works everywhere: utilities, event handlers, not just React components.\n\n**Cookie caching:**\n\n```typescript\nlet cookieCache: Record<string, string> | null = null\n\nfunction getCookie(name: string) {\n  if (!cookieCache) {\n    cookieCache = Object.fromEntries(\n      document.cookie.split('; ').map(c => c.split('='))\n    )\n  }\n  return cookieCache[name]\n}\n```\n\n**Important (invalidate on external changes):**\n\nIf storage can change externally (another tab, server-set cookies), invalidate cache:\n\n```typescript\nwindow.addEventListener('storage', (e) => {\n  if (e.key) storageCache.delete(e.key)\n})\n\ndocument.addEventListener('visibilitychange', () => {\n  if (document.visibilityState === 'visible') {\n    storageCache.clear()\n  }\n})\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md",
    "content": "---\ntitle: Combine Multiple Array Iterations\nimpact: LOW-MEDIUM\nimpactDescription: reduces iterations\ntags: javascript, arrays, loops, performance\n---\n\n## Combine Multiple Array Iterations\n\nMultiple `.filter()` or `.map()` calls iterate the array multiple times. Combine into one loop.\n\n**Incorrect (3 iterations):**\n\n```typescript\nconst admins = users.filter(u => u.isAdmin)\nconst testers = users.filter(u => u.isTester)\nconst inactive = users.filter(u => !u.isActive)\n```\n\n**Correct (1 iteration):**\n\n```typescript\nconst admins: User[] = []\nconst testers: User[] = []\nconst inactive: User[] = []\n\nfor (const user of users) {\n  if (user.isAdmin) admins.push(user)\n  if (user.isTester) testers.push(user)\n  if (!user.isActive) inactive.push(user)\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-early-exit.md",
    "content": "---\ntitle: Early Return from Functions\nimpact: LOW-MEDIUM\nimpactDescription: avoids unnecessary computation\ntags: javascript, functions, optimization, early-return\n---\n\n## Early Return from Functions\n\nReturn early when result is determined to skip unnecessary processing.\n\n**Incorrect (processes all items even after finding answer):**\n\n```typescript\nfunction validateUsers(users: User[]) {\n  let hasError = false\n  let errorMessage = ''\n  \n  for (const user of users) {\n    if (!user.email) {\n      hasError = true\n      errorMessage = 'Email required'\n    }\n    if (!user.name) {\n      hasError = true\n      errorMessage = 'Name required'\n    }\n    // Continues checking all users even after error found\n  }\n  \n  return hasError ? { valid: false, error: errorMessage } : { valid: true }\n}\n```\n\n**Correct (returns immediately on first error):**\n\n```typescript\nfunction validateUsers(users: User[]) {\n  for (const user of users) {\n    if (!user.email) {\n      return { valid: false, error: 'Email required' }\n    }\n    if (!user.name) {\n      return { valid: false, error: 'Name required' }\n    }\n  }\n\n  return { valid: true }\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md",
    "content": "---\ntitle: Hoist RegExp Creation\nimpact: LOW-MEDIUM\nimpactDescription: avoids recreation\ntags: javascript, regexp, optimization, memoization\n---\n\n## Hoist RegExp Creation\n\nDon't create RegExp inside render. Hoist to module scope or memoize with `useMemo()`.\n\n**Incorrect (new RegExp every render):**\n\n```tsx\nfunction Highlighter({ text, query }: Props) {\n  const regex = new RegExp(`(${query})`, 'gi')\n  const parts = text.split(regex)\n  return <>{parts.map((part, i) => ...)}</>\n}\n```\n\n**Correct (memoize or hoist):**\n\n```tsx\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction Highlighter({ text, query }: Props) {\n  const regex = useMemo(\n    () => new RegExp(`(${escapeRegex(query)})`, 'gi'),\n    [query]\n  )\n  const parts = text.split(regex)\n  return <>{parts.map((part, i) => ...)}</>\n}\n```\n\n**Warning (global regex has mutable state):**\n\nGlobal regex (`/g`) has mutable `lastIndex` state:\n\n```typescript\nconst regex = /foo/g\nregex.test('foo')  // true, lastIndex = 3\nregex.test('foo')  // false, lastIndex = 0\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-index-maps.md",
    "content": "---\ntitle: Build Index Maps for Repeated Lookups\nimpact: LOW-MEDIUM\nimpactDescription: 1M ops to 2K ops\ntags: javascript, map, indexing, optimization, performance\n---\n\n## Build Index Maps for Repeated Lookups\n\nMultiple `.find()` calls by the same key should use a Map.\n\n**Incorrect (O(n) per lookup):**\n\n```typescript\nfunction processOrders(orders: Order[], users: User[]) {\n  return orders.map(order => ({\n    ...order,\n    user: users.find(u => u.id === order.userId)\n  }))\n}\n```\n\n**Correct (O(1) per lookup):**\n\n```typescript\nfunction processOrders(orders: Order[], users: User[]) {\n  const userById = new Map(users.map(u => [u.id, u]))\n\n  return orders.map(order => ({\n    ...order,\n    user: userById.get(order.userId)\n  }))\n}\n```\n\nBuild map once (O(n)), then all lookups are O(1).\nFor 1000 orders × 1000 users: 1M ops → 2K ops.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-length-check-first.md",
    "content": "---\ntitle: Early Length Check for Array Comparisons\nimpact: MEDIUM-HIGH\nimpactDescription: avoids expensive operations when lengths differ\ntags: javascript, arrays, performance, optimization, comparison\n---\n\n## Early Length Check for Array Comparisons\n\nWhen comparing arrays with expensive operations (sorting, deep equality, serialization), check lengths first. If lengths differ, the arrays cannot be equal.\n\nIn real-world applications, this optimization is especially valuable when the comparison runs in hot paths (event handlers, render loops).\n\n**Incorrect (always runs expensive comparison):**\n\n```typescript\nfunction hasChanges(current: string[], original: string[]) {\n  // Always sorts and joins, even when lengths differ\n  return current.sort().join() !== original.sort().join()\n}\n```\n\nTwo O(n log n) sorts run even when `current.length` is 5 and `original.length` is 100. There is also overhead of joining the arrays and comparing the strings.\n\n**Correct (O(1) length check first):**\n\n```typescript\nfunction hasChanges(current: string[], original: string[]) {\n  // Early return if lengths differ\n  if (current.length !== original.length) {\n    return true\n  }\n  // Only sort when lengths match\n  const currentSorted = current.toSorted()\n  const originalSorted = original.toSorted()\n  for (let i = 0; i < currentSorted.length; i++) {\n    if (currentSorted[i] !== originalSorted[i]) {\n      return true\n    }\n  }\n  return false\n}\n```\n\nThis new approach is more efficient because:\n- It avoids the overhead of sorting and joining the arrays when lengths differ\n- It avoids consuming memory for the joined strings (especially important for large arrays)\n- It avoids mutating the original arrays\n- It returns early when a difference is found\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md",
    "content": "---\ntitle: Use Loop for Min/Max Instead of Sort\nimpact: LOW\nimpactDescription: O(n) instead of O(n log n)\ntags: javascript, arrays, performance, sorting, algorithms\n---\n\n## Use Loop for Min/Max Instead of Sort\n\nFinding the smallest or largest element only requires a single pass through the array. Sorting is wasteful and slower.\n\n**Incorrect (O(n log n) - sort to find latest):**\n\n```typescript\ninterface Project {\n  id: string\n  name: string\n  updatedAt: number\n}\n\nfunction getLatestProject(projects: Project[]) {\n  const sorted = [...projects].sort((a, b) => b.updatedAt - a.updatedAt)\n  return sorted[0]\n}\n```\n\nSorts the entire array just to find the maximum value.\n\n**Incorrect (O(n log n) - sort for oldest and newest):**\n\n```typescript\nfunction getOldestAndNewest(projects: Project[]) {\n  const sorted = [...projects].sort((a, b) => a.updatedAt - b.updatedAt)\n  return { oldest: sorted[0], newest: sorted[sorted.length - 1] }\n}\n```\n\nStill sorts unnecessarily when only min/max are needed.\n\n**Correct (O(n) - single loop):**\n\n```typescript\nfunction getLatestProject(projects: Project[]) {\n  if (projects.length === 0) return null\n  \n  let latest = projects[0]\n  \n  for (let i = 1; i < projects.length; i++) {\n    if (projects[i].updatedAt > latest.updatedAt) {\n      latest = projects[i]\n    }\n  }\n  \n  return latest\n}\n\nfunction getOldestAndNewest(projects: Project[]) {\n  if (projects.length === 0) return { oldest: null, newest: null }\n  \n  let oldest = projects[0]\n  let newest = projects[0]\n  \n  for (let i = 1; i < projects.length; i++) {\n    if (projects[i].updatedAt < oldest.updatedAt) oldest = projects[i]\n    if (projects[i].updatedAt > newest.updatedAt) newest = projects[i]\n  }\n  \n  return { oldest, newest }\n}\n```\n\nSingle pass through the array, no copying, no sorting.\n\n**Alternative (Math.min/Math.max for small arrays):**\n\n```typescript\nconst numbers = [5, 2, 8, 1, 9]\nconst min = Math.min(...numbers)\nconst max = Math.max(...numbers)\n```\n\nThis works for small arrays, but can be slower or just throw an error for very large arrays due to spread operator limitations. Maximal array length is approximately 124000 in Chrome 143 and 638000 in Safari 18; exact numbers may vary - see [the fiddle](https://jsfiddle.net/qw1jabsx/4/). Use the loop approach for reliability.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md",
    "content": "---\ntitle: Use Set/Map for O(1) Lookups\nimpact: LOW-MEDIUM\nimpactDescription: O(n) to O(1)\ntags: javascript, set, map, data-structures, performance\n---\n\n## Use Set/Map for O(1) Lookups\n\nConvert arrays to Set/Map for repeated membership checks.\n\n**Incorrect (O(n) per check):**\n\n```typescript\nconst allowedIds = ['a', 'b', 'c', ...]\nitems.filter(item => allowedIds.includes(item.id))\n```\n\n**Correct (O(1) per check):**\n\n```typescript\nconst allowedIds = new Set(['a', 'b', 'c', ...])\nitems.filter(item => allowedIds.has(item.id))\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md",
    "content": "---\ntitle: Use toSorted() Instead of sort() for Immutability\nimpact: MEDIUM-HIGH\nimpactDescription: prevents mutation bugs in React state\ntags: javascript, arrays, immutability, react, state, mutation\n---\n\n## Use toSorted() Instead of sort() for Immutability\n\n`.sort()` mutates the array in place, which can cause bugs with React state and props. Use `.toSorted()` to create a new sorted array without mutation.\n\n**Incorrect (mutates original array):**\n\n```typescript\nfunction UserList({ users }: { users: User[] }) {\n  // Mutates the users prop array!\n  const sorted = useMemo(\n    () => users.sort((a, b) => a.name.localeCompare(b.name)),\n    [users]\n  )\n  return <div>{sorted.map(renderUser)}</div>\n}\n```\n\n**Correct (creates new array):**\n\n```typescript\nfunction UserList({ users }: { users: User[] }) {\n  // Creates new sorted array, original unchanged\n  const sorted = useMemo(\n    () => users.toSorted((a, b) => a.name.localeCompare(b.name)),\n    [users]\n  )\n  return <div>{sorted.map(renderUser)}</div>\n}\n```\n\n**Why this matters in React:**\n\n1. Props/state mutations break React's immutability model - React expects props and state to be treated as read-only\n2. Causes stale closure bugs - Mutating arrays inside closures (callbacks, effects) can lead to unexpected behavior\n\n**Browser support (fallback for older browsers):**\n\n`.toSorted()` is available in all modern browsers (Chrome 110+, Safari 16+, Firefox 115+, Node.js 20+). For older environments, use spread operator:\n\n```typescript\n// Fallback for older browsers\nconst sorted = [...items].sort((a, b) => a.value - b.value)\n```\n\n**Other immutable array methods:**\n\n- `.toSorted()` - immutable sort\n- `.toReversed()` - immutable reverse\n- `.toSpliced()` - immutable splice\n- `.with()` - immutable element replacement\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-activity.md",
    "content": "---\ntitle: Use Activity Component for Show/Hide\nimpact: MEDIUM\nimpactDescription: preserves state/DOM\ntags: rendering, activity, visibility, state-preservation\n---\n\n## Use Activity Component for Show/Hide\n\nUse React's `<Activity>` to preserve state/DOM for expensive components that frequently toggle visibility.\n\n**Usage:**\n\n```tsx\nimport { Activity } from 'react'\n\nfunction Dropdown({ isOpen }: Props) {\n  return (\n    <Activity mode={isOpen ? 'visible' : 'hidden'}>\n      <ExpensiveMenu />\n    </Activity>\n  )\n}\n```\n\nAvoids expensive re-renders and state loss.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md",
    "content": "---\ntitle: Animate SVG Wrapper Instead of SVG Element\nimpact: LOW\nimpactDescription: enables hardware acceleration\ntags: rendering, svg, css, animation, performance\n---\n\n## Animate SVG Wrapper Instead of SVG Element\n\nMany browsers don't have hardware acceleration for CSS3 animations on SVG elements. Wrap SVG in a `<div>` and animate the wrapper instead.\n\n**Incorrect (animating SVG directly - no hardware acceleration):**\n\n```tsx\nfunction LoadingSpinner() {\n  return (\n    <svg \n      className=\"animate-spin\"\n      width=\"24\" \n      height=\"24\" \n      viewBox=\"0 0 24 24\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" />\n    </svg>\n  )\n}\n```\n\n**Correct (animating wrapper div - hardware accelerated):**\n\n```tsx\nfunction LoadingSpinner() {\n  return (\n    <div className=\"animate-spin\">\n      <svg \n        width=\"24\" \n        height=\"24\" \n        viewBox=\"0 0 24 24\"\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" />\n      </svg>\n    </div>\n  )\n}\n```\n\nThis applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md",
    "content": "---\ntitle: Use Explicit Conditional Rendering\nimpact: LOW\nimpactDescription: prevents rendering 0 or NaN\ntags: rendering, conditional, jsx, falsy-values\n---\n\n## Use Explicit Conditional Rendering\n\nUse explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.\n\n**Incorrect (renders \"0\" when count is 0):**\n\n```tsx\nfunction Badge({ count }: { count: number }) {\n  return (\n    <div>\n      {count && <span className=\"badge\">{count}</span>}\n    </div>\n  )\n}\n\n// When count = 0, renders: <div>0</div>\n// When count = 5, renders: <div><span class=\"badge\">5</span></div>\n```\n\n**Correct (renders nothing when count is 0):**\n\n```tsx\nfunction Badge({ count }: { count: number }) {\n  return (\n    <div>\n      {count > 0 ? <span className=\"badge\">{count}</span> : null}\n    </div>\n  )\n}\n\n// When count = 0, renders: <div></div>\n// When count = 5, renders: <div><span class=\"badge\">5</span></div>\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md",
    "content": "---\ntitle: CSS content-visibility for Long Lists\nimpact: HIGH\nimpactDescription: faster initial render\ntags: rendering, css, content-visibility, long-lists\n---\n\n## CSS content-visibility for Long Lists\n\nApply `content-visibility: auto` to defer off-screen rendering.\n\n**CSS:**\n\n```css\n.message-item {\n  content-visibility: auto;\n  contain-intrinsic-size: 0 80px;\n}\n```\n\n**Example:**\n\n```tsx\nfunction MessageList({ messages }: { messages: Message[] }) {\n  return (\n    <div className=\"overflow-y-auto h-screen\">\n      {messages.map(msg => (\n        <div key={msg.id} className=\"message-item\">\n          <Avatar user={msg.author} />\n          <div>{msg.content}</div>\n        </div>\n      ))}\n    </div>\n  )\n}\n```\n\nFor 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md",
    "content": "---\ntitle: Hoist Static JSX Elements\nimpact: LOW\nimpactDescription: avoids re-creation\ntags: rendering, jsx, static, optimization\n---\n\n## Hoist Static JSX Elements\n\nExtract static JSX outside components to avoid re-creation.\n\n**Incorrect (recreates element every render):**\n\n```tsx\nfunction LoadingSkeleton() {\n  return <div className=\"animate-pulse h-20 bg-gray-200\" />\n}\n\nfunction Container() {\n  return (\n    <div>\n      {loading && <LoadingSkeleton />}\n    </div>\n  )\n}\n```\n\n**Correct (reuses same element):**\n\n```tsx\nconst loadingSkeleton = (\n  <div className=\"animate-pulse h-20 bg-gray-200\" />\n)\n\nfunction Container() {\n  return (\n    <div>\n      {loading && loadingSkeleton}\n    </div>\n  )\n}\n```\n\nThis is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.\n\n**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md",
    "content": "---\ntitle: Prevent Hydration Mismatch Without Flickering\nimpact: MEDIUM\nimpactDescription: avoids visual flicker and hydration errors\ntags: rendering, ssr, hydration, localStorage, flicker\n---\n\n## Prevent Hydration Mismatch Without Flickering\n\nWhen rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.\n\n**Incorrect (breaks SSR):**\n\n```tsx\nfunction ThemeWrapper({ children }: { children: ReactNode }) {\n  // localStorage is not available on server - throws error\n  const theme = localStorage.getItem('theme') || 'light'\n  \n  return (\n    <div className={theme}>\n      {children}\n    </div>\n  )\n}\n```\n\nServer-side rendering will fail because `localStorage` is undefined.\n\n**Incorrect (visual flickering):**\n\n```tsx\nfunction ThemeWrapper({ children }: { children: ReactNode }) {\n  const [theme, setTheme] = useState('light')\n  \n  useEffect(() => {\n    // Runs after hydration - causes visible flash\n    const stored = localStorage.getItem('theme')\n    if (stored) {\n      setTheme(stored)\n    }\n  }, [])\n  \n  return (\n    <div className={theme}>\n      {children}\n    </div>\n  )\n}\n```\n\nComponent first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.\n\n**Correct (no flicker, no hydration mismatch):**\n\n```tsx\nfunction ThemeWrapper({ children }: { children: ReactNode }) {\n  return (\n    <>\n      <div id=\"theme-wrapper\">\n        {children}\n      </div>\n      <script\n        dangerouslySetInnerHTML={{\n          __html: `\n            (function() {\n              try {\n                var theme = localStorage.getItem('theme') || 'light';\n                var el = document.getElementById('theme-wrapper');\n                if (el) el.className = theme;\n              } catch (e) {}\n            })();\n          `,\n        }}\n      />\n    </>\n  )\n}\n```\n\nThe inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.\n\nThis pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md",
    "content": "---\ntitle: Suppress Expected Hydration Mismatches\nimpact: LOW-MEDIUM\nimpactDescription: avoids noisy hydration warnings for known differences\ntags: rendering, hydration, ssr, nextjs\n---\n\n## Suppress Expected Hydration Mismatches\n\nIn SSR frameworks (e.g., Next.js), some values are intentionally different on server vs client (random IDs, dates, locale/timezone formatting). For these *expected* mismatches, wrap the dynamic text in an element with `suppressHydrationWarning` to prevent noisy warnings. Do not use this to hide real bugs. Don’t overuse it.\n\n**Incorrect (known mismatch warnings):**\n\n```tsx\nfunction Timestamp() {\n  return <span>{new Date().toLocaleString()}</span>\n}\n```\n\n**Correct (suppress expected mismatch only):**\n\n```tsx\nfunction Timestamp() {\n  return (\n    <span suppressHydrationWarning>\n      {new Date().toLocaleString()}\n    </span>\n  )\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md",
    "content": "---\ntitle: Optimize SVG Precision\nimpact: LOW\nimpactDescription: reduces file size\ntags: rendering, svg, optimization, svgo\n---\n\n## Optimize SVG Precision\n\nReduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.\n\n**Incorrect (excessive precision):**\n\n```svg\n<path d=\"M 10.293847 20.847362 L 30.938472 40.192837\" />\n```\n\n**Correct (1 decimal place):**\n\n```svg\n<path d=\"M 10.3 20.8 L 30.9 40.2\" />\n```\n\n**Automate with SVGO:**\n\n```bash\nnpx svgo --precision=1 --multipass icon.svg\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md",
    "content": "---\ntitle: Use useTransition Over Manual Loading States\nimpact: LOW\nimpactDescription: reduces re-renders and improves code clarity\ntags: rendering, transitions, useTransition, loading, state\n---\n\n## Use useTransition Over Manual Loading States\n\nUse `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions.\n\n**Incorrect (manual loading state):**\n\n```tsx\nfunction SearchResults() {\n  const [query, setQuery] = useState('')\n  const [results, setResults] = useState([])\n  const [isLoading, setIsLoading] = useState(false)\n\n  const handleSearch = async (value: string) => {\n    setIsLoading(true)\n    setQuery(value)\n    const data = await fetchResults(value)\n    setResults(data)\n    setIsLoading(false)\n  }\n\n  return (\n    <>\n      <input onChange={(e) => handleSearch(e.target.value)} />\n      {isLoading && <Spinner />}\n      <ResultsList results={results} />\n    </>\n  )\n}\n```\n\n**Correct (useTransition with built-in pending state):**\n\n```tsx\nimport { useTransition, useState } from 'react'\n\nfunction SearchResults() {\n  const [query, setQuery] = useState('')\n  const [results, setResults] = useState([])\n  const [isPending, startTransition] = useTransition()\n\n  const handleSearch = (value: string) => {\n    setQuery(value) // Update input immediately\n    \n    startTransition(async () => {\n      // Fetch and update results\n      const data = await fetchResults(value)\n      setResults(data)\n    })\n  }\n\n  return (\n    <>\n      <input onChange={(e) => handleSearch(e.target.value)} />\n      {isPending && <Spinner />}\n      <ResultsList results={results} />\n    </>\n  )\n}\n```\n\n**Benefits:**\n\n- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)`\n- **Error resilience**: Pending state correctly resets even if the transition throws\n- **Better responsiveness**: Keeps the UI responsive during updates\n- **Interrupt handling**: New transitions automatically cancel pending ones\n\nReference: [useTransition](https://react.dev/reference/react/useTransition)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md",
    "content": "---\ntitle: Defer State Reads to Usage Point\nimpact: MEDIUM\nimpactDescription: avoids unnecessary subscriptions\ntags: rerender, searchParams, localStorage, optimization\n---\n\n## Defer State Reads to Usage Point\n\nDon't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.\n\n**Incorrect (subscribes to all searchParams changes):**\n\n```tsx\nfunction ShareButton({ chatId }: { chatId: string }) {\n  const searchParams = useSearchParams()\n\n  const handleShare = () => {\n    const ref = searchParams.get('ref')\n    shareChat(chatId, { ref })\n  }\n\n  return <button onClick={handleShare}>Share</button>\n}\n```\n\n**Correct (reads on demand, no subscription):**\n\n```tsx\nfunction ShareButton({ chatId }: { chatId: string }) {\n  const handleShare = () => {\n    const params = new URLSearchParams(window.location.search)\n    const ref = params.get('ref')\n    shareChat(chatId, { ref })\n  }\n\n  return <button onClick={handleShare}>Share</button>\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md",
    "content": "---\ntitle: Narrow Effect Dependencies\nimpact: LOW\nimpactDescription: minimizes effect re-runs\ntags: rerender, useEffect, dependencies, optimization\n---\n\n## Narrow Effect Dependencies\n\nSpecify primitive dependencies instead of objects to minimize effect re-runs.\n\n**Incorrect (re-runs on any user field change):**\n\n```tsx\nuseEffect(() => {\n  console.log(user.id)\n}, [user])\n```\n\n**Correct (re-runs only when id changes):**\n\n```tsx\nuseEffect(() => {\n  console.log(user.id)\n}, [user.id])\n```\n\n**For derived state, compute outside effect:**\n\n```tsx\n// Incorrect: runs on width=767, 766, 765...\nuseEffect(() => {\n  if (width < 768) {\n    enableMobileMode()\n  }\n}, [width])\n\n// Correct: runs only on boolean transition\nconst isMobile = width < 768\nuseEffect(() => {\n  if (isMobile) {\n    enableMobileMode()\n  }\n}, [isMobile])\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md",
    "content": "---\ntitle: Calculate Derived State During Rendering\nimpact: MEDIUM\nimpactDescription: avoids redundant renders and state drift\ntags: rerender, derived-state, useEffect, state\n---\n\n## Calculate Derived State During Rendering\n\nIf a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead.\n\n**Incorrect (redundant state and effect):**\n\n```tsx\nfunction Form() {\n  const [firstName, setFirstName] = useState('First')\n  const [lastName, setLastName] = useState('Last')\n  const [fullName, setFullName] = useState('')\n\n  useEffect(() => {\n    setFullName(firstName + ' ' + lastName)\n  }, [firstName, lastName])\n\n  return <p>{fullName}</p>\n}\n```\n\n**Correct (derive during render):**\n\n```tsx\nfunction Form() {\n  const [firstName, setFirstName] = useState('First')\n  const [lastName, setLastName] = useState('Last')\n  const fullName = firstName + ' ' + lastName\n\n  return <p>{fullName}</p>\n}\n```\n\nReferences: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md",
    "content": "---\ntitle: Subscribe to Derived State\nimpact: MEDIUM\nimpactDescription: reduces re-render frequency\ntags: rerender, derived-state, media-query, optimization\n---\n\n## Subscribe to Derived State\n\nSubscribe to derived boolean state instead of continuous values to reduce re-render frequency.\n\n**Incorrect (re-renders on every pixel change):**\n\n```tsx\nfunction Sidebar() {\n  const width = useWindowWidth()  // updates continuously\n  const isMobile = width < 768\n  return <nav className={isMobile ? 'mobile' : 'desktop'} />\n}\n```\n\n**Correct (re-renders only when boolean changes):**\n\n```tsx\nfunction Sidebar() {\n  const isMobile = useMediaQuery('(max-width: 767px)')\n  return <nav className={isMobile ? 'mobile' : 'desktop'} />\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md",
    "content": "---\ntitle: Use Functional setState Updates\nimpact: MEDIUM\nimpactDescription: prevents stale closures and unnecessary callback recreations\ntags: react, hooks, useState, useCallback, callbacks, closures\n---\n\n## Use Functional setState Updates\n\nWhen updating state based on the current state value, use the functional update form of setState instead of directly referencing the state variable. This prevents stale closures, eliminates unnecessary dependencies, and creates stable callback references.\n\n**Incorrect (requires state as dependency):**\n\n```tsx\nfunction TodoList() {\n  const [items, setItems] = useState(initialItems)\n  \n  // Callback must depend on items, recreated on every items change\n  const addItems = useCallback((newItems: Item[]) => {\n    setItems([...items, ...newItems])\n  }, [items])  // ❌ items dependency causes recreations\n  \n  // Risk of stale closure if dependency is forgotten\n  const removeItem = useCallback((id: string) => {\n    setItems(items.filter(item => item.id !== id))\n  }, [])  // ❌ Missing items dependency - will use stale items!\n  \n  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />\n}\n```\n\nThe first callback is recreated every time `items` changes, which can cause child components to re-render unnecessarily. The second callback has a stale closure bug—it will always reference the initial `items` value.\n\n**Correct (stable callbacks, no stale closures):**\n\n```tsx\nfunction TodoList() {\n  const [items, setItems] = useState(initialItems)\n  \n  // Stable callback, never recreated\n  const addItems = useCallback((newItems: Item[]) => {\n    setItems(curr => [...curr, ...newItems])\n  }, [])  // ✅ No dependencies needed\n  \n  // Always uses latest state, no stale closure risk\n  const removeItem = useCallback((id: string) => {\n    setItems(curr => curr.filter(item => item.id !== id))\n  }, [])  // ✅ Safe and stable\n  \n  return <ItemsEditor items={items} onAdd={addItems} onRemove={removeItem} />\n}\n```\n\n**Benefits:**\n\n1. **Stable callback references** - Callbacks don't need to be recreated when state changes\n2. **No stale closures** - Always operates on the latest state value\n3. **Fewer dependencies** - Simplifies dependency arrays and reduces memory leaks\n4. **Prevents bugs** - Eliminates the most common source of React closure bugs\n\n**When to use functional updates:**\n\n- Any setState that depends on the current state value\n- Inside useCallback/useMemo when state is needed\n- Event handlers that reference state\n- Async operations that update state\n\n**When direct updates are fine:**\n\n- Setting state to a static value: `setCount(0)`\n- Setting state from props/arguments only: `setName(newName)`\n- State doesn't depend on previous value\n\n**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler can automatically optimize some cases, but functional updates are still recommended for correctness and to prevent stale closure bugs.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md",
    "content": "---\ntitle: Use Lazy State Initialization\nimpact: MEDIUM\nimpactDescription: wasted computation on every render\ntags: react, hooks, useState, performance, initialization\n---\n\n## Use Lazy State Initialization\n\nPass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.\n\n**Incorrect (runs on every render):**\n\n```tsx\nfunction FilteredList({ items }: { items: Item[] }) {\n  // buildSearchIndex() runs on EVERY render, even after initialization\n  const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))\n  const [query, setQuery] = useState('')\n  \n  // When query changes, buildSearchIndex runs again unnecessarily\n  return <SearchResults index={searchIndex} query={query} />\n}\n\nfunction UserProfile() {\n  // JSON.parse runs on every render\n  const [settings, setSettings] = useState(\n    JSON.parse(localStorage.getItem('settings') || '{}')\n  )\n  \n  return <SettingsForm settings={settings} onChange={setSettings} />\n}\n```\n\n**Correct (runs only once):**\n\n```tsx\nfunction FilteredList({ items }: { items: Item[] }) {\n  // buildSearchIndex() runs ONLY on initial render\n  const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))\n  const [query, setQuery] = useState('')\n  \n  return <SearchResults index={searchIndex} query={query} />\n}\n\nfunction UserProfile() {\n  // JSON.parse runs only on initial render\n  const [settings, setSettings] = useState(() => {\n    const stored = localStorage.getItem('settings')\n    return stored ? JSON.parse(stored) : {}\n  })\n  \n  return <SettingsForm settings={settings} onChange={setSettings} />\n}\n```\n\nUse lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.\n\nFor simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md",
    "content": "---\n\ntitle: Extract Default Non-primitive Parameter Value from Memoized Component to Constant\nimpact: MEDIUM\nimpactDescription: restores memoization by using a constant for default value\ntags: rerender, memo, optimization\n\n---\n\n## Extract Default Non-primitive Parameter Value from Memoized Component to Constant\n\nWhen memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`.\n\nTo address this issue, extract the default value into a constant.\n\n**Incorrect (`onClick` has different values on every rerender):**\n\n```tsx\nconst UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) {\n  // ...\n})\n\n// Used without optional onClick\n<UserAvatar />\n```\n\n**Correct (stable default value):**\n\n```tsx\nconst NOOP = () => {};\n\nconst UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) {\n  // ...\n})\n\n// Used without optional onClick\n<UserAvatar />\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-memo.md",
    "content": "---\ntitle: Extract to Memoized Components\nimpact: MEDIUM\nimpactDescription: enables early returns\ntags: rerender, memo, useMemo, optimization\n---\n\n## Extract to Memoized Components\n\nExtract expensive work into memoized components to enable early returns before computation.\n\n**Incorrect (computes avatar even when loading):**\n\n```tsx\nfunction Profile({ user, loading }: Props) {\n  const avatar = useMemo(() => {\n    const id = computeAvatarId(user)\n    return <Avatar id={id} />\n  }, [user])\n\n  if (loading) return <Skeleton />\n  return <div>{avatar}</div>\n}\n```\n\n**Correct (skips computation when loading):**\n\n```tsx\nconst UserAvatar = memo(function UserAvatar({ user }: { user: User }) {\n  const id = useMemo(() => computeAvatarId(user), [user])\n  return <Avatar id={id} />\n})\n\nfunction Profile({ user, loading }: Props) {\n  if (loading) return <Skeleton />\n  return (\n    <div>\n      <UserAvatar user={user} />\n    </div>\n  )\n}\n```\n\n**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md",
    "content": "---\ntitle: Put Interaction Logic in Event Handlers\nimpact: MEDIUM\nimpactDescription: avoids effect re-runs and duplicate side effects\ntags: rerender, useEffect, events, side-effects, dependencies\n---\n\n## Put Interaction Logic in Event Handlers\n\nIf a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action.\n\n**Incorrect (event modeled as state + effect):**\n\n```tsx\nfunction Form() {\n  const [submitted, setSubmitted] = useState(false)\n  const theme = useContext(ThemeContext)\n\n  useEffect(() => {\n    if (submitted) {\n      post('/api/register')\n      showToast('Registered', theme)\n    }\n  }, [submitted, theme])\n\n  return <button onClick={() => setSubmitted(true)}>Submit</button>\n}\n```\n\n**Correct (do it in the handler):**\n\n```tsx\nfunction Form() {\n  const theme = useContext(ThemeContext)\n\n  function handleSubmit() {\n    post('/api/register')\n    showToast('Registered', theme)\n  }\n\n  return <button onClick={handleSubmit}>Submit</button>\n}\n```\n\nReference: [Should this code move to an event handler?](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md",
    "content": "---\ntitle: Do not wrap a simple expression with a primitive result type in useMemo\nimpact: LOW-MEDIUM\nimpactDescription: wasted computation on every render\ntags: rerender, useMemo, optimization\n---\n\n## Do not wrap a simple expression with a primitive result type in useMemo\n\nWhen an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`.\nCalling `useMemo` and comparing hook dependencies may consume more resources than the expression itself.\n\n**Incorrect:**\n\n```tsx\nfunction Header({ user, notifications }: Props) {\n  const isLoading = useMemo(() => {\n    return user.isLoading || notifications.isLoading\n  }, [user.isLoading, notifications.isLoading])\n\n  if (isLoading) return <Skeleton />\n  // return some markup\n}\n```\n\n**Correct:**\n\n```tsx\nfunction Header({ user, notifications }: Props) {\n  const isLoading = user.isLoading || notifications.isLoading\n\n  if (isLoading) return <Skeleton />\n  // return some markup\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-transitions.md",
    "content": "---\ntitle: Use Transitions for Non-Urgent Updates\nimpact: MEDIUM\nimpactDescription: maintains UI responsiveness\ntags: rerender, transitions, startTransition, performance\n---\n\n## Use Transitions for Non-Urgent Updates\n\nMark frequent, non-urgent state updates as transitions to maintain UI responsiveness.\n\n**Incorrect (blocks UI on every scroll):**\n\n```tsx\nfunction ScrollTracker() {\n  const [scrollY, setScrollY] = useState(0)\n  useEffect(() => {\n    const handler = () => setScrollY(window.scrollY)\n    window.addEventListener('scroll', handler, { passive: true })\n    return () => window.removeEventListener('scroll', handler)\n  }, [])\n}\n```\n\n**Correct (non-blocking updates):**\n\n```tsx\nimport { startTransition } from 'react'\n\nfunction ScrollTracker() {\n  const [scrollY, setScrollY] = useState(0)\n  useEffect(() => {\n    const handler = () => {\n      startTransition(() => setScrollY(window.scrollY))\n    }\n    window.addEventListener('scroll', handler, { passive: true })\n    return () => window.removeEventListener('scroll', handler)\n  }, [])\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md",
    "content": "---\ntitle: Use useRef for Transient Values\nimpact: MEDIUM\nimpactDescription: avoids unnecessary re-renders on frequent updates\ntags: rerender, useref, state, performance\n---\n\n## Use useRef for Transient Values\n\nWhen a value changes frequently and you don't want a re-render on every update (e.g., mouse trackers, intervals, transient flags), store it in `useRef` instead of `useState`. Keep component state for UI; use refs for temporary DOM-adjacent values. Updating a ref does not trigger a re-render.\n\n**Incorrect (renders every update):**\n\n```tsx\nfunction Tracker() {\n  const [lastX, setLastX] = useState(0)\n\n  useEffect(() => {\n    const onMove = (e: MouseEvent) => setLastX(e.clientX)\n    window.addEventListener('mousemove', onMove)\n    return () => window.removeEventListener('mousemove', onMove)\n  }, [])\n\n  return (\n    <div\n      style={{\n        position: 'fixed',\n        top: 0,\n        left: lastX,\n        width: 8,\n        height: 8,\n        background: 'black',\n      }}\n    />\n  )\n}\n```\n\n**Correct (no re-render for tracking):**\n\n```tsx\nfunction Tracker() {\n  const lastXRef = useRef(0)\n  const dotRef = useRef<HTMLDivElement>(null)\n\n  useEffect(() => {\n    const onMove = (e: MouseEvent) => {\n      lastXRef.current = e.clientX\n      const node = dotRef.current\n      if (node) {\n        node.style.transform = `translateX(${e.clientX}px)`\n      }\n    }\n    window.addEventListener('mousemove', onMove)\n    return () => window.removeEventListener('mousemove', onMove)\n  }, [])\n\n  return (\n    <div\n      ref={dotRef}\n      style={{\n        position: 'fixed',\n        top: 0,\n        left: 0,\n        width: 8,\n        height: 8,\n        background: 'black',\n        transform: 'translateX(0px)',\n      }}\n    />\n  )\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md",
    "content": "---\ntitle: Use after() for Non-Blocking Operations\nimpact: MEDIUM\nimpactDescription: faster response times\ntags: server, async, logging, analytics, side-effects\n---\n\n## Use after() for Non-Blocking Operations\n\nUse Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response.\n\n**Incorrect (blocks response):**\n\n```tsx\nimport { logUserAction } from '@/app/utils'\n\nexport async function POST(request: Request) {\n  // Perform mutation\n  await updateDatabase(request)\n  \n  // Logging blocks the response\n  const userAgent = request.headers.get('user-agent') || 'unknown'\n  await logUserAction({ userAgent })\n  \n  return new Response(JSON.stringify({ status: 'success' }), {\n    status: 200,\n    headers: { 'Content-Type': 'application/json' }\n  })\n}\n```\n\n**Correct (non-blocking):**\n\n```tsx\nimport { after } from 'next/server'\nimport { headers, cookies } from 'next/headers'\nimport { logUserAction } from '@/app/utils'\n\nexport async function POST(request: Request) {\n  // Perform mutation\n  await updateDatabase(request)\n  \n  // Log after response is sent\n  after(async () => {\n    const userAgent = (await headers()).get('user-agent') || 'unknown'\n    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'\n    \n    logUserAction({ sessionCookie, userAgent })\n  })\n  \n  return new Response(JSON.stringify({ status: 'success' }), {\n    status: 200,\n    headers: { 'Content-Type': 'application/json' }\n  })\n}\n```\n\nThe response is sent immediately while logging happens in the background.\n\n**Common use cases:**\n\n- Analytics tracking\n- Audit logging\n- Sending notifications\n- Cache invalidation\n- Cleanup tasks\n\n**Important notes:**\n\n- `after()` runs even if the response fails or redirects\n- Works in Server Actions, Route Handlers, and Server Components\n\nReference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/server-auth-actions.md",
    "content": "---\ntitle: Authenticate Server Actions Like API Routes\nimpact: CRITICAL\nimpactDescription: prevents unauthorized access to server mutations\ntags: server, server-actions, authentication, security, authorization\n---\n\n## Authenticate Server Actions Like API Routes\n\n**Impact: CRITICAL (prevents unauthorized access to server mutations)**\n\nServer Actions (functions with `\"use server\"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly.\n\nNext.js documentation explicitly states: \"Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation.\"\n\n**Incorrect (no authentication check):**\n\n```typescript\n'use server'\n\nexport async function deleteUser(userId: string) {\n  // Anyone can call this! No auth check\n  await db.user.delete({ where: { id: userId } })\n  return { success: true }\n}\n```\n\n**Correct (authentication inside the action):**\n\n```typescript\n'use server'\n\nimport { verifySession } from '@/lib/auth'\nimport { unauthorized } from '@/lib/errors'\n\nexport async function deleteUser(userId: string) {\n  // Always check auth inside the action\n  const session = await verifySession()\n  \n  if (!session) {\n    throw unauthorized('Must be logged in')\n  }\n  \n  // Check authorization too\n  if (session.user.role !== 'admin' && session.user.id !== userId) {\n    throw unauthorized('Cannot delete other users')\n  }\n  \n  await db.user.delete({ where: { id: userId } })\n  return { success: true }\n}\n```\n\n**With input validation:**\n\n```typescript\n'use server'\n\nimport { verifySession } from '@/lib/auth'\nimport { z } from 'zod'\n\nconst updateProfileSchema = z.object({\n  userId: z.string().uuid(),\n  name: z.string().min(1).max(100),\n  email: z.string().email()\n})\n\nexport async function updateProfile(data: unknown) {\n  // Validate input first\n  const validated = updateProfileSchema.parse(data)\n  \n  // Then authenticate\n  const session = await verifySession()\n  if (!session) {\n    throw new Error('Unauthorized')\n  }\n  \n  // Then authorize\n  if (session.user.id !== validated.userId) {\n    throw new Error('Can only update own profile')\n  }\n  \n  // Finally perform the mutation\n  await db.user.update({\n    where: { id: validated.userId },\n    data: {\n      name: validated.name,\n      email: validated.email\n    }\n  })\n  \n  return { success: true }\n}\n```\n\nReference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/server-cache-lru.md",
    "content": "---\ntitle: Cross-Request LRU Caching\nimpact: HIGH\nimpactDescription: caches across requests\ntags: server, cache, lru, cross-request\n---\n\n## Cross-Request LRU Caching\n\n`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.\n\n**Implementation:**\n\n```typescript\nimport { LRUCache } from 'lru-cache'\n\nconst cache = new LRUCache<string, any>({\n  max: 1000,\n  ttl: 5 * 60 * 1000  // 5 minutes\n})\n\nexport async function getUser(id: string) {\n  const cached = cache.get(id)\n  if (cached) return cached\n\n  const user = await db.user.findUnique({ where: { id } })\n  cache.set(id, user)\n  return user\n}\n\n// Request 1: DB query, result cached\n// Request 2: cache hit, no DB query\n```\n\nUse when sequential user actions hit multiple endpoints needing the same data within seconds.\n\n**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis.\n\n**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching.\n\nReference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/server-cache-react.md",
    "content": "---\ntitle: Per-Request Deduplication with React.cache()\nimpact: MEDIUM\nimpactDescription: deduplicates within request\ntags: server, cache, react-cache, deduplication\n---\n\n## Per-Request Deduplication with React.cache()\n\nUse `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.\n\n**Usage:**\n\n```typescript\nimport { cache } from 'react'\n\nexport const getCurrentUser = cache(async () => {\n  const session = await auth()\n  if (!session?.user?.id) return null\n  return await db.user.findUnique({\n    where: { id: session.user.id }\n  })\n})\n```\n\nWithin a single request, multiple calls to `getCurrentUser()` execute the query only once.\n\n**Avoid inline objects as arguments:**\n\n`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits.\n\n**Incorrect (always cache miss):**\n\n```typescript\nconst getUser = cache(async (params: { uid: number }) => {\n  return await db.user.findUnique({ where: { id: params.uid } })\n})\n\n// Each call creates new object, never hits cache\ngetUser({ uid: 1 })\ngetUser({ uid: 1 })  // Cache miss, runs query again\n```\n\n**Correct (cache hit):**\n\n```typescript\nconst getUser = cache(async (uid: number) => {\n  return await db.user.findUnique({ where: { id: uid } })\n})\n\n// Primitive args use value equality\ngetUser(1)\ngetUser(1)  // Cache hit, returns cached result\n```\n\nIf you must pass objects, pass the same reference:\n\n```typescript\nconst params = { uid: 1 }\ngetUser(params)  // Query runs\ngetUser(params)  // Cache hit (same reference)\n```\n\n**Next.js-Specific Note:**\n\nIn Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks:\n\n- Database queries (Prisma, Drizzle, etc.)\n- Heavy computations\n- Authentication checks\n- File system operations\n- Any non-fetch async work\n\nUse `React.cache()` to deduplicate these operations across your component tree.\n\nReference: [React.cache documentation](https://react.dev/reference/react/cache)\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/server-dedup-props.md",
    "content": "---\ntitle: Avoid Duplicate Serialization in RSC Props\nimpact: LOW\nimpactDescription: reduces network payload by avoiding duplicate serialization\ntags: server, rsc, serialization, props, client-components\n---\n\n## Avoid Duplicate Serialization in RSC Props\n\n**Impact: LOW (reduces network payload by avoiding duplicate serialization)**\n\nRSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server.\n\n**Incorrect (duplicates array):**\n\n```tsx\n// RSC: sends 6 strings (2 arrays × 3 items)\n<ClientList usernames={usernames} usernamesOrdered={usernames.toSorted()} />\n```\n\n**Correct (sends 3 strings):**\n\n```tsx\n// RSC: send once\n<ClientList usernames={usernames} />\n\n// Client: transform there\n'use client'\nconst sorted = useMemo(() => [...usernames].sort(), [usernames])\n```\n\n**Nested deduplication behavior:**\n\nDeduplication works recursively. Impact varies by data type:\n\n- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated\n- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference\n\n```tsx\n// string[] - duplicates everything\nusernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings\n\n// object[] - duplicates array structure only\nusers={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4)\n```\n\n**Operations breaking deduplication (create new references):**\n\n- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]`\n- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())`\n\n**More examples:**\n\n```tsx\n// ❌ Bad\n<C users={users} active={users.filter(u => u.active)} />\n<C product={product} productName={product.name} />\n\n// ✅ Good\n<C users={users} />\n<C product={product} />\n// Do filtering/destructuring in client\n```\n\n**Exception:** Pass derived data when transformation is expensive or client doesn't need original.\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md",
    "content": "---\ntitle: Parallel Data Fetching with Component Composition\nimpact: CRITICAL\nimpactDescription: eliminates server-side waterfalls\ntags: server, rsc, parallel-fetching, composition\n---\n\n## Parallel Data Fetching with Component Composition\n\nReact Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.\n\n**Incorrect (Sidebar waits for Page's fetch to complete):**\n\n```tsx\nexport default async function Page() {\n  const header = await fetchHeader()\n  return (\n    <div>\n      <div>{header}</div>\n      <Sidebar />\n    </div>\n  )\n}\n\nasync function Sidebar() {\n  const items = await fetchSidebarItems()\n  return <nav>{items.map(renderItem)}</nav>\n}\n```\n\n**Correct (both fetch simultaneously):**\n\n```tsx\nasync function Header() {\n  const data = await fetchHeader()\n  return <div>{data}</div>\n}\n\nasync function Sidebar() {\n  const items = await fetchSidebarItems()\n  return <nav>{items.map(renderItem)}</nav>\n}\n\nexport default function Page() {\n  return (\n    <div>\n      <Header />\n      <Sidebar />\n    </div>\n  )\n}\n```\n\n**Alternative with children prop:**\n\n```tsx\nasync function Header() {\n  const data = await fetchHeader()\n  return <div>{data}</div>\n}\n\nasync function Sidebar() {\n  const items = await fetchSidebarItems()\n  return <nav>{items.map(renderItem)}</nav>\n}\n\nfunction Layout({ children }: { children: ReactNode }) {\n  return (\n    <div>\n      <Header />\n      {children}\n    </div>\n  )\n}\n\nexport default function Page() {\n  return (\n    <Layout>\n      <Sidebar />\n    </Layout>\n  )\n}\n```\n"
  },
  {
    "path": ".agents/skills/vercel-react-best-practices/rules/server-serialization.md",
    "content": "---\ntitle: Minimize Serialization at RSC Boundaries\nimpact: HIGH\nimpactDescription: reduces data transfer size\ntags: server, rsc, serialization, props\n---\n\n## Minimize Serialization at RSC Boundaries\n\nThe React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses.\n\n**Incorrect (serializes all 50 fields):**\n\n```tsx\nasync function Page() {\n  const user = await fetchUser()  // 50 fields\n  return <Profile user={user} />\n}\n\n'use client'\nfunction Profile({ user }: { user: User }) {\n  return <div>{user.name}</div>  // uses 1 field\n}\n```\n\n**Correct (serializes only 1 field):**\n\n```tsx\nasync function Page() {\n  const user = await fetchUser()\n  return <Profile name={user.name} />\n}\n\n'use client'\nfunction Profile({ name }: { name: string }) {\n  return <div>{name}</div>\n}\n```\n"
  },
  {
    "path": ".agents/skills/web-design-guidelines/SKILL.md",
    "content": "---\nname: web-design-guidelines\ndescription: Review UI code for Web Interface Guidelines compliance. Use when asked to \"review my UI\", \"check accessibility\", \"audit design\", \"review UX\", or \"check my site against best practices\".\nmetadata:\n  author: vercel\n  version: \"1.0.0\"\n  argument-hint: <file-or-pattern>\n---\n\n# Web Interface Guidelines\n\nReview files for compliance with Web Interface Guidelines.\n\n## How It Works\n\n1. Fetch the latest guidelines from the source URL below\n2. Read the specified files (or prompt user for files/pattern)\n3. Check against all rules in the fetched guidelines\n4. Output findings in the terse `file:line` format\n\n## Guidelines Source\n\nFetch fresh guidelines before each review:\n\n```\nhttps://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md\n```\n\nUse WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.\n\n## Usage\n\nWhen a user provides a file or pattern argument:\n1. Fetch guidelines from the source URL above\n2. Read the specified files\n3. Apply all rules from the fetched guidelines\n4. Output findings using the format specified in the guidelines\n\nIf no files specified, ask the user which files to review.\n"
  },
  {
    "path": ".cursor/rules/rule-claude-opus.mdc",
    "content": "---\ndescription: Base Guidelines for Claude Opus 4.6 + Cursor Agent\nglobs: *,**/*\nalwaysApply: true\n---\n\n# Instructions\n\n1. Always search with SemanticSearch or Grep first to find existing core files before creating new ones\n2. Always check existing system files purposes before creating new ones with similar functionality\n3. Always list the cursor rules you're using\n\n# Optional\n\n- If a prompt or request specifies certain behaviors, languages, or output formats, you must obey them without deviation.\n- Do not include explanations, reasoning, or filler text unless explicitly instructed. Stick strictly to the requested output.\n- If multiple steps or sub-requests are given, address them in the specified order. Provide answers in the exact format or sequence requested.\n- Pay close attention to all stated constraints (e.g., language choice, performance goals, coding style). Do not ignore any requirement or best practice stated.\n- Only produce output relevant to the question or instructions. Do not add features, code, or details beyond what is explicitly asked.\n- Deliver the response in a minimal yet complete form. Avoid unnecessary verbosity and tangential remarks.\n- If the prompt requests a specific output format (e.g., a fenced code block, bullet points, JSON), follow that format exactly.\n- If a prompt includes a pre-seeded answer structure (e.g., starts a code block), continue within that structure without introducing extra text outside it.\n- If the request is ambiguous, you may ask clarifying questions (if instructions allow). Otherwise, state briefly that more information is needed.\n- When generating or modifying code, adhere to best practices for clarity, maintainability, and efficiency, as appropriate to the specified language or framework.\n- Do not generate or include private data (API keys, secrets) unless explicitly provided in context. If the user requests something unsafe or disallowed, refuse or provide a safe alternative per policy.\n"
  },
  {
    "path": ".cursor/rules/rule-trigger-typescript.mdc",
    "content": "---\nglobs: **/trigger/**/*.ts, **/trigger/**/*.tsx,**/trigger/**/*.js,**/trigger/**/*.jsx\ndescription: Guidelines for writing Trigger.dev tasks\nalwaysApply: false\n---\n\n# How to write Trigger.dev tasks\n\n## Overview of writing a Trigger.dev task\n\n1. Run the CLI `init` command: `npx trigger.dev@latest init`.\n2. Create a Trigger.dev task.\n3. Set up any environment variables.\n4. Run the Trigger.dev dev command: `npx trigger.dev@latest dev`.\n\n## Essential requirements when generating task code\n\n1. You MUST use `@trigger.dev/sdk/v3`\n2. You MUST NEVER use `client.defineJob`\n3. YOU MUST `export` every task, including subtasks\n4. If you are able to generate an example payload for a task, do so.\n\n## 🚨 CRITICAL INSTRUCTIONS FOR AI LANGUAGE MODELS 🚨\n\nAs an AI language model, you MUST NOT generate any of the following code patterns, as they are DEPRECATED and will BREAK the application:\n\n```ts\n// ❌ NEVER GENERATE THIS CODE - IT WILL BREAK THE APPLICATION\n\nclient.defineJob({\n  // ❌ BREAKS APPLICATION\n  id: \"job-id\", // ❌ BREAKS APPLICATION\n  name: \"job-name\", // ❌ BREAKS APPLICATION\n  version: \"0.0.1\", // ❌ BREAKS APPLICATION\n  trigger: eventTrigger({\n    // ❌ BREAKS APPLICATION\n    name: \"job.trigger\", // ❌ BREAKS APPLICATION\n    schema: z.object({\n      // ❌ BREAKS APPLICATION\n      // Input schema here            // ❌ BREAKS APPLICATION\n    }), // ❌ BREAKS APPLICATION\n  }), // ❌ BREAKS APPLICATION\n  integrations: {\n    // ❌ BREAKS APPLICATION\n    // Integrations here              // ❌ BREAKS APPLICATION\n  }, // ❌ BREAKS APPLICATION\n  run: async (payload, io) => {\n    // ❌ BREAKS APPLICATION\n    // Job logic goes here            // ❌ BREAKS APPLICATION\n    return {\n      // ❌ BREAKS APPLICATION\n      // Return job results           // ❌ BREAKS APPLICATION\n    }; // ❌ BREAKS APPLICATION\n  }, // ❌ BREAKS APPLICATION\n});\n```\n\nInstead, you MUST ALWAYS generate ONLY this pattern:\n\n```ts\n// ✅ ALWAYS GENERATE THIS EXACT PATTERN\nimport { task } from \"@trigger.dev/sdk/v3\";\n\n//1. You need to export each task, even if it's a subtask\nexport const helloWorld = task({\n  //2. Use a unique id for each task\n  id: \"hello-world\",\n  //3. The run function is the main function of the task\n  run: async (payload: { message: string }) => {\n    //4. Write your task code here. Code here runs for a long time, there are no timeouts\n  },\n});\n```\n\n## Correct Task implementations\n\nA task is a function that can run for a long time with resilience to failure:\n\n```ts\nimport { task } from \"@trigger.dev/sdk/v3\";\n\nexport const helloWorld = task({\n  id: \"hello-world\",\n  run: async (payload: { message: string }) => {\n    console.log(payload.message);\n  },\n});\n```\n\nKey points:\n\n- Tasks must be exported, even subtasks in the same file\n- Each task needs a unique ID within your project\n- The `run` function contains your task logic\n\n### Task configuration options\n\n#### Retry options\n\nControl retry behavior when errors occur:\n\n```ts\nexport const taskWithRetries = task({\n  id: \"task-with-retries\",\n  retry: {\n    maxAttempts: 10,\n    factor: 1.8,\n    minTimeoutInMs: 500,\n    maxTimeoutInMs: 30_000,\n    randomize: false,\n  },\n  run: async (payload) => {\n    // Task logic\n  },\n});\n```\n\n#### Queue options\n\nControl concurrency:\n\n```ts\nexport const oneAtATime = task({\n  id: \"one-at-a-time\",\n  queue: {\n    concurrencyLimit: 1,\n  },\n  run: async (payload) => {\n    // Task logic\n  },\n});\n```\n\n#### Machine options\n\nSpecify CPU/RAM requirements:\n\n```ts\nexport const heavyTask = task({\n  id: \"heavy-task\",\n  machine: {\n    preset: \"large-1x\", // 4 vCPU, 8 GB RAM\n  },\n  run: async (payload) => {\n    // Task logic\n  },\n});\n```\n\nMachine configuration options:\n\n| Machine name       | vCPU | Memory | Disk space |\n| ------------------ | ---- | ------ | ---------- |\n| micro              | 0.25 | 0.25   | 10GB       |\n| small-1x (default) | 0.5  | 0.5    | 10GB       |\n| small-2x           | 1    | 1      | 10GB       |\n| medium-1x          | 1    | 2      | 10GB       |\n| medium-2x          | 2    | 4      | 10GB       |\n| large-1x           | 4    | 8      | 10GB       |\n| large-2x           | 8    | 16     | 10GB       |\n\n#### Max Duration\n\nLimit how long a task can run:\n\n```ts\nexport const longTask = task({\n  id: \"long-task\",\n  maxDuration: 300, // 5 minutes\n  run: async (payload) => {\n    // Task logic\n  },\n});\n```\n\n### Lifecycle functions\n\nTasks support several lifecycle hooks:\n\n#### init\n\nRuns before each attempt, can return data for other functions:\n\n```ts\nexport const taskWithInit = task({\n  id: \"task-with-init\",\n  init: async (payload, { ctx }) => {\n    return { someData: \"someValue\" };\n  },\n  run: async (payload, { ctx, init }) => {\n    console.log(init.someData); // \"someValue\"\n  },\n});\n```\n\n#### cleanup\n\nRuns after each attempt, regardless of success/failure:\n\n```ts\nexport const taskWithCleanup = task({\n  id: \"task-with-cleanup\",\n  cleanup: async (payload, { ctx }) => {\n    // Cleanup resources\n  },\n  run: async (payload, { ctx }) => {\n    // Task logic\n  },\n});\n```\n\n#### onStart\n\nRuns once when a task starts (not on retries):\n\n```ts\nexport const taskWithOnStart = task({\n  id: \"task-with-on-start\",\n  onStart: async (payload, { ctx }) => {\n    // Send notification, log, etc.\n  },\n  run: async (payload, { ctx }) => {\n    // Task logic\n  },\n});\n```\n\n#### onSuccess\n\nRuns when a task succeeds:\n\n```ts\nexport const taskWithOnSuccess = task({\n  id: \"task-with-on-success\",\n  onSuccess: async (payload, output, { ctx }) => {\n    // Handle success\n  },\n  run: async (payload, { ctx }) => {\n    // Task logic\n  },\n});\n```\n\n#### onFailure\n\nRuns when a task fails after all retries:\n\n```ts\nexport const taskWithOnFailure = task({\n  id: \"task-with-on-failure\",\n  onFailure: async (payload, error, { ctx }) => {\n    // Handle failure\n  },\n  run: async (payload, { ctx }) => {\n    // Task logic\n  },\n});\n```\n\n#### handleError\n\nControls error handling and retry behavior:\n\n```ts\nexport const taskWithErrorHandling = task({\n  id: \"task-with-error-handling\",\n  handleError: async (error, { ctx }) => {\n    // Custom error handling\n  },\n  run: async (payload, { ctx }) => {\n    // Task logic\n  },\n});\n```\n\nGlobal lifecycle hooks can also be defined in `trigger.config.ts` to apply to all tasks.\n\n## Correct Schedules task (cron) implementations\n\n```ts\nimport { schedules } from \"@trigger.dev/sdk/v3\";\n\nexport const firstScheduledTask = schedules.task({\n  id: \"first-scheduled-task\",\n  run: async (payload) => {\n    //when the task was scheduled to run\n    //note this will be slightly different from new Date() because it takes a few ms to run the task\n    console.log(payload.timestamp); //is a Date object\n\n    //when the task was last run\n    //this can be undefined if it's never been run\n    console.log(payload.lastTimestamp); //is a Date object or undefined\n\n    //the timezone the schedule was registered with, defaults to \"UTC\"\n    //this is in IANA format, e.g. \"America/New_York\"\n    //See the full list here: https://cloud.trigger.dev/timezones\n    console.log(payload.timezone); //is a string\n\n    //If you want to output the time in the user's timezone do this:\n    const formatted = payload.timestamp.toLocaleString(\"en-US\", {\n      timeZone: payload.timezone,\n    });\n\n    //the schedule id (you can have many schedules for the same task)\n    //using this you can remove the schedule, update it, etc\n    console.log(payload.scheduleId); //is a string\n\n    //you can optionally provide an external id when creating the schedule\n    //usually you would set this to a userId or some other unique identifier\n    //this can be undefined if you didn't provide one\n    console.log(payload.externalId); //is a string or undefined\n\n    //the next 5 dates this task is scheduled to run\n    console.log(payload.upcoming); //is an array of Date objects\n  },\n});\n```\n\n### Attach a Declarative schedule\n\n```ts\nimport { schedules } from \"@trigger.dev/sdk/v3\";\n\n// Sepcify a cron pattern (UTC)\nexport const firstScheduledTask = schedules.task({\n  id: \"first-scheduled-task\",\n  //every two hours (UTC timezone)\n  cron: \"0 */2 * * *\",\n  run: async (payload, { ctx }) => {\n    //do something\n  },\n});\n```\n\n```ts\nimport { schedules } from \"@trigger.dev/sdk/v3\";\n\n// Specify a specific timezone like this:\nexport const secondScheduledTask = schedules.task({\n  id: \"second-scheduled-task\",\n  cron: {\n    //5am every day Tokyo time\n    pattern: \"0 5 * * *\",\n    timezone: \"Asia/Tokyo\",\n  },\n  run: async (payload) => {},\n});\n```\n\n### Attach an Imperative schedule\n\nCreate schedules explicitly for tasks using the dashboard's \"New schedule\" button or the SDK.\n\n#### Benefits\n\n- Dynamic creation (e.g., one schedule per user)\n- Manage without code deployment:\n  - Activate/disable\n  - Edit\n  - Delete\n\n#### Implementation\n\n1. Define a task using `⁠schedules.task()`\n2. Attach one or more schedules via:\n\n- Dashboard\n  - SDK\n\n#### Attach schedules with the SDK like this\n\n```ts\nconst createdSchedule = await schedules.create({\n  //The id of the scheduled task you want to attach to.\n  task: firstScheduledTask.id,\n  //The schedule in cron format.\n  cron: \"0 0 * * *\",\n  //this is required, it prevents you from creating duplicate schedules. It will update the schedule if it already exists.\n  deduplicationKey: \"my-deduplication-key\",\n});\n```\n\n## Correct Schema task implementations\n\nSchema tasks validate payloads against a schema before execution:\n\n```ts\nimport { schemaTask } from \"@trigger.dev/sdk/v3\";\nimport { z } from \"zod\";\n\nconst myTask = schemaTask({\n  id: \"my-task\",\n  schema: z.object({\n    name: z.string(),\n    age: z.number(),\n  }),\n  run: async (payload) => {\n    // Payload is typed and validated\n    console.log(payload.name, payload.age);\n  },\n});\n```\n\n## Correct implementations for triggering a task from your backend\n\nWhen you trigger a task from your backend code, you need to set the `TRIGGER_SECRET_KEY` environment variable. You can find the value on the API keys page in the Trigger.dev dashboard.\n\n### tasks.trigger()\n\nTriggers a single run of a task with specified payload and options without importing the task. Use type-only imports for full type checking.\n\n```ts\nimport { tasks } from \"@trigger.dev/sdk/v3\";\nimport type { emailSequence } from \"~/trigger/emails\";\n\nexport async function POST(request: Request) {\n  const data = await request.json();\n  const handle = await tasks.trigger<typeof emailSequence>(\"email-sequence\", {\n    to: data.email,\n    name: data.name,\n  });\n  return Response.json(handle);\n}\n```\n\n### tasks.batchTrigger()\n\nTriggers multiple runs of a single task with different payloads without importing the task.\n\n```ts\nimport { tasks } from \"@trigger.dev/sdk/v3\";\nimport type { emailSequence } from \"~/trigger/emails\";\n\nexport async function POST(request: Request) {\n  const data = await request.json();\n  const batchHandle = await tasks.batchTrigger<typeof emailSequence>(\n    \"email-sequence\",\n    data.users.map((u) => ({ payload: { to: u.email, name: u.name } })),\n  );\n  return Response.json(batchHandle);\n}\n```\n\n### batch.trigger()\n\nTriggers multiple runs of different tasks at once, useful when you need to execute multiple tasks simultaneously.\n\n```ts\nimport { batch } from \"@trigger.dev/sdk/v3\";\nimport type { myTask1, myTask2 } from \"~/trigger/myTasks\";\n\nexport async function POST(request: Request) {\n  const data = await request.json();\n  const result = await batch.trigger<typeof myTask1 | typeof myTask2>([\n    { id: \"my-task-1\", payload: { some: data.some } },\n    { id: \"my-task-2\", payload: { other: data.other } },\n  ]);\n  return Response.json(result);\n}\n```\n\n## Correct implementations for triggering a task from inside another task\n\n### yourTask.trigger()\n\nTriggers a single run of a task with specified payload and options.\n\n```ts\nimport { myOtherTask, runs } from \"~/trigger/my-other-task\";\n\nexport const myTask = task({\n  id: \"my-task\",\n  run: async (payload: string) => {\n    const handle = await myOtherTask.trigger({ foo: \"some data\" });\n\n    const run = await runs.retrieve(handle);\n    // Do something with the run\n  },\n});\n```\n\nIf you need to call `trigger()` on a task in a loop, use `batchTrigger()` instead which can trigger up to 500 runs in a single call.\n\n### yourTask.batchTrigger()\n\nTriggers multiple runs of a single task with different payloads.\n\n```ts\nimport { batch, myOtherTask } from \"~/trigger/my-other-task\";\n\nexport const myTask = task({\n  id: \"my-task\",\n  run: async (payload: string) => {\n    const batchHandle = await myOtherTask.batchTrigger([\n      { payload: \"some data\" },\n    ]);\n\n    //...do other stuff\n    const batch = await batch.retrieve(batchHandle.id);\n  },\n});\n```\n\n### yourTask.triggerAndWait()\n\nTriggers a task and waits for the result, useful when you need to call a different task and use its result.\n\n```ts\nexport const parentTask = task({\n  id: \"parent-task\",\n  run: async (payload: string) => {\n    const result = await childTask.triggerAndWait(\"some-data\");\n    console.log(\"Result\", result);\n\n    //...do stuff with the result\n  },\n});\n```\n\nThe result object needs to be checked to see if the child task run was successful. You can also use the `unwrap` method to get the output directly or handle errors with `SubtaskUnwrapError`. This method should only be used inside a task.\n\n### yourTask.batchTriggerAndWait()\n\nBatch triggers a task and waits for all results, useful for fan-out patterns.\n\n```ts\nexport const batchParentTask = task({\n  id: \"parent-task\",\n  run: async (payload: string) => {\n    const results = await childTask.batchTriggerAndWait([\n      { payload: \"item4\" },\n      { payload: \"item5\" },\n      { payload: \"item6\" },\n    ]);\n    console.log(\"Results\", results);\n\n    //...do stuff with the result\n  },\n});\n```\n\nYou can handle run failures by inspecting individual run results and implementing custom error handling strategies. This method should only be used inside a task.\n\n### batch.triggerAndWait()\n\nBatch triggers multiple different tasks and waits for all results.\n\n```ts\nexport const parentTask = task({\n  id: \"parent-task\",\n  run: async (payload: string) => {\n    const results = await batch.triggerAndWait<\n      typeof childTask1 | typeof childTask2\n    >([\n      { id: \"child-task-1\", payload: { foo: \"World\" } },\n      { id: \"child-task-2\", payload: { bar: 42 } },\n    ]);\n\n    for (const result of results) {\n      if (result.ok) {\n        switch (result.taskIdentifier) {\n          case \"child-task-1\":\n            console.log(\"Child task 1 output\", result.output);\n            break;\n          case \"child-task-2\":\n            console.log(\"Child task 2 output\", result.output);\n            break;\n        }\n      }\n    }\n  },\n});\n```\n\n### batch.triggerByTask()\n\nBatch triggers multiple tasks by passing task instances, useful for static task sets.\n\n```ts\nexport const parentTask = task({\n  id: \"parent-task\",\n  run: async (payload: string) => {\n    const results = await batch.triggerByTask([\n      { task: childTask1, payload: { foo: \"World\" } },\n      { task: childTask2, payload: { bar: 42 } },\n    ]);\n\n    const run1 = await runs.retrieve(results.runs[0]);\n    const run2 = await runs.retrieve(results.runs[1]);\n  },\n});\n```\n\n### batch.triggerByTaskAndWait()\n\nBatch triggers multiple tasks by passing task instances and waits for all results.\n\n```ts\nexport const parentTask = task({\n  id: \"parent-task\",\n  run: async (payload: string) => {\n    const { runs } = await batch.triggerByTaskAndWait([\n      { task: childTask1, payload: { foo: \"World\" } },\n      { task: childTask2, payload: { bar: 42 } },\n    ]);\n\n    if (runs[0].ok) {\n      console.log(\"Child task 1 output\", runs[0].output);\n    }\n\n    if (runs[1].ok) {\n      console.log(\"Child task 2 output\", runs[1].output);\n    }\n  },\n});\n```\n\n## Correct Metadata implementation\n\n### Overview\n\nMetadata allows attaching up to 256KB of structured data to a run, which can be accessed during execution, via API, Realtime, and in the dashboard. Useful for storing user information, tracking progress, or saving intermediate results.\n\n### Basic Usage\n\nAdd metadata when triggering a task:\n\n```ts\nconst handle = await myTask.trigger(\n  { message: \"hello world\" },\n  { metadata: { user: { name: \"Eric\", id: \"user_1234\" } } },\n);\n```\n\nAccess metadata inside a run:\n\n```ts\nimport { metadata, task } from \"@trigger.dev/sdk/v3\";\n\nexport const myTask = task({\n  id: \"my-task\",\n  run: async (payload: { message: string }) => {\n    // Get the whole metadata object\n    const currentMetadata = metadata.current();\n\n    // Get a specific key\n    const user = metadata.get(\"user\");\n    console.log(user.name); // \"Eric\"\n  },\n});\n```\n\n### Update methods\n\nMetadata can be updated as the run progresses:\n\n- **set**: `metadata.set(\"progress\", 0.5)`\n- **del**: `metadata.del(\"progress\")`\n- **replace**: `metadata.replace({ user: { name: \"Eric\" } })`\n- **append**: `metadata.append(\"logs\", \"Step 1 complete\")`\n- **remove**: `metadata.remove(\"logs\", \"Step 1 complete\")`\n- **increment**: `metadata.increment(\"progress\", 0.4)`\n- **decrement**: `metadata.decrement(\"progress\", 0.4)`\n- **stream**: `await metadata.stream(\"logs\", readableStream)`\n- **flush**: `await metadata.flush()`\n\nUpdates can be chained with a fluent API:\n\n```ts\nmetadata\n  .set(\"progress\", 0.1)\n  .append(\"logs\", \"Step 1 complete\")\n  .increment(\"progress\", 0.4);\n```\n\n### Parent & root updates\n\nChild tasks can update parent task metadata:\n\n```ts\nexport const childTask = task({\n  id: \"child-task\",\n  run: async (payload: { message: string }) => {\n    // Update parent task's metadata\n    metadata.parent.set(\"progress\", 0.5);\n\n    // Update root task's metadata\n    metadata.root.set(\"status\", \"processing\");\n  },\n});\n```\n\n### Type safety\n\nMetadata accepts any JSON-serializable object. For type safety, consider wrapping with Zod:\n\n```ts\nimport { z } from \"zod\";\n\nconst Metadata = z.object({\n  user: z.object({\n    name: z.string(),\n    id: z.string(),\n  }),\n  date: z.coerce.date(),\n});\n\nfunction getMetadata() {\n  return Metadata.parse(metadata.current());\n}\n```\n\n### Important notes\n\n- Metadata methods only work inside run functions or task lifecycle hooks\n- Metadata is NOT automatically propagated to child tasks\n- Maximum size is 256KB (configurable if self-hosting)\n- Objects like Dates are serialized to strings and must be deserialized when retrieved\n\n## Correct Realtime implementation\n\n### Overview\n\nTrigger.dev Realtime enables subscribing to runs for real-time updates on run status, useful for monitoring tasks, updating UIs, and building realtime dashboards. It's built on Electric SQL, a PostgreSQL syncing engine.\n\n### Basic usage\n\nSubscribe to a run after triggering a task:\n\n```ts\nimport { runs, tasks } from \"@trigger.dev/sdk/v3\";\n\nasync function myBackend() {\n  const handle = await tasks.trigger(\"my-task\", { some: \"data\" });\n\n  for await (const run of runs.subscribeToRun(handle.id)) {\n    console.log(run); // Logs the run every time it changes\n  }\n}\n```\n\n### Subscription methods\n\n- **subscribeToRun**: Subscribe to changes for a specific run\n- **subscribeToRunsWithTag**: Subscribe to changes for all runs with a specific tag\n- **subscribeToBatch**: Subscribe to changes for all runs in a batch\n\n### Type safety\n\nYou can infer types of run's payload and output by passing the task type:\n\n```ts\nimport { runs } from \"@trigger.dev/sdk/v3\";\n\nimport type { myTask } from \"./trigger/my-task\";\n\nfor await (const run of runs.subscribeToRun<typeof myTask>(handle.id)) {\n  console.log(run.payload.some); // Type-safe access to payload\n\n  if (run.output) {\n    console.log(run.output.result); // Type-safe access to output\n  }\n}\n```\n\n### Realtime Streams\n\nStream data in realtime from inside your tasks using the metadata system:\n\n```ts\nimport { metadata, task } from \"@trigger.dev/sdk/v3\";\nimport OpenAI from \"openai\";\n\nexport type STREAMS = {\n  openai: OpenAI.ChatCompletionChunk;\n};\n\nexport const myTask = task({\n  id: \"my-task\",\n  run: async (payload: { prompt: string }) => {\n    const completion = await openai.chat.completions.create({\n      messages: [{ role: \"user\", content: payload.prompt }],\n      model: \"gpt-3.5-turbo\",\n      stream: true,\n    });\n\n    // Register the stream with the key \"openai\"\n    const stream = await metadata.stream(\"openai\", completion);\n\n    let text = \"\";\n    for await (const chunk of stream) {\n      text += chunk.choices.map((choice) => choice.delta?.content).join(\"\");\n    }\n\n    return { text };\n  },\n});\n```\n\nSubscribe to streams using `withStreams`:\n\n```ts\nfor await (const part of runs\n  .subscribeToRun<typeof myTask>(runId)\n  .withStreams<STREAMS>()) {\n  switch (part.type) {\n    case \"run\": {\n      console.log(\"Received run\", part.run);\n      break;\n    }\n    case \"openai\": {\n      console.log(\"Received OpenAI chunk\", part.chunk);\n      break;\n    }\n  }\n}\n```\n\n## Realtime hooks\n\n### Installation\n\n```bash\nnpm add @trigger.dev/react-hooks\n```\n\n### Authentication\n\nAll hooks require a Public Access Token. You can provide it directly to each hook:\n\n```ts\nimport { useRealtimeRun } from \"@trigger.dev/react-hooks\";\n\nfunction MyComponent({ runId, publicAccessToken }) {\n  const { run, error } = useRealtimeRun(runId, {\n    accessToken: publicAccessToken,\n    baseURL: \"https://your-trigger-dev-instance.com\", // Optional for self-hosting\n  });\n}\n```\n\nOr use the `TriggerAuthContext` provider:\n\n```ts\nimport { TriggerAuthContext } from \"@trigger.dev/react-hooks\";\n\nfunction SetupTrigger({ publicAccessToken }) {\n  return (\n    <TriggerAuthContext.Provider value={{ accessToken: publicAccessToken }}>\n      <MyComponent />\n    </TriggerAuthContext.Provider>\n  );\n}\n```\n\nFor Next.js App Router, wrap the provider in a client component:\n\n```ts\n// components/TriggerProvider.tsx\n\"use client\";\n\nimport { TriggerAuthContext } from \"@trigger.dev/react-hooks\";\n\nexport function TriggerProvider({ accessToken, children }) {\n  return (\n    <TriggerAuthContext.Provider value={{ accessToken }}>\n      {children}\n    </TriggerAuthContext.Provider>\n  );\n}\n```\n\n### Passing tokens to the frontend\n\nSeveral approaches for Next.js App Router:\n\n1. **Using cookies**:\n\n```ts\n// Server action\nexport async function startRun() {\n  const handle = await tasks.trigger<typeof exampleTask>(\"example\", { foo: \"bar\" });\n  cookies().set(\"publicAccessToken\", handle.publicAccessToken);\n  redirect(`/runs/${handle.id}`);\n}\n\n// Page component\nexport default function RunPage({ params }) {\n  const publicAccessToken = cookies().get(\"publicAccessToken\");\n  return (\n    <TriggerProvider accessToken={publicAccessToken}>\n      <RunDetails id={params.id} />\n    </TriggerProvider>\n  );\n}\n```\n\n2. **Using query parameters**:\n\n```ts\n// Server action\nexport async function startRun() {\n  const handle = await tasks.trigger<typeof exampleTask>(\"example\", {\n    foo: \"bar\",\n  });\n  redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`);\n}\n```\n\n3. **Server-side token generation**:\n\n```ts\n// Page component\nexport default async function RunPage({ params }) {\n  const publicAccessToken = await generatePublicAccessToken(params.id);\n  return (\n    <TriggerProvider accessToken={publicAccessToken}>\n      <RunDetails id={params.id} />\n    </TriggerProvider>\n  );\n}\n\n// Token generation function\nexport async function generatePublicAccessToken(runId: string) {\n  return auth.createPublicToken({\n    scopes: {\n      read: {\n        runs: [runId],\n      },\n    },\n    expirationTime: \"1h\",\n  });\n}\n```\n\n### Hook types\n\n#### SWR hooks\n\nData fetching hooks that use SWR for caching:\n\n```ts\n\"use client\";\nimport { useRun } from \"@trigger.dev/react-hooks\";\nimport type { myTask } from \"@/trigger/myTask\";\n\nfunction MyComponent({ runId }) {\n  const { run, error, isLoading } = useRun<typeof myTask>(runId);\n\n  if (isLoading) return <div>Loading...</div>;\n  if (error) return <div>Error: {error.message}</div>;\n\n  return <div>Run: {run.id}</div>;\n}\n```\n\nCommon options:\n\n- `revalidateOnFocus`: Revalidate when window regains focus\n- `revalidateOnReconnect`: Revalidate when network reconnects\n- `refreshInterval`: Polling interval in milliseconds\n\n#### Realtime hooks\n\nHooks that use Trigger.dev's realtime API for live updates (recommended over polling).\n\nFor most use cases, Realtime hooks are preferred over SWR hooks with polling due to better performance and lower API usage.\n\n### Authentication\n\nFor client-side usage, generate a public access token with appropriate scopes:\n\n```ts\nimport { auth } from \"@trigger.dev/sdk/v3\";\n\nconst publicToken = await auth.createPublicToken({\n  scopes: {\n    read: {\n      runs: [\"run_1234\"],\n    },\n  },\n});\n```\n\n## Correct Idempotency implementation\n\nIdempotency ensures that an operation produces the same result when called multiple times. Trigger.dev supports idempotency at the task level through the `idempotencyKey` option.\n\n### Using idempotencyKey\n\nProvide an `idempotencyKey` when triggering a task to ensure it runs only once with that key:\n\n```ts\nimport { idempotencyKeys, task } from \"@trigger.dev/sdk/v3\";\n\nexport const myTask = task({\n  id: \"my-task\",\n  retry: {\n    maxAttempts: 4,\n  },\n  run: async (payload: any) => {\n    // Create a key unique to this task run\n    const idempotencyKey = await idempotencyKeys.create(\"my-task-key\");\n\n    // Child task will only be triggered once across all retries\n    await childTask.trigger({ foo: \"bar\" }, { idempotencyKey });\n\n    // This may throw an error and cause retries\n    throw new Error(\"Something went wrong\");\n  },\n});\n```\n\n### Scoping Idempotency Keys\n\nBy default, keys are scoped to the current run. You can create globally unique keys:\n\n```ts\nconst idempotencyKey = await idempotencyKeys.create(\"my-task-key\", {\n  scope: \"global\",\n});\n```\n\nWhen triggering from backend code:\n\n```ts\nconst idempotencyKey = await idempotencyKeys.create([myUser.id, \"my-task\"]);\nawait tasks.trigger(\"my-task\", { some: \"data\" }, { idempotencyKey });\n```\n\nYou can also pass a string directly:\n\n```ts\nawait myTask.trigger({ some: \"data\" }, { idempotencyKey: myUser.id });\n```\n\n### Time-To-Live (TTL)\n\nThe `idempotencyKeyTTL` option defines a time window during which duplicate triggers return the original run:\n\n```ts\nawait childTask.trigger(\n  { foo: \"bar\" },\n  { idempotencyKey, idempotencyKeyTTL: \"60s\" },\n);\n\nawait wait.for({ seconds: 61 });\n\n// Key expired, will trigger a new run\nawait childTask.trigger({ foo: \"bar\" }, { idempotencyKey });\n```\n\nSupported time units:\n\n- `s` for seconds (e.g., `60s`)\n- `m` for minutes (e.g., `5m`)\n- `h` for hours (e.g., `2h`)\n- `d` for days (e.g., `3d`)\n\n### Payload-Based Idempotency\n\nWhile not directly supported, you can implement payload-based idempotency by hashing the payload:\n\n```ts\nimport { createHash } from \"node:crypto\";\n\nconst idempotencyKey = await idempotencyKeys.create(hash(payload));\nawait tasks.trigger(\"child-task\", payload, { idempotencyKey });\n\nfunction hash(payload: any): string {\n  const hash = createHash(\"sha256\");\n  hash.update(JSON.stringify(payload));\n  return hash.digest(\"hex\");\n}\n```\n\n### Important Notes\n\n- Idempotency keys are scoped to the task and environment\n- Different tasks with the same key will still both run\n- Default TTL is 30 days\n- Not available with `triggerAndWait` or `batchTriggerAndWait` in v3.3.0+ due to a bug\n\n## Correct Logs implementation\n\n```ts\n// onFailure executes after all retries are exhausted; use for notifications, logging, or side effects on final failure:\nimport { logger, task } from \"@trigger.dev/sdk/v3\";\n\nexport const loggingExample = task({\n  id: \"logging-example\",\n  run: async (payload: { data: Record<string, string> }) => {\n    //the first parameter is the message, the second parameter must be a key-value object (Record<string, unknown>)\n    logger.debug(\"Debug message\", payload.data);\n    logger.log(\"Log message\", payload.data);\n    logger.info(\"Info message\", payload.data);\n    logger.warn(\"You've been warned\", payload.data);\n    logger.error(\"Error message\", payload.data);\n  },\n});\n```\n\n## Correct `trigger.config.ts` implementation\n\nThe `trigger.config.ts` file configures your Trigger.dev project, specifying task locations, retry settings, telemetry, and build options.\n\n```ts\nimport { defineConfig } from \"@trigger.dev/sdk/v3\";\n\nexport default defineConfig({\n  project: \"<project ref>\",\n  dirs: [\"./trigger\"],\n  retries: {\n    enabledInDev: false,\n    default: {\n      maxAttempts: 3,\n      minTimeoutInMs: 1000,\n      maxTimeoutInMs: 10000,\n      factor: 2,\n      randomize: true,\n    },\n  },\n});\n```\n\n### Key configuration options\n\n#### Dirs\n\nSpecify where your tasks are located:\n\n```ts\ndirs: [\"./trigger\"],\n```\n\nFiles with `.test` or `.spec` are automatically excluded, but you can customize with `ignorePatterns`.\n\n#### Lifecycle functions\n\nAdd global hooks for all tasks:\n\n```ts\nonStart: async (payload, { ctx }) => {\n  console.log(\"Task started\", ctx.task.id);\n},\nonSuccess: async (payload, output, { ctx }) => {\n  console.log(\"Task succeeded\", ctx.task.id);\n},\nonFailure: async (payload, error, { ctx }) => {\n  console.log(\"Task failed\", ctx.task.id);\n},\n```\n\n#### Telemetry instrumentations\n\nAdd OpenTelemetry instrumentations for enhanced logging:\n\n```ts\ntelemetry: {\n  instrumentations: [\n    new PrismaInstrumentation(),\n    new OpenAIInstrumentation()\n  ],\n  exporters: [axiomExporter], // Optional custom exporters\n},\n```\n\n#### Runtime\n\nSpecify the runtime environment:\n\n```ts\nruntime: \"node\", // or \"bun\" (experimental)\n```\n\n#### Machine settings\n\nSet default machine for all tasks:\n\n```ts\ndefaultMachine: \"large-1x\",\n```\n\n#### Log level\n\nConfigure logging verbosity:\n\n```ts\nlogLevel: \"debug\", // Controls logger API logs\n```\n\n#### Max Duration\n\nSet default maximum runtime for all tasks:\n\n```ts\nmaxDuration: 60, // 60 seconds\n```\n\n### Build configuration\n\nCustomize the build process:\n\n```ts\nbuild: {\n  external: [\"header-generator\"], // Don't bundle these packages\n  jsx: {\n    fragment: \"Fragment\",\n    factory: \"h\",\n    automatic: false,\n  },\n  conditions: [\"react-server\"], // Import conditions\n  extensions: [\n    // Build extensions\n    additionalFiles({ files: [\"./assets/**\", \"./fonts/**\"] }),\n    additionalPackages({ packages: [\"wrangler\"] }),\n    aptGet({ packages: [\"ffmpeg\"] }),\n  ],\n}\n```\n\n### Build Extensions\n\nTrigger.dev provides several built-in extensions:\n\n- **additionalFiles**: Copy files to the build directory\n- **additionalPackages**: Include extra packages in the build\n- **aptGet**: Install system packages in the deployed image\n- **emitDecoratorMetadata**: Enable TypeScript decorator metadata\n- **prismaExtension**: Support for Prisma ORM\n- **syncEnvVars**: Sync environment variables\n- **puppeteer**: Add Puppeteer support\n- **ffmpeg**: Add FFmpeg support\n- **esbuildPlugin**: Add custom esbuild plugins\n\nYou can also create custom build extensions with hooks like `onBuildStart`, `onBuildComplete`, and `externalsForTarget`.\n\n#### Key points:\n\n- Validates payload at trigger time and before execution\n- Supports Zod, Yup, Superstruct, ArkType, Effect/schema, runtypes, valibot, typebox\n- Can use input/output schemas (e.g., defaults, type coercion)\n- Custom parser functions supported\n- Invalid payloads throw errors and prevent task execution\n\n#### Trigger with:\n\n```ts\nawait myTask.trigger({ name: \"Alice\", age: 30 });\n```\n\n## AI model verification steps\n\nBefore generating any code, you MUST verify:\n\n1. Are you importing from `@trigger.dev/sdk/v3`? If not, STOP and FIX.\n2. Have you exported every task? If not, STOP and FIX.\n3. Have you generated any DEPRECATED code patterns? If yes, STOP and FIX.\n\n## Consequences of incorrect implementations\n\nIf you generate code that fails the verification steps above, your implementation will:\n\n1. Break in production\n2. Fail to deploy to the Trigger.dev servers\n3. Fail to run in a local Dev environment\n\n## AI model response template\n\nWhen asked about Trigger.dev task implementation, you MUST:\n\n1. FIRST use code patterns from this guide\n2. NEVER suggest deprecated approaches\n3. VERIFY your response against the patterns shown here\n"
  },
  {
    "path": ".cursorignore",
    "content": ".env"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"extends\": \"next/core-web-vitals\",\n  \"rules\": {\n    \"@next/next/no-img-element\": \"off\"\n  }\n}\n"
  },
  {
    "path": ".github/CODEOWNERS",
    "content": "# Learn how to add code owners here:\n# https://help.github.com/en/articles/about-code-owners\n\n# These owners will be the default owners for everything in the repo.\n# Order is important; the last matching pattern takes the most precedence.\n\n*             @mfts\n/.github/     @mfts"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/oss-gg-hack-template.yml",
    "content": "name: oss.gg hack submission 🕹️\ndescription: \"Submit your contribution for the for the oss.gg hackathon\"\ntitle: \"[🕹️]\"\nlabels: 🕹️ oss.gg, player submission, hacktoberfest\nassignees: []\nbody:\n  - type: textarea\n    id: contribution-name\n    attributes:\n      label: What side quest or challenge are you solving?\n      description: Add the name of the side quest or challenge.\n    validations:\n      required: true\n  - type: textarea\n    id: points\n    attributes:\n      label: Points\n      description: How many points are assigned to this contribution?\n    validations:\n      required: true\n  - type: textarea\n    id: description\n    attributes:\n      label: Description\n      description: What's the task your performed?\n    validations:\n  - type: textarea\n    id: proof\n    attributes:\n      label: Provide proof that you've completed the task\n      description: Screenshots, loom recordings, links to the content you shared or interacted with.\n    validations:\n      required: true\n"
  },
  {
    "path": ".github/workflows/cla.yml",
    "content": "name: \"CLA Assistant\"\non:\n  issue_comment:\n    types: [created]\n  pull_request_target:\n    types: [opened,closed,synchronize]\n\npermissions:\n  actions: write\n  contents: read\n  pull-requests: write\n  statuses: write\n\njobs:\n  CLAAssistant:\n    runs-on: ubuntu-latest\n    steps:\n      - name: \"CLA Assistant\"\n        if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'\n        # Beta Release\n        uses: contributor-assistant/github-action@v2.5.1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          # the below token should have repo scope and must be manually added by you in the repository's secret\n          # This token is required only if you have configured to store the signatures in a remote repository/organization\n          PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}\n        with:\n          remote-organization-name: 'papermark'\n          remote-repository-name: 'cla-signatures'\n          path-to-signatures: 'signatures/version1/cla.json'\n          path-to-document: 'https://github.com/mfts/papermark/blob/main/CLA.md'\n          # branch should not be protected\n          branch: 'main'\n          allowlist: cursoragent\n\n         # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken\n          #remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository)\n          #remote-repository-name: enter the  remote repository name where the signatures should be stored (Default is storing the signatures in the same repository)\n          #create-file-commit-message: 'For example: Creating file for storing CLA Signatures'\n          #signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'\n          #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'\n          #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'\n          #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'\n          #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)\n          #use-dco-flag: true - If you are using DCO instead of CLA\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n\n# nvm\n.npmrc\n.nvmrc\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n.env\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n# react-email\n.react-email\n\n# Tinybird config for the cli is stored in this file\n.tinyb\n\n# Internal scripts\npages/api/scripts\nscripts/\n\n# marketing emails\ncomponents/emails/marketing\nlib/emails/marketing\n\n# vscode configs\n.vscode\n\n# trigger.dev\n.trigger\n\n# changelog and docs\nchangelog\n.docs"
  },
  {
    "path": ".prettierignore",
    "content": "node_modules/\n.next/\n.react-email/\n.vercel/\n.github/\n*.min.js\n*.min.css"
  },
  {
    "path": "CLA.md",
    "content": "# Papermark Contributors License Agreement\n\nThis Contributors License Agreement (\"CLA\") is entered into between the Contributor, and Papermark, Inc. (\"Papermark\"), collectively referred to as the \"Parties.\"\n\n## Background:\n\nPapermark is an open-source project aimed at providing an open-source document sharing and tracking infrastructure for all parties. This CLA governs the rights and contributions made by the Contributor to the Papermark project.\n\n## Agreement:\n\n**Contributor Grant of License:**\n\nBy submitting code, documentation, or any other materials (collectively, \"Contributions\") to the Papermark project, the Contributor grants Papermark a perpetual, worldwide, non-exclusive, royalty-free, sublicensable license to use, modify, distribute, and otherwise exploit the Contributions, including any intellectual property rights therein, for the purposes of the Papermark project.\n\n**Representation of Ownership and Right to Contribute:**\n\nThe Contributor represents that they have the legal right to grant the license stated in Section 1, and that the Contributions do not infringe upon the intellectual property rights of any third party. The Contributor also represents that they have the authority to submit the Contributions on their own behalf or, if applicable, on behalf of their employer or any other entity.\n\n**Patent Grant:**\n\nIf the Contributions include any method, process, or apparatus that is covered by a patent, the Contributor agrees to grant Papermark a non-exclusive, worldwide, royalty-free license under any patent claims necessary to use, modify, distribute, and otherwise exploit the Contributions for the purposes of the Papermark project.\n\n**No Implied Warranties or Support:**\n\nThe Contributor acknowledges that the Contributions are provided \"as is,\" without any warranties or support of any kind. Papermark shall have no obligation to provide maintenance, updates, bug fixes, or support for the Contributions.\n\n**Retention of Contributor Rights:**\n\nThe Contributor retains all right, title, and interest in and to their Contributions. This CLA does not restrict the Contributor from using their own Contributions for any other purpose.\n\n**Governing Law:**\n\nThis CLA shall be governed by and construed in accordance with the laws of Delaware (DE), without regard to its conflict of laws principles.\n\n**Entire Agreement:**\n\nThis CLA constitutes the entire agreement between the Parties with respect to the subject matter hereof and supersedes all prior and contemporaneous understandings, agreements, representations, and warranties.\n\n**Acceptance:**\n\nBy submitting Contributions to the Papermark project, the Contributor acknowledges and agrees to the terms and conditions of this CLA. If the Contributor is agreeing to this CLA on behalf of an entity, they represent that they have the necessary authority to bind that entity to these terms.\n\n**Effective Date:**\n\nThis CLA is effective as of the date of the first Contribution made by the Contributor to the Papermark project.\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright (c) 2023-present Papermark, Inc.\n\nPortions of this software are licensed as follows:\n\n- All content that resides under https://github.com/mfts/papermark/tree/main/ee and https://github.com/mfts/papermark/tree/main/app/(ee) directory of this repository (Commercial License) is licensed under the license defined in \"ee/LICENSE\".\n- All third party components incorporated into the Papermark Software are licensed under the original license provided by the owner of the applicable component.\n- Content outside of the above mentioned directories or restrictions above is available under the \"AGPLv3\" license as defined below.\n\n                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\nCopyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\nEveryone is permitted to copy and distribute verbatim copies\nof this license document, but changing it is not allowed.\n\n                            Preamble\n\nThe GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\nThe licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\nWhen we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\nDevelopers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\nA secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate. Many developers of free software are heartened and\nencouraged by the resulting cooperation. However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\nThe GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community. It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server. Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\nAn older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals. This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\nThe precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n1. Definitions.\n\n\"This License\" refers to version 3 of the GNU Affero General Public License.\n\n\"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n\"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\nTo \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy. The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\nA \"covered work\" means either the unmodified Program or a work based\non the Program.\n\nTo \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\nTo \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\nAn interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n1. Source Code.\n\nThe \"source code\" for a work means the preferred form of the work\nfor making modifications to it. \"Object code\" means any non-source\nform of a work.\n\nA \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\nThe \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\nThe \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\nThe Corresponding Source for a work in source code form is that\nsame work.\n\n2. Basic Permissions.\n\nAll rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force. You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright. Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\nConveying under any other circumstances is permitted solely under\nthe conditions stated below. Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\nNo covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\nWhen you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n4. Conveying Verbatim Copies.\n\nYou may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\nYou may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n5. Conveying Modified Source Versions.\n\nYou may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\nA compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n6. Conveying Non-Source Forms.\n\nYou may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\nA \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling. In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage. For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product. A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n\"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source. The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\nIf you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\nThe requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed. Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\nCorresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n7. Additional Terms.\n\n\"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\nAll other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n8. Termination.\n\nYou may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\nHowever, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n9. Acceptance Not Required for Having Copies.\n\nYou are not required to accept this License in order to receive or\nrun a copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n10. Automatic Licensing of Downstream Recipients.\n\nEach time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\nAn \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n11. Patents.\n\nA \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\nA contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\nIn the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\nA patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License. You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n12. No Surrender of Others' Freedom.\n\nIf conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all. For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n13. Remote Network Interaction; Use with the GNU General Public License.\n\nNotwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software. This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\nNotwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n14. Revised Versions of this License.\n\nThe Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time. Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number. If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\nIf the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\nLater license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n15. Disclaimer of Warranty.\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n16. Limitation of Liability.\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n17. Interpretation of Sections 15 and 16.\n\nIf the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\nIf you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\nTo do so, attach the following notices to the program. It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source. For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code. There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\nYou should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "Pipfile",
    "content": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\ntinybird-cli = \"*\"\n\n[dev-packages]\n\n[requires]\npython_version = \"3.11\"\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <h1 align=\"center\">Papermark</h1>\n  <h3>The open-source DocSend alternative.</h3>\n\n<a target=\"_blank\" href=\"https://www.producthunt.com/posts/papermark-3?utm_source=badge-top-post-badge&amp;utm_medium=badge&amp;utm_souce=badge-papermark\"><img src=\"https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=411605&amp;theme=light&amp;period=daily\" alt=\"Papermark - The open-source DocSend alternative | Product Hunt\" style=\"width:250px;height:40px\"></a>\n\n</div>\n\n<div align=\"center\">\n  <a href=\"https://www.papermark.com\">papermark.com</a>\n</div>\n\n<br/>\n\n<div align=\"center\">\n  <a href=\"https://github.com/mfts/papermark/stargazers\"><img alt=\"GitHub Repo stars\" src=\"https://img.shields.io/github/stars/mfts/papermark\"></a>\n  <a href=\"https://twitter.com/papermarkio\"><img alt=\"Twitter Follow\" src=\"https://img.shields.io/twitter/follow/papermarkio\"></a>\n  <a href=\"https://github.com/mfts/papermark/blob/main/LICENSE\"><img alt=\"License\" src=\"https://img.shields.io/badge/license-AGPLv3-purple\"></a>\n</div>\n\n<br/>\n\nPapermark is the open-source document-sharing alternative to DocSend, featuring built-in analytics and custom domains.\n\n## Features\n\n- **Shareable Links:** Share your documents securely by sending a custom link.\n- **Custom Branding:** Add a custom domain and your own branding.\n- **Analytics:** Gain insights through document tracking and soon page-by-page analytics.\n- **Self-hosted, Open-source:** Host it yourself and customize it as needed.\n\n## Demo\n\n![Papermark Welcome GIF](.github/images/papermark-welcome.gif)\n\n## Tech Stack\n\n- [Next.js](https://nextjs.org/) – Framework\n- [TypeScript](https://www.typescriptlang.org/) – Language\n- [Tailwind](https://tailwindcss.com/) – CSS\n- [shadcn/ui](https://ui.shadcn.com) - UI Components\n- [Prisma](https://prisma.io) - ORM [![Made with Prisma](https://made-with.prisma.io/dark.svg)](https://prisma.io)\n- [PostgreSQL](https://www.postgresql.org/) - Database\n- [NextAuth.js](https://next-auth.js.org/) – Authentication\n- [Tinybird](https://tinybird.co) – Analytics\n- [Resend](https://resend.com) – Email\n- [Stripe](https://stripe.com) – Payments\n- [Vercel](https://vercel.com/) – Hosting\n\n## Getting Started\n\n### Prerequisites\n\nHere's what you need to run Papermark:\n\n- Node.js (version >= 18.17.0)\n- PostgreSQL Database\n- Blob storage (currently [AWS S3](https://aws.amazon.com/s3/) or [Vercel Blob](https://vercel.com/storage/blob))\n- [Resend](https://resend.com) (for sending emails)\n\n### 1. Clone the repository\n\n```shell\ngit clone https://github.com/mfts/papermark.git\ncd papermark\n```\n\n### 2. Install npm dependencies\n\n```shell\nnpm install\n```\n\n### 3. Copy the environment variables to `.env` and change the values\n\n```shell\ncp .env.example .env\n```\n\n### 4. Initialize the database\n\n```shell\nnpm run dev:prisma\n```\n\n### 5. Run the dev server\n\n```shell\nnpm run dev\n```\n\n### 6. Open the app in your browser\n\nVisit [http://localhost:3000](http://localhost:3000) in your browser.\n\n## Tinybird Instructions\n\nTo prepare the Tinybird database, follow these steps:\n\n0. We use `pipenv` to manage our Python dependencies. If you don't have it installed, you can install it using the following command:\n   ```sh\n   pkgx pipenv\n   ```\n1. Download the Tinybird CLI from [here](https://www.tinybird.co/docs/cli.html) and install it on your system.\n2. After authenticating with the Tinybird CLI, navigate to the `lib/tinybird` directory:\n   ```sh\n   cd lib/tinybird\n   ```\n3. Push the necessary data sources using the following command:\n   ```sh\n   tb push datasources/*\n   tb push endpoints/get_*\n   ```\n4. Don't forget to set the `TINYBIRD_TOKEN` with the appropriate rights in your `.env` file.\n\n#### Updating Tinybird\n\n```sh\npipenv shell\n## start: pkgx-specific\ncd ..\ncd papermark\n## end: pkgx-specific\npipenv update tinybird-cli\n```\n\n## Contributing\n\nPapermark is an open-source project, and we welcome contributions from the community.\n\nIf you'd like to contribute, please fork the repository and make any changes you'd like. Pull requests are warmly welcome.\n\n### Our Contributors ✨\n\n<a href=\"https://github.com/mfts/papermark/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=mfts/papermark\" />\n</a>\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nThe latest version of Papermark is currently being supported with security updates.\n\n## Reporting a Vulnerability\n\nTo report a vulnerability, send an email to security@papermark.com.\n\nWe will respond within 48 hours acknowledging your report with details about next steps and potential rewards/compensation for responsible disclosure.\n"
  },
  {
    "path": "app/(auth)/auth/confirm-email-change/[token]/page-client.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/navigation\";\n\nimport { useEffect, useRef } from \"react\";\n\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\n\nexport default function ConfirmEmailChangePageClient() {\n  const router = useRouter();\n  const { update, status } = useSession();\n  const hasUpdatedSession = useRef(false);\n\n  useEffect(() => {\n    if (status !== \"authenticated\" || hasUpdatedSession.current) {\n      return;\n    }\n\n    async function updateSession() {\n      hasUpdatedSession.current = true;\n      await update();\n      toast.success(\"Email update successful!\");\n      router.replace(\"/account/general\");\n    }\n\n    updateSession();\n  }, [status, update]);\n\n  return (\n    <div className=\"flex h-screen w-full items-center justify-center\">\n      <LoadingSpinner className=\"h-20 w-20 text-foreground\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/auth/confirm-email-change/[token]/page.tsx",
    "content": "import { Metadata } from \"next\";\nimport { redirect } from \"next/navigation\";\n\nimport NotFound from \"@/pages/404\";\nimport { VerificationToken } from \"@prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\nimport { sendEmail, subscribe, unsubscribe } from \"@/lib/resend\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport EmailUpdated from \"@/components/emails/email-updated\";\n\nimport ConfirmEmailChangePageClient from \"./page-client\";\nimport { getSession } from \"./utils\";\n\nexport const runtime = \"nodejs\";\n\nconst data = {\n  description: \"Confirm email change\",\n  title: \"Confirm email change | Papermark\",\n  url: \"/auth/confirm-email-change\",\n};\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(\"https://www.papermark.com\"),\n  title: data.title,\n  description: data.description,\n  openGraph: {\n    title: data.title,\n    description: data.description,\n    url: data.url,\n    siteName: \"Papermark\",\n    images: [\n      {\n        url: \"/_static/meta-image.png\",\n        width: 800,\n        height: 600,\n      },\n    ],\n    locale: \"en_US\",\n    type: \"website\",\n  },\n  twitter: {\n    card: \"summary_large_image\",\n    title: data.title,\n    description: data.description,\n    creator: \"@papermarkio\",\n    images: [\"/_static/meta-image.png\"],\n  },\n};\n\ninterface PageProps {\n  params: { token: string };\n}\n\nexport default async function ConfirmEmailChangePage(props: PageProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center gap-6 text-center\">\n      <VerifyEmailChange {...props} />\n    </div>\n  );\n}\n\nconst VerifyEmailChange = async ({ params: { token } }: PageProps) => {\n  const tokenFound = await prisma.verificationToken.findUnique({\n    where: {\n      token: hashToken(token),\n    },\n  });\n\n  if (!tokenFound || tokenFound.expires < new Date()) return <NotFound />;\n\n  const session = await getSession();\n\n  if (!session) {\n    redirect(`/login?next=/auth/confirm-email-change/${token}`);\n  }\n\n  const currentUserId = (session.user as CustomUser).id;\n\n  const data = await redis.get<{ email: string; newEmail: string }>(\n    `email-change-request:user:${currentUserId}`,\n  );\n\n  if (!data) return <NotFound />;\n\n  await unsubscribe(data.email);\n\n  await prisma.user.update({\n    where: {\n      id: currentUserId,\n    },\n    data: {\n      email: data.newEmail,\n    },\n  });\n\n  waitUntil(\n    Promise.all([\n      deleteRequest(tokenFound),\n\n      subscribe(data.newEmail),\n\n      sendEmail({\n        to: data.email,\n        subject: \"Your email address has been changed\",\n        system: true,\n        react: EmailUpdated({\n          oldEmail: data.email,\n          newEmail: data.newEmail,\n        }),\n        test: process.env.NODE_ENV === \"development\",\n      }),\n    ]),\n  );\n\n  return <ConfirmEmailChangePageClient />;\n};\n\nconst deleteRequest = async (tokenFound: VerificationToken) => {\n  await Promise.all([\n    prisma.verificationToken.delete({\n      where: {\n        token: tokenFound.token,\n      },\n    }),\n\n    redis.del(`email-change-request:user:${tokenFound.identifier}`),\n  ]);\n};\n"
  },
  {
    "path": "app/(auth)/auth/confirm-email-change/[token]/utils.ts",
    "content": "import { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nexport const getSession = async () => {\n  return getServerSession(authOptions);\n};\n"
  },
  {
    "path": "app/(auth)/auth/email/[[...params]]/page-client.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useRouter } from \"next/navigation\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { LogoCloud } from \"@/components/shared/logo-cloud\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nexport default function EmailVerificationClient() {\n  const router = useRouter();\n  const codeInputRef = useRef<HTMLInputElement>(null);\n  const [email, setEmail] = useState(\"\");\n  const [emailLocked, setEmailLocked] = useState(false);\n  const [code, setCode] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [isExpired, setIsExpired] = useState(false);\n  const [showEmailDeliveryNotice, setShowEmailDeliveryNotice] = useState(false);\n\n  // Check sessionStorage for pending verification email on mount\n  useEffect(() => {\n    try {\n      const pendingEmail = sessionStorage.getItem(\"pendingVerificationEmail\");\n      if (pendingEmail) {\n        setEmail(pendingEmail);\n        setEmailLocked(true);\n        // Clear the stored email after reading\n        sessionStorage.removeItem(\"pendingVerificationEmail\");\n        // Focus the code input\n        setTimeout(() => {\n          codeInputRef.current?.focus();\n        }, 100);\n      }\n    } catch {\n      // sessionStorage not available, user will need to enter email manually\n    }\n  }, []);\n\n  // Show email delivery notice after 5 seconds when waiting for verification\n  useEffect(() => {\n    if (!emailLocked) {\n      setShowEmailDeliveryNotice(false);\n      return;\n    }\n\n    const timer = setTimeout(() => {\n      setShowEmailDeliveryNotice(true);\n    }, 10000);\n\n    return () => clearTimeout(timer);\n  }, [emailLocked]);\n\n  // Code verification\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    setIsLoading(true);\n    setError(null);\n\n    try {\n      const response = await fetch(\"/api/auth/verify-code\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          email: email.trim().toLowerCase(),\n          code: code.trim().toUpperCase(),\n        }),\n      });\n\n      const data = await response.json();\n\n      if (!response.ok) {\n        if (\n          response.status === 410 ||\n          response.status === 401 ||\n          data.error?.includes(\"expired\") ||\n          data.error?.includes(\"Invalid code\")\n        ) {\n          setIsExpired(true);\n          setError(\"This code has expired or is invalid.\");\n        } else if (response.status === 429) {\n          setError(\n            data.error || \"Too many attempts. Please wait before trying again.\",\n          );\n        } else {\n          setError(data.error || \"Verification failed. Please try again.\");\n        }\n        setIsLoading(false);\n        return;\n      }\n\n      // Redirect to the callback URL\n      if (data.callbackUrl) {\n        router.push(data.callbackUrl);\n      } else {\n        // No callback URL in response - stop loading and show error\n        setIsLoading(false);\n        setError(\"Unable to complete sign-in: missing callback URL. Please try again.\");\n      }\n    } catch (err) {\n      setError(\"An error occurred. Please try again.\");\n      setIsLoading(false);\n    }\n  };\n\n  // Show expired state\n  if (isExpired) {\n    return (\n      <div className=\"flex h-screen w-full flex-wrap\">\n        <div className=\"flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2\">\n          <div className=\"z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]\">\n            <div className=\"items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12\">\n              <Link href=\"https://www.papermark.com\" target=\"_blank\">\n                <img\n                  src=\"/_static/papermark-logo.svg\"\n                  alt=\"Papermark Logo\"\n                  className=\"-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48\"\n                />\n              </Link>\n              <span className=\"text-balance text-3xl font-semibold text-gray-900\">\n                Code Expired\n              </span>\n              <h3 className=\"text-balance text-sm text-gray-800\">\n                This login code has expired or has already been used.\n              </h3>\n            </div>\n            <div className=\"flex flex-col gap-4 px-4 pt-8 sm:px-12\">\n              <Link href=\"/login\">\n                <Button className=\"focus:shadow-outline w-full transform rounded bg-gray-800 px-4 py-2 text-white transition-colors duration-300 ease-in-out hover:bg-gray-900 focus:outline-none\">\n                  Request a new code\n                </Button>\n              </Link>\n            </div>\n          </div>\n        </div>\n        <TestimonialSection />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-screen w-full flex-wrap\">\n      {/* Left part */}\n      <div className=\"flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2\">\n        <div\n          className=\"absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl\"\n          aria-hidden=\"true\"\n        ></div>\n        <div className=\"z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]\">\n          <div className=\"items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12\">\n            <Link href=\"https://www.papermark.com\" target=\"_blank\">\n              <img\n                src=\"/_static/papermark-logo.svg\"\n                alt=\"Papermark Logo\"\n                className=\"-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48\"\n              />\n            </Link>\n            <Link href=\"/\">\n              <span className=\"text-balance text-3xl font-semibold text-gray-900\">\n                Check your email\n              </span>\n            </Link>\n            <h3 className=\"text-balance text-sm text-gray-800\">\n              {emailLocked ? (\n                <>\n                  We sent a login code to{\" \"}\n                  <span className=\"font-medium\">{email}</span>\n                </>\n              ) : (\n                \"Enter your email and the code we sent you\"\n              )}\n            </h3>\n          </div>\n\n          {/* Delayed email delivery notice */}\n          {showEmailDeliveryNotice && emailLocked && (\n            <div className=\"mx-4 mt-4 rounded-lg border border-orange-200 bg-orange-50 p-4 sm:mx-12\">\n              <p className=\"text-sm text-orange-800\">\n                Due to a recent Microsoft outage, we are experiencing delivery\n                issues with Outlook and Microsoft email accounts.\n              </p>\n              <p className=\"mt-2 text-sm text-orange-800\">\n                Check your junk/spam and quarantine folders and ensure that{\" \"}\n                <a\n                  href=\"mailto:system@papermark.com\"\n                  className=\"font-medium text-orange-600 underline hover:text-orange-700\"\n                >\n                  system@papermark.com\n                </a>{\" \"}\n                is on your allowed senders list.\n              </p>\n            </div>\n          )}\n\n          <form\n            className=\"flex flex-col gap-4 px-4 pt-8 sm:px-12\"\n            onSubmit={handleSubmit}\n          >\n            {/* Only show email field if not locked from sessionStorage */}\n            {!emailLocked && (\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"email\">Email</Label>\n                <Input\n                  id=\"email\"\n                  placeholder=\"name@example.com\"\n                  type=\"email\"\n                  autoCapitalize=\"none\"\n                  autoComplete=\"email\"\n                  autoCorrect=\"off\"\n                  disabled={isLoading}\n                  value={email}\n                  onChange={(e) => setEmail(e.target.value)}\n                  className=\"flex h-10 w-full rounded-md border-0 bg-background bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n                />\n              </div>\n            )}\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"code\">Verification Code</Label>\n              <Input\n                ref={codeInputRef}\n                id=\"code\"\n                placeholder=\"Enter 10-character code\"\n                type=\"text\"\n                autoCapitalize=\"characters\"\n                autoComplete=\"one-time-code\"\n                autoCorrect=\"off\"\n                disabled={isLoading}\n                value={code}\n                onChange={(e) => setCode(e.target.value.toUpperCase())}\n                maxLength={10}\n                className=\"flex h-10 w-full rounded-md border-0 bg-background bg-white px-3 py-2 font-mono text-lg tracking-widest text-gray-900 ring-1 ring-gray-200 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground placeholder:font-sans placeholder:text-sm placeholder:tracking-normal focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n              />\n            </div>\n\n            {error && (\n              <p className=\"text-sm text-red-600\" role=\"alert\">\n                {error}\n              </p>\n            )}\n\n            <Button\n              type=\"submit\"\n              loading={isLoading}\n              disabled={isLoading || !email || code.length < 10}\n              className=\"focus:shadow-outline w-full transform rounded bg-gray-800 px-4 py-2 text-white transition-colors duration-300 ease-in-out hover:bg-gray-900 focus:outline-none\"\n            >\n              Verify\n            </Button>\n          </form>\n\n          <p className=\"mt-6 px-4 text-center text-sm text-muted-foreground sm:px-12\">\n            Didn&apos;t receive a code?{\" \"}\n            <Link href=\"/login\" className=\"text-gray-900 underline\">\n              Try again\n            </Link>\n          </p>\n\n          <p className=\"mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-12\">\n            By clicking continue, you acknowledge that you have read and agree\n            to Papermark&apos;s{\" \"}\n            <a\n              href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}\n              target=\"_blank\"\n              className=\"underline\"\n            >\n              Terms of Service\n            </a>{\" \"}\n            and{\" \"}\n            <a\n              href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}\n              target=\"_blank\"\n              className=\"underline\"\n            >\n              Privacy Policy\n            </a>\n            .\n          </p>\n        </div>\n      </div>\n      <TestimonialSection />\n    </div>\n  );\n}\n\nfunction TestimonialSection() {\n  return (\n    <div className=\"relative hidden w-full justify-center overflow-hidden bg-black md:flex md:w-1/2 lg:w-1/2\">\n      <div className=\"relative m-0 flex h-full min-h-[700px] w-full p-0\">\n        <div\n          className=\"relative flex h-full w-full flex-col justify-between\"\n          id=\"features\"\n        >\n          {/* Testimonial top 2/3 */}\n          <div\n            className=\"flex w-full flex-col items-center justify-center\"\n            style={{ height: \"66.6666%\" }}\n          >\n            {/* Image container */}\n            <div className=\"mb-4 h-64 w-80\">\n              <img\n                className=\"h-full w-full rounded-2xl object-cover shadow-2xl\"\n                src=\"/_static/testimonials/backtrace.jpeg\"\n                alt=\"Backtrace Capital\"\n              />\n            </div>\n            {/* Text content */}\n            <div className=\"max-w-xl text-center\">\n              <blockquote className=\"text-balance font-normal leading-8 text-white sm:text-xl sm:leading-9\">\n                <p>\n                  &quot;We raised our €30M Fund with Papermark Data Rooms. Love\n                  the customization, security and ease of use.&quot;\n                </p>\n              </blockquote>\n              <figcaption className=\"mt-4\">\n                <div className=\"text-balance font-normal text-white\">\n                  Michael Münnix\n                </div>\n                <div className=\"text-balance font-light text-gray-400\">\n                  Partner, Backtrace Capital\n                </div>\n              </figcaption>\n            </div>\n          </div>\n          {/* White block with logos bottom 1/3, full width/height */}\n          <div\n            className=\"absolute bottom-0 left-0 flex w-full flex-col items-center justify-center bg-white\"\n            style={{ height: \"33.3333%\" }}\n          >\n            <div className=\"mb-4 max-w-xl text-balance text-center font-semibold text-gray-900\">\n              Trusted by teams at\n            </div>\n            <LogoCloud />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/auth/email/[[...params]]/page.tsx",
    "content": "import { Metadata } from \"next\";\n\nimport EmailVerificationClient from \"./page-client\";\n\nconst data = {\n  description: \"Verify your login to Papermark\",\n  title: \"Verify Login | Papermark\",\n  url: \"/auth/email\",\n};\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(\"https://www.papermark.com\"),\n  title: data.title,\n  description: data.description,\n  openGraph: {\n    title: data.title,\n    description: data.description,\n    url: data.url,\n    siteName: \"Papermark\",\n    images: [\n      {\n        url: \"/_static/meta-image.png\",\n        width: 800,\n        height: 600,\n      },\n    ],\n    locale: \"en_US\",\n    type: \"website\",\n  },\n  twitter: {\n    card: \"summary_large_image\",\n    title: data.title,\n    description: data.description,\n    creator: \"@papermarkio\",\n    images: [\"/_static/meta-image.png\"],\n  },\n};\n\nexport default async function EmailVerificationPage() {\n  return <EmailVerificationClient />;\n}\n"
  },
  {
    "path": "app/(auth)/auth/saml/page-client.tsx",
    "content": "\"use client\";\n\nimport { useRouter, useSearchParams } from \"next/navigation\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { signIn } from \"next-auth/react\";\n\n/**\n * SAML Callback Page\n *\n * This page handles IdP-initiated SSO flow:\n * 1. User clicks the app tile in their IdP dashboard\n * 2. Jackson processes the SAML response and redirects here with a `code`\n * 3. We exchange the code via the `saml-idp` CredentialsProvider\n *\n * SP-initiated SSO (user clicks \"Continue with SSO\" on login page) is handled\n * entirely by NextAuth's OAuth flow via the `saml` provider — it never hits this page.\n */\nexport default function SAMLCallbackClient() {\n  const searchParams = useSearchParams();\n  const router = useRouter();\n  const [status, setStatus] = useState<\"loading\" | \"error\">(\"loading\");\n  const [errorMessage, setErrorMessage] = useState<string>(\"\");\n\n  useEffect(() => {\n    const code = searchParams?.get(\"code\");\n    if (code) {\n      signIn(\"saml-idp\", {\n        code,\n        redirect: false,\n      }).then((result) => {\n        if (result?.ok) {\n          router.push(\"/dashboard\");\n        } else {\n          setStatus(\"error\");\n          setErrorMessage(\n            result?.error || \"SSO authentication failed. Please try again.\",\n          );\n        }\n      });\n    } else {\n      setStatus(\"error\");\n      setErrorMessage(\n        \"No authorization code received from your identity provider.\",\n      );\n    }\n  }, [searchParams, router]);\n\n  if (status === \"error\") {\n    return (\n      <div className=\"flex min-h-screen items-center justify-center\">\n        <div className=\"mx-auto max-w-md text-center\">\n          <h2 className=\"text-xl font-semibold text-gray-900\">\n            SSO Login Failed\n          </h2>\n          <p className=\"mt-2 text-sm text-gray-600\">{errorMessage}</p>\n          <button\n            onClick={() => router.push(\"/login\")}\n            className=\"mt-4 rounded-md bg-gray-900 px-4 py-2 text-sm text-white hover:bg-gray-800\"\n          >\n            Return to Login\n          </button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex min-h-screen items-center justify-center\">\n      <div className=\"mx-auto max-w-md text-center\">\n        <div className=\"mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900\" />\n        <p className=\"text-sm text-gray-600\">Completing SSO login...</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/auth/saml/page.tsx",
    "content": "import { Metadata } from \"next\";\n\nimport SAMLCallbackClient from \"./page-client\";\n\nexport const metadata: Metadata = {\n  title: \"SSO Login | Papermark\",\n  description: \"Completing SSO login\",\n};\n\nexport default function SAMLCallbackPage() {\n  return <SAMLCallbackClient />;\n}\n"
  },
  {
    "path": "app/(auth)/layout.tsx",
    "content": "\"use client\";\n\nimport { SessionProvider } from \"next-auth/react\";\nimport { Toaster } from \"sonner\";\n\nimport { ThemeProvider } from \"@/components/theme-provider\";\n\nexport default function Layout({ children }: { children: React.ReactNode }) {\n  return (\n    <SessionProvider>\n      <ThemeProvider attribute=\"class\" defaultTheme=\"light\" enableSystem>\n        <main>\n          <Toaster closeButton richColors theme=\"system\" />\n          <div>{children}</div>\n        </main>\n      </ThemeProvider>\n    </SessionProvider>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/login/page-client.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useParams, useRouter, useSearchParams } from \"next/navigation\";\n\nimport { useState } from \"react\";\n\nimport { AlertCircle } from \"lucide-react\";\n\nimport { SSOLogin } from \"@/ee/features/security/sso\";\nimport { signInWithPasskey } from \"@teamhanko/passkeys-next-auth-provider/client\";\nimport { signIn } from \"next-auth/react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { LastUsed, useLastUsed } from \"@/components/hooks/useLastUsed\";\nimport Google from \"@/components/shared/icons/google\";\nimport LinkedIn from \"@/components/shared/icons/linkedin\";\nimport Passkey from \"@/components/shared/icons/passkey\";\nimport { LogoCloud } from \"@/components/shared/logo-cloud\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nexport default function Login() {\n  const { next } = useParams as { next?: string };\n  const router = useRouter();\n  const searchParams = useSearchParams();\n  const authError = searchParams?.get(\"error\");\n  const isSSORequired = authError === \"require-saml-sso\";\n\n  const [lastUsed, setLastUsed] = useLastUsed();\n  const authMethods = [\"google\", \"email\", \"linkedin\", \"passkey\"] as const;\n  type AuthMethod = (typeof authMethods)[number];\n  const [clickedMethod, setClickedMethod] = useState<AuthMethod | undefined>(\n    undefined,\n  );\n  const [email, setEmail] = useState<string>(\"\");\n  const [emailButtonText, setEmailButtonText] = useState<string>(\n    \"Continue with Email\",\n  );\n\n  const emailSchema = z\n    .string()\n    .trim()\n    .toLowerCase()\n    .min(3, { message: \"Please enter a valid email.\" })\n    .email({ message: \"Please enter a valid email.\" });\n\n  const emailValidation = emailSchema.safeParse(email);\n\n  return (\n    <div className=\"flex h-screen w-full flex-wrap\">\n      {/* Left part */}\n      <div className=\"flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2\">\n        <div\n          className=\"absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl\"\n          aria-hidden=\"true\"\n        ></div>\n        <div className=\"z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]\">\n          <div className=\"items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12\">\n            <Link href=\"https://www.papermark.com\" target=\"_blank\">\n              <img\n                src=\"/_static/papermark-logo.svg\"\n                alt=\"Papermark Logo\"\n                className=\"md:mb-48s -mt-8 mb-36 h-7 w-auto self-start sm:mb-32\"\n              />\n            </Link>\n            <Link href=\"/\">\n              <span className=\"text-balance text-3xl font-semibold text-gray-900\">\n                Welcome to Papermark\n              </span>\n            </Link>\n            <h3 className=\"text-balance text-sm text-gray-800\">\n              Share documents. Not attachments.\n            </h3>\n          </div>\n          {isSSORequired && (\n            <div className=\"mx-4 mb-2 flex items-start gap-3 rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 sm:mx-12\">\n              <AlertCircle className=\"mt-0.5 h-5 w-5 flex-shrink-0 text-orange-600\" />\n              <div>\n                <p className=\"text-sm font-medium text-orange-900\">\n                  Your organization requires SSO login\n                </p>\n                <p className=\"mt-1 text-sm text-orange-700\">\n                  Please use the <strong>SAML SSO</strong> option below to sign\n                  in with your company&apos;s identity provider.\n                </p>\n              </div>\n            </div>\n          )}\n          <form\n            className=\"flex flex-col gap-4 px-4 pt-8 sm:px-12\"\n            onSubmit={(e) => {\n              e.preventDefault();\n              if (!emailValidation.success) {\n                toast.error(emailValidation.error.errors[0].message);\n                return;\n              }\n\n              setClickedMethod(\"email\");\n              signIn(\"email\", {\n                email: emailValidation.data,\n                redirect: false,\n                ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n              }).then((res) => {\n                if (res?.ok && !res?.error) {\n                  setLastUsed(\"credentials\");\n                  // Store email in sessionStorage for the verification page\n                  try {\n                    sessionStorage.setItem(\n                      \"pendingVerificationEmail\",\n                      emailValidation.data,\n                    );\n                  } catch {\n                    // sessionStorage not available, verification page will show email input\n                  }\n                  router.push(\"/auth/email\");\n                } else {\n                  setEmailButtonText(\"Error sending email - try again?\");\n                  toast.error(\"Error sending email - try again?\");\n                  setClickedMethod(undefined);\n                }\n              });\n            }}\n          >\n            <Label className=\"sr-only\" htmlFor=\"email\">\n              Email\n            </Label>\n            <Input\n              id=\"email\"\n              placeholder=\"name@example.com\"\n              type=\"email\"\n              autoCapitalize=\"none\"\n              autoComplete=\"email\"\n              autoCorrect=\"off\"\n              disabled={clickedMethod === \"email\"}\n              // pattern={patternSimpleEmailRegex}\n              value={email}\n              onChange={(e) => setEmail(e.target.value)}\n              className={cn(\n                \"flex h-10 w-full rounded-md border-0 bg-background bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white\",\n                email.length > 0 && !emailValidation.success\n                  ? \"ring-red-500\"\n                  : \"ring-gray-200\",\n              )}\n            />\n            <div className=\"relative\">\n              <Button\n                type=\"submit\"\n                loading={clickedMethod === \"email\"}\n                disabled={!emailValidation.success || !!clickedMethod}\n                className={cn(\n                  \"focus:shadow-outline w-full transform rounded px-4 py-2 text-white transition-colors duration-300 ease-in-out focus:outline-none\",\n                  clickedMethod === \"email\"\n                    ? \"bg-black\"\n                    : \"bg-gray-800 hover:bg-gray-900\",\n                )}\n              >\n                {emailButtonText}\n              </Button>\n              {lastUsed === \"credentials\" && <LastUsed />}\n            </div>\n          </form>\n          <p className=\"py-4 text-center\">or</p>\n          <div className=\"flex flex-col space-y-2 px-4 sm:px-12\">\n            <div className=\"relative\">\n              <Button\n                onClick={() => {\n                  setClickedMethod(\"google\");\n                  setLastUsed(\"google\");\n                  signIn(\"google\", {\n                    ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n                  }).then((res) => {\n                    setClickedMethod(undefined);\n                  });\n                }}\n                loading={clickedMethod === \"google\"}\n                disabled={clickedMethod && clickedMethod !== \"google\"}\n                className=\"flex w-full items-center justify-center space-x-2 border border-gray-300 bg-gray-100 font-normal text-gray-900 hover:bg-gray-200\"\n              >\n                <Google className=\"h-5 w-5\" />\n                <span>Continue with Google</span>\n                {clickedMethod !== \"google\" && lastUsed === \"google\" && (\n                  <LastUsed />\n                )}\n              </Button>\n            </div>\n            <div className=\"relative\">\n              <Button\n                onClick={() => {\n                  setClickedMethod(\"linkedin\");\n                  setLastUsed(\"linkedin\");\n                  signIn(\"linkedin\", {\n                    ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n                  }).then((res) => {\n                    setClickedMethod(undefined);\n                  });\n                }}\n                loading={clickedMethod === \"linkedin\"}\n                disabled={clickedMethod && clickedMethod !== \"linkedin\"}\n                className=\"flex w-full items-center justify-center space-x-2 border border-gray-300 bg-gray-100 font-normal text-gray-900 hover:bg-gray-200\"\n              >\n                <LinkedIn />\n                <span>Continue with LinkedIn</span>\n                {clickedMethod !== \"linkedin\" && lastUsed === \"linkedin\" && (\n                  <LastUsed />\n                )}\n              </Button>\n            </div>\n            <div className=\"relative\">\n              <Button\n                onClick={() => {\n                  setLastUsed(\"passkey\");\n                  setClickedMethod(\"passkey\");\n                  signInWithPasskey({\n                    tenantId: process.env.NEXT_PUBLIC_HANKO_TENANT_ID as string,\n                  }).then(() => {\n                    setClickedMethod(undefined);\n                  });\n                }}\n                variant=\"outline\"\n                loading={clickedMethod === \"passkey\"}\n                disabled={clickedMethod && clickedMethod !== \"passkey\"}\n                className=\"flex w-full items-center justify-center space-x-2 border border-gray-300 bg-gray-100 font-normal text-gray-900 hover:bg-gray-200 hover:text-gray-900\"\n              >\n                <Passkey className=\"h-4 w-4\" />\n                <span>Continue with a passkey</span>\n                {lastUsed === \"passkey\" && <LastUsed />}\n              </Button>\n            </div>\n            <div className=\"relative\">\n              <SSOLogin autoExpand={isSSORequired} />\n            </div>\n          </div>\n          <p className=\"mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-12\">\n            By clicking continue, you acknowledge that you have read and agree\n            to Papermark&apos;s{\" \"}\n            <a\n              href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}\n              target=\"_blank\"\n              className=\"underline\"\n            >\n              Terms of Service\n            </a>{\" \"}\n            and{\" \"}\n            <a\n              href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}\n              target=\"_blank\"\n              className=\"underline\"\n            >\n              Privacy Policy\n            </a>\n            .\n          </p>\n        </div>\n      </div>\n      <div className=\"relative hidden w-full justify-center overflow-hidden bg-black md:flex md:w-1/2 lg:w-1/2\">\n        <div className=\"relative m-0 flex h-full min-h-[700px] w-full p-0\">\n          <div\n            className=\"relative flex h-full w-full flex-col justify-between\"\n            id=\"features\"\n          >\n            {/* Testimonial top 2/3 */}\n            <div\n              className=\"flex w-full flex-col items-center justify-center\"\n              style={{ height: \"66.6666%\" }}\n            >\n              {/* Image container */}\n              <div className=\"mb-4 h-64 w-80\">\n                <img\n                  className=\"h-full w-full rounded-2xl object-cover shadow-2xl\"\n                  src=\"/_static/testimonials/backtrace.jpeg\"\n                  alt=\"Backtrace Capital\"\n                />\n              </div>\n              {/* Text content */}\n              <div className=\"max-w-xl text-center\">\n                <blockquote className=\"text-balance font-normal leading-8 text-white sm:text-xl sm:leading-9\">\n                  <p>\n                    &quot;We raised our €30M Fund with Papermark Data Rooms.\n                    Love the customization, security and ease of use.&quot;\n                  </p>\n                </blockquote>\n                <figcaption className=\"mt-4\">\n                  <div className=\"text-balance font-normal text-white\">\n                    Michael Münnix\n                  </div>\n                  <div className=\"text-balance font-light text-gray-400\">\n                    Partner, Backtrace Capital\n                  </div>\n                </figcaption>\n              </div>\n            </div>\n            {/* White block with logos bottom 1/3, full width/height */}\n            <div\n              className=\"absolute bottom-0 left-0 flex w-full flex-col items-center justify-center bg-white\"\n              style={{ height: \"33.3333%\" }}\n            >\n              <div className=\"mb-4 max-w-xl text-balance text-center font-semibold text-gray-900\">\n                Trusted by teams at\n              </div>\n              <LogoCloud />\n              {/* <img\n                src=\"https://assets.papermark.io/upload/file_7JEGY7zM9ZTfmxu8pe7vWj-Screenshot-2025-05-09-at-18.09.13.png\"\n                alt=\"Trusted teams illustration\"\n                className=\"mt-4 max-w-full h-auto object-contain\"\n                style={{maxHeight: '120px'}}\n              /> */}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/login/page.tsx",
    "content": "import { Metadata } from \"next\";\n\nimport { GTMComponent } from \"@/components/gtm-component\";\n\nimport LoginClient from \"./page-client\";\n\nconst data = {\n  description: \"Login to Papermark\",\n  title: \"Login | Papermark\",\n  url: \"/login\",\n};\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(\"https://www.papermark.com\"),\n  title: data.title,\n  description: data.description,\n  openGraph: {\n    title: data.title,\n    description: data.description,\n    url: data.url,\n    siteName: \"Papermark\",\n    images: [\n      {\n        url: \"/_static/meta-image.png\",\n        width: 800,\n        height: 600,\n      },\n    ],\n    locale: \"en_US\",\n    type: \"website\",\n  },\n  twitter: {\n    card: \"summary_large_image\",\n    title: data.title,\n    description: data.description,\n    creator: \"@papermarkio\",\n    images: [\"/_static/meta-image.png\"],\n  },\n};\n\nexport default function LoginPage() {\n  return (\n    <>\n      <GTMComponent />\n      <LoginClient />\n    </>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/register/page-client.tsx",
    "content": "\"use client\";\n\nimport Image from \"next/image\";\nimport Link from \"next/link\";\nimport { useParams } from \"next/navigation\";\n\nimport { useState } from \"react\";\n\nimport PapermarkLogo from \"@/public/_static/papermark-logo.svg\";\nimport { signIn } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport LinkedIn from \"@/components/shared/icons/linkedin\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\n\nexport default function Register() {\n  const { next } = useParams as { next?: string };\n\n  const [email, setEmail] = useState<string>(\"\");\n\n  return (\n    <div className=\"flex h-screen w-full justify-center\">\n      <div\n        className=\"absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl\"\n        aria-hidden=\"true\"\n      >\n        <div\n          className=\"aspect-[1108/632] w-[69.25rem] flex-none bg-gradient-to-r from-[#80caff] to-[#4f46e5] opacity-20\"\n          style={{\n            clipPath:\n              \"polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)\",\n          }}\n        />\n      </div>\n      <div className=\"z-10 mx-5 mt-[calc(20vh)] h-fit w-full max-w-md overflow-hidden rounded-lg border border-border bg-gray-50 dark:bg-gray-900 sm:mx-0 sm:shadow-xl\">\n        <div className=\"flex flex-col items-center justify-center space-y-3 px-4 py-6 pt-8 text-center sm:px-16\">\n          <Link href=\"https://www.papermark.com\" target=\"_blank\">\n            <Image\n              src={PapermarkLogo}\n              width={119}\n              height={32}\n              alt=\"Papermark Logo\"\n            />\n          </Link>\n          <h3 className=\"text-2xl font-medium text-foreground\">\n            Start sharing documents\n          </h3>\n        </div>\n        <form\n          className=\"flex flex-col gap-4 p-4 pt-8 sm:px-16\"\n          onSubmit={(e) => {\n            e.preventDefault();\n            signIn(\"email\", {\n              email: email,\n              redirect: false,\n              ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n            }).then((res) => {\n              if (res?.ok && !res?.error) {\n                setEmail(\"\");\n                toast.success(\"Email sent - check your inbox!\");\n              } else {\n                toast.error(\"Error sending email - try again?\");\n              }\n            });\n          }}\n        >\n          <Input\n            className=\"border-4\"\n            placeholder=\"jsmith@company.co\"\n            value={email}\n            onChange={(e) => setEmail(e.target.value)}\n          />\n          <Button type=\"submit\">Continue with Email</Button>\n        </form>\n        <p className=\"text-center\">or</p>\n        <div className=\"flex flex-col space-y-2 px-4 py-8 sm:px-16\">\n          <Button\n            onClick={() => {\n              signIn(\"google\", {\n                ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n              });\n            }}\n            className=\"flex items-center justify-center space-x-2\"\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 488 512\"\n              fill=\"currentColor\"\n              className=\"h-4 w-4\"\n            >\n              <path d=\"M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z\" />\n            </svg>\n            <span>Continue with Google</span>\n          </Button>\n          <Button\n            onClick={() => {\n              signIn(\"linkedin\", {\n                ...(next && next.length > 0 ? { callbackUrl: next } : {}),\n              });\n            }}\n            className=\"flex items-center justify-center space-x-2\"\n          >\n            <LinkedIn />\n            <span>Continue with LinkedIn</span>\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/register/page.tsx",
    "content": "import { Metadata } from \"next\";\n\nimport RegisterClient from \"./page-client\";\n\nconst data = {\n  description: \"Signup to Papermark\",\n  title: \"Sign up | Papermark\",\n  url: \"/register\",\n};\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(\"https://www.papermark.com\"),\n  title: data.title,\n  description: data.description,\n  openGraph: {\n    title: data.title,\n    description: data.description,\n    url: data.url,\n    siteName: \"Papermark\",\n    images: [\n      {\n        url: \"/_static/meta-image.png\",\n        width: 800,\n        height: 600,\n      },\n    ],\n    locale: \"en_US\",\n    type: \"website\",\n  },\n  twitter: {\n    card: \"summary_large_image\",\n    title: data.title,\n    description: data.description,\n    creator: \"@papermarkio\",\n    images: [\"/_static/meta-image.png\"],\n  },\n};\n\nexport default function RegisterPage() {\n  return <RegisterClient />;\n}\n"
  },
  {
    "path": "app/(auth)/verify/invitation/AcceptInvitationButton.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\n\ninterface AcceptInvitationButtonProps {\n  verificationUrl: string;\n}\n\nexport default function AcceptInvitationButton({\n  verificationUrl,\n}: AcceptInvitationButtonProps) {\n  const [isLoading, setIsLoading] = useState(false);\n\n  const handleAccept = () => {\n    setIsLoading(true);\n  };\n\n  return (\n    <Link href={verificationUrl}>\n      <Button\n        className=\"focus:shadow-outline w-full transform rounded-lg bg-gray-800 px-4 py-3 text-white transition-colors duration-300 ease-in-out hover:bg-gray-900 focus:outline-none\"\n        onClick={handleAccept}\n        loading={isLoading}\n      >\n        Accept Invitation\n      </Button>\n    </Link>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/verify/invitation/InvitationStatusContent.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\n\nimport { Button } from \"@/components/ui/button\";\n\nexport default function InvitationStatusContent({\n  status,\n}: {\n  status: \"expired\" | \"revoked\";\n}) {\n  const statusConfig = {\n    expired: {\n      title: \"Invitation Expired\",\n      message:\n        \"This invitation has expired. It is no longer valid.\\nAsk your admin to send you a new invitation.\",\n      iconColor: \"bg-amber-100\",\n      iconTextColor: \"text-amber-600\",\n    },\n    revoked: {\n      title: \"Invitation No Longer Available\",\n      message: \"It may have been used or revoked \\nby the team administrator.\",\n      iconColor: \"bg-red-100\",\n      iconTextColor: \"text-red-600\",\n    },\n  };\n\n  const config = statusConfig[status];\n\n  return (\n    <div className=\"flex flex-col items-center space-y-6\">\n      <div className=\"w-full text-center\">\n        <h3 className=\"text-2xl font-bold text-gray-800\">{config.title}</h3>\n        <p className=\"mx-auto mt-2 max-w-xs whitespace-pre-line break-words text-sm text-gray-600\">\n          {config.message}\n        </p>\n      </div>\n      <div className=\"w-full space-y-4\">\n        <h4 className=\"text-center text-sm font-medium text-gray-800\">\n          Create your own Papermark account\n        </h4>\n        <div className=\"space-y-3\">\n          <Link href=\"/login\" className=\"block w-full\">\n            <Button className=\"w-full bg-gray-800 hover:bg-gray-900\">\n              Register\n            </Button>\n          </Link>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/verify/invitation/page.tsx",
    "content": "import { Metadata } from \"next\";\nimport Link from \"next/link\";\n\nimport NotFound from \"@/pages/404\";\nimport { format } from \"date-fns\";\nimport { ClockIcon, MailIcon } from \"lucide-react\";\n\nimport prisma from \"@/lib/prisma\";\nimport { verifyJWT } from \"@/lib/utils/generate-jwt\";\n\nimport AcceptInvitationButton from \"./AcceptInvitationButton\";\nimport InvitationStatusContent from \"./InvitationStatusContent\";\nimport CleanUrlOnExpire from \"./status/ClientRedirect\";\n\nconst data = {\n  description: \"Accept your team invitation on Papermark\",\n  title: \"Accept Invitation | Papermark\",\n  url: \"/verify/invitation\",\n};\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(\"https://www.papermark.com\"),\n  title: data.title,\n  description: data.description,\n  openGraph: {\n    title: data.title,\n    description: data.description,\n    url: data.url,\n    siteName: \"Papermark\",\n    images: [\n      {\n        url: \"/_static/meta-image.png\",\n        width: 800,\n        height: 600,\n      },\n    ],\n    locale: \"en_US\",\n    type: \"website\",\n  },\n  twitter: {\n    card: \"summary_large_image\",\n    title: data.title,\n    description: data.description,\n    creator: \"@papermarkio\",\n    images: [\"/_static/meta-image.png\"],\n  },\n};\n\nexport default async function VerifyInvitationPage({\n  searchParams,\n}: {\n  searchParams: {\n    token?: string;\n  };\n}) {\n  const { token: jwtToken } = searchParams;\n\n  if (!jwtToken) {\n    return <NotFound />;\n  }\n\n  // verify JWT token\n  const payload = verifyJWT(jwtToken);\n\n  if (!payload) {\n    return <NotFound />;\n  }\n\n  const { verification_url, teamId, token, email, expiresAt } = payload;\n\n  // Validate required parameters\n  if (!verification_url || !teamId || !token || !email) {\n    return <NotFound />;\n  }\n  const isExpired = expiresAt ? new Date() > new Date(expiresAt) : false;\n  let isRevoked = false;\n  if (!isExpired) {\n    try {\n      const invitation = await prisma.invitation.findUnique({\n        where: {\n          token: token,\n        },\n      });\n      isRevoked = !invitation;\n    } catch (error) {\n      console.error(\"Error checking invitation status:\", error);\n    }\n  }\n  return (\n    <>\n      <CleanUrlOnExpire shouldClean={isExpired || isRevoked} />\n      <div className=\"flex h-screen w-full flex-wrap\">\n        {/* Left part */}\n        <div className=\"flex h-full w-full items-center justify-center bg-white md:w-1/2 lg:w-2/5\">\n          <div\n            className=\"absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl\"\n            aria-hidden=\"true\"\n          ></div>\n          <div className=\"z-10 mx-auto h-fit w-full max-w-md overflow-hidden rounded-lg\">\n            <div className=\"flex flex-col items-center justify-center space-y-3 px-4 py-6 pt-8 text-center sm:px-16\">\n              <Link href=\"/\">\n                <span className=\"text-balance text-2xl font-semibold text-gray-800\">\n                  Welcome to Papermark\n                </span>\n              </Link>\n              {!isExpired && !isRevoked && (\n                <>\n                  <h3 className=\"text-balance py-1 text-sm font-normal text-gray-800\">\n                    You&apos;ve been invited to join a team on Papermark\n                  </h3>\n                  <div className=\"mt-2 flex w-auto items-center justify-center gap-2 rounded-full bg-gray-50 px-5 py-2.5 text-sm text-gray-600 shadow-sm\">\n                    <MailIcon className=\"h-4 w-4 text-gray-400\" />\n                    {email}\n                  </div>\n                </>\n              )}\n            </div>\n\n            {isRevoked || isExpired ? (\n              <div className=\"px-4 py-6 sm:px-16\">\n                <InvitationStatusContent status={\"expired\"} />\n              </div>\n            ) : (\n              <>\n                <div className=\"flex flex-col gap-4 px-4 pt-8 sm:px-16\">\n                  <div className=\"relative\">\n                    <AcceptInvitationButton\n                      verificationUrl={verification_url}\n                    />\n                  </div>\n                  {expiresAt ? (\n                    <div className=\"text-center text-sm text-gray-500\">\n                      <p className=\"flex items-center justify-center gap-1.5 rounded-md bg-amber-50 px-3 py-2 text-amber-700\">\n                        <ClockIcon className=\"h-4 w-4 text-amber-500\" />\n                        <span>\n                          Expires on{\" \"}\n                          <span className=\"font-medium\">\n                            {format(new Date(expiresAt), \"MMM d, yyyy\")}\n                          </span>{\" \"}\n                          at{\" \"}\n                          <span className=\"font-medium\">\n                            {format(new Date(expiresAt), \"h:mm a\")}\n                          </span>\n                        </span>\n                      </p>\n                    </div>\n                  ) : null}\n                </div>\n                <p className=\"mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-16\">\n                  By accepting this invitation, you acknowledge that you have\n                  read and agree to Papermark&apos;s{\" \"}\n                  <a\n                    href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}\n                    target=\"_blank\"\n                    className=\"underline hover:text-gray-900\"\n                  >\n                    Terms of Service\n                  </a>{\" \"}\n                  and{\" \"}\n                  <a\n                    href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}\n                    target=\"_blank\"\n                    className=\"underline hover:text-gray-900\"\n                  >\n                    Privacy Policy\n                  </a>\n                  .\n                </p>\n              </>\n            )}\n          </div>\n        </div>\n        {/* Right part */}\n        <div className=\"hidden h-full w-full justify-center bg-gray-800 md:flex md:w-1/2 lg:w-3/5\">\n          <div className=\"flex w-full max-w-5xl px-4 py-20 md:px-8\">\n            <div\n              className=\"mx-auto flex w-full max-w-5xl justify-center rounded-3xl bg-gray-800 px-4 py-20 md:px-8\"\n              id=\"features\"\n            >\n              <div className=\"flex flex-col items-center justify-center\">\n                {/* Image container */}\n                <div className=\"mb-4 h-64 w-64\">\n                  <img\n                    className=\"h-full w-full rounded-2xl object-cover shadow-2xl\"\n                    src=\"/_static/testimonials/jaski.jpeg\"\n                    alt=\"Jaski\"\n                  />\n                </div>\n                {/* Text content */}\n                <div className=\"max-w-xl text-center\">\n                  <blockquote className=\"text-l text-balance leading-8 text-gray-100 sm:text-xl sm:leading-9\">\n                    <p>\n                      True builders listen to their users and build what they\n                      need. Thanks Papermark team for solving a big pain point.\n                      DocSend monopoly will end soon!\n                    </p>\n                  </blockquote>\n                  <figcaption className=\"mt-4\">\n                    <div className=\"text-balance font-semibold text-white\">\n                      Jaski\n                    </div>\n                    <div className=\"text-balance text-gray-400\">\n                      Founder, Townhall Network\n                    </div>\n                  </figcaption>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "app/(auth)/verify/invitation/status/ClientRedirect.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\nexport default function CleanUrlOnExpire({\n  shouldClean,\n}: {\n  shouldClean: boolean;\n}) {\n  useEffect(() => {\n    if (shouldClean && typeof window !== \"undefined\") {\n      const url = new URL(window.location.href);\n      url.search = \"\";\n      window.history.replaceState({}, \"\", url.toString());\n    }\n  }, [shouldClean]);\n\n  return null;\n}\n"
  },
  {
    "path": "app/(auth)/verify/page.tsx",
    "content": "import { redirect } from \"next/navigation\";\n\n// Legacy verify page - redirect to new auth email flow\nexport default async function VerifyPage() {\n  redirect(\"/auth/email\");\n}\n"
  },
  {
    "path": "app/(ee)/LICENSE.md",
    "content": "../ee/LICENSE.md\n"
  },
  {
    "path": "app/(ee)/README.md",
    "content": "../ee/README.md\n"
  },
  {
    "path": "app/(ee)/api/ai/chat/[chatId]/messages/route.ts",
    "content": "import { NextRequest } from \"next/server\";\n\nimport { generateChatTitle } from \"@/ee/features/ai/lib/chat/generate-chat-title\";\nimport { getFilteredDataroomDocumentIds } from \"@/ee/features/ai/lib/chat/get-filtered-dataroom-document-ids\";\nimport { sendMessage } from \"@/ee/features/ai/lib/chat/send-message\";\nimport { validateChatAccess } from \"@/ee/features/ai/lib/permissions/validate-chat-access\";\nimport { sendMessageSchema } from \"@/ee/features/ai/schemas/chat\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * POST /api/ai/chat/[chatId]/messages\n * Send a message and get streaming response\n */\nexport async function POST(\n  req: NextRequest,\n  { params }: { params: { chatId: string } },\n) {\n  try {\n    const { chatId } = params;\n    const body = await req.json();\n    const validation = sendMessageSchema.safeParse(body);\n\n    if (!validation.success) {\n      return new Response(\n        JSON.stringify({\n          error: \"Invalid request body\",\n          details: validation.error,\n        }),\n        { status: 400, headers: { \"Content-Type\": \"application/json\" } },\n      );\n    }\n\n    const {\n      content,\n      filterDocumentId,\n      filterDataroomDocumentIds,\n    } = validation.data;\n\n    const session = await getServerSession(authOptions);\n    const searchParams = req.nextUrl.searchParams;\n    const viewerId = searchParams.get(\"viewerId\");\n\n    let userId: string | undefined;\n\n    if (session) {\n      userId = (session.user as CustomUser).id;\n    }\n\n    // Validate access\n    const hasAccess = await validateChatAccess({\n      chatId,\n      userId,\n      viewerId: viewerId || undefined,\n    });\n\n    if (!hasAccess) {\n      return new Response(JSON.stringify({ error: \"Unauthorized\" }), {\n        status: 401,\n        headers: { \"Content-Type\": \"application/json\" },\n      });\n    }\n\n    // Get chat details\n    const chat = await prisma.chat.findUnique({\n      where: { id: chatId },\n      include: {\n        messages: {\n          take: 1,\n        },\n      },\n    });\n\n    if (!chat) {\n      return new Response(JSON.stringify({ error: \"Chat not found\" }), {\n        status: 404,\n        headers: { \"Content-Type\": \"application/json\" },\n      });\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId: chat.teamId });\n    if (!features.ai) {\n      return new Response(\n        JSON.stringify({\n          error: \"AI features are not available for this team\",\n        }),\n        { status: 403, headers: { \"Content-Type\": \"application/json\" } },\n      );\n    }\n\n    if (!chat.vectorStoreId) {\n      return new Response(\n        JSON.stringify({\n          error: \"Vector store not available. Please index documents first.\",\n        }),\n        { status: 400, headers: { \"Content-Type\": \"application/json\" } },\n      );\n    }\n\n    if (\n      filterDataroomDocumentIds &&\n      filterDataroomDocumentIds.length > 0 &&\n      !chat.dataroomId\n    ) {\n      return new Response(\n        JSON.stringify({\n          error:\n            \"Dataroom document filters are only allowed for dataroom chats\",\n        }),\n        { status: 400, headers: { \"Content-Type\": \"application/json\" } },\n      );\n    }\n\n    // Generate title from first message if not set\n    if (!chat.title && chat.messages.length === 0) {\n      const title = await generateChatTitle(content);\n      await prisma.chat.update({\n        where: { id: chatId },\n        data: { title },\n      });\n    }\n\n    // Get filtered dataroom document IDs based on permissions\n    let filteredDataroomDocumentIds: string[] | undefined;\n\n    if (chat.dataroomId && chat.linkId) {\n      filteredDataroomDocumentIds = await getFilteredDataroomDocumentIds(\n        chat.dataroomId,\n        chat.linkId,\n      );\n    }\n\n    // Send message and get streaming response\n    const { result, referencesForStream } = await sendMessage({\n      chatId,\n      content,\n      vectorStoreId: chat.vectorStoreId,\n      filteredDataroomDocumentIds,\n      filterDocumentId,\n      userSelectedDataroomDocumentIds: filterDataroomDocumentIds,\n      dataroomId: chat.dataroomId || undefined,\n      linkId: chat.linkId || undefined,\n    });\n\n    const encoder = new TextEncoder();\n    const stream = new ReadableStream<Uint8Array>({\n      async start(controller) {\n        try {\n          for await (const chunk of result.textStream) {\n            controller.enqueue(encoder.encode(chunk));\n          }\n\n          const referencesSection = await Promise.race([\n            referencesForStream,\n            new Promise<string>((resolve) =>\n              setTimeout(() => resolve(\"\"), 5000),\n            ),\n          ]);\n\n          if (referencesSection) {\n            controller.enqueue(encoder.encode(referencesSection));\n          }\n\n          controller.close();\n        } catch (error) {\n          console.error(\"Error streaming AI response:\", error);\n          controller.error(error);\n        }\n      },\n    });\n\n    return new Response(stream, {\n      headers: {\n        \"Content-Type\": \"text/plain; charset=utf-8\",\n      },\n    });\n  } catch (error) {\n    console.error(\"Error sending message:\", error);\n    return new Response(JSON.stringify({ error: \"Internal server error\" }), {\n      status: 500,\n      headers: { \"Content-Type\": \"application/json\" },\n    });\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/ai/chat/[chatId]/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { validateChatAccess } from \"@/ee/features/ai/lib/permissions/validate-chat-access\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * GET /api/ai/chat/[chatId]\n * Get chat details with messages\n */\nexport async function GET(\n  req: NextRequest,\n  { params }: { params: { chatId: string } },\n) {\n  try {\n    const { chatId } = params;\n    const session = await getServerSession(authOptions);\n    const searchParams = req.nextUrl.searchParams;\n    const viewerId = searchParams.get(\"viewerId\");\n\n    let userId: string | undefined;\n\n    if (session) {\n      userId = (session.user as CustomUser).id;\n    }\n\n    // Validate access\n    const hasAccess = await validateChatAccess({\n      chatId,\n      userId,\n      viewerId: viewerId || undefined,\n    });\n\n    if (!hasAccess) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Fetch chat with messages\n    const chat = await prisma.chat.findUnique({\n      where: { id: chatId },\n      include: {\n        messages: {\n          orderBy: { createdAt: \"asc\" },\n        },\n        document: {\n          select: {\n            id: true,\n            name: true,\n          },\n        },\n        dataroom: {\n          select: {\n            id: true,\n            name: true,\n          },\n        },\n      },\n    });\n\n    if (!chat) {\n      return NextResponse.json({ error: \"Chat not found\" }, { status: 404 });\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId: chat.teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    return NextResponse.json(chat);\n  } catch (error) {\n    console.error(\"Error fetching chat:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n/**\n * DELETE /api/ai/chat/[chatId]\n * Delete a chat\n */\nexport async function DELETE(\n  req: NextRequest,\n  { params }: { params: { chatId: string } },\n) {\n  try {\n    const { chatId } = params;\n    const session = await getServerSession(authOptions);\n\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Validate ownership\n    const hasAccess = await validateChatAccess({\n      chatId,\n      userId,\n    });\n\n    if (!hasAccess) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Verify user owns this chat\n    const chat = await prisma.chat.findUnique({\n      where: { id: chatId },\n    });\n\n    if (!chat || chat.userId !== userId) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId: chat.teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Delete chat (messages will cascade)\n    await prisma.chat.delete({\n      where: { id: chatId },\n    });\n\n    return NextResponse.json({ success: true });\n  } catch (error) {\n    console.error(\"Error deleting chat:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/ai/chat/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { createChat } from \"@/ee/features/ai/lib/chat/create-chat\";\nimport { createChatSchema } from \"@/ee/features/ai/schemas/chat\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport { verifyDataroomSession } from \"@/lib/auth/dataroom-auth\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * POST /api/ai/chat\n * Create a new chat session\n */\nexport async function POST(req: NextRequest) {\n  try {\n    const body = await req.json();\n    const validation = createChatSchema.safeParse(body);\n\n    if (!validation.success) {\n      return NextResponse.json(\n        { error: \"Invalid request body\", details: validation.error },\n        { status: 400 },\n      );\n    }\n\n    const { documentId, dataroomId, linkId, viewId, title, viewerId } =\n      validation.data;\n\n    // Check for either internal user or external viewer authentication\n    const session = await getServerSession(authOptions);\n\n    let userId: string | undefined;\n    let teamId: string;\n\n    // Internal user flow\n    if (session) {\n      userId = (session.user as CustomUser).id;\n\n      // Get team ID from document or dataroom\n      if (dataroomId) {\n        const dataroom = await prisma.dataroom.findUnique({\n          where: { id: dataroomId },\n          select: {\n            teamId: true,\n            agentsEnabled: true,\n            vectorStoreId: true,\n            team: {\n              select: {\n                agentsEnabled: true,\n              },\n            },\n          },\n        });\n\n        if (!dataroom) {\n          return NextResponse.json(\n            { error: \"Dataroom not found\" },\n            { status: 404 },\n          );\n        }\n\n        // Check if team has AI enabled first\n        if (!dataroom.team?.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this team\" },\n            { status: 403 },\n          );\n        }\n\n        if (!dataroom.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this dataroom\" },\n            { status: 403 },\n          );\n        }\n\n        teamId = dataroom.teamId;\n\n        // Verify user is member of team\n        const userTeam = await prisma.userTeam.findUnique({\n          where: {\n            userId_teamId: {\n              userId,\n              teamId,\n            },\n          },\n        });\n\n        if (!userTeam) {\n          return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n        }\n      } else if (documentId) {\n        const document = await prisma.document.findUnique({\n          where: { id: documentId },\n          select: {\n            teamId: true,\n            agentsEnabled: true,\n            team: {\n              select: {\n                agentsEnabled: true,\n              },\n            },\n          },\n        });\n\n        if (!document) {\n          return NextResponse.json(\n            { error: \"Document not found\" },\n            { status: 404 },\n          );\n        }\n\n        // Check if team has AI enabled first\n        if (!document.team?.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this team\" },\n            { status: 403 },\n          );\n        }\n\n        if (!document.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this document\" },\n            { status: 403 },\n          );\n        }\n\n        teamId = document.teamId;\n\n        // Verify user is member of team\n        const userTeam = await prisma.userTeam.findUnique({\n          where: {\n            userId_teamId: {\n              userId,\n              teamId,\n            },\n          },\n        });\n\n        if (!userTeam) {\n          return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n        }\n      } else {\n        return NextResponse.json(\n          { error: \"Either documentId or dataroomId is required\" },\n          { status: 400 },\n        );\n      }\n    }\n    // External viewer flow\n    else if (linkId && viewId && viewerId) {\n      // Verify dataroom session if this is a dataroom link\n      if (dataroomId) {\n        const dataroomSession = await verifyDataroomSession(\n          req,\n          linkId,\n          dataroomId,\n        );\n\n        if (!dataroomSession || dataroomSession.viewerId !== viewerId) {\n          return NextResponse.json(\n            { error: \"Unauthorized - invalid session\" },\n            { status: 401 },\n          );\n        }\n\n        // Verify link has AI enabled\n        const link = await prisma.link.findUnique({\n          where: { id: linkId, dataroomId },\n          select: {\n            enableAIAgents: true,\n            isArchived: true,\n            dataroom: { select: { teamId: true, agentsEnabled: true } },\n            team: { select: { agentsEnabled: true } },\n          },\n        });\n\n        if (!link) {\n          return NextResponse.json(\n            { error: \"Link not found\" },\n            { status: 404 },\n          );\n        }\n\n        if (link?.isArchived) {\n          return NextResponse.json(\n            { error: \"Link is archived\" },\n            { status: 403 },\n          );\n        }\n\n        if (!link?.enableAIAgents) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this link\" },\n            { status: 403 },\n          );\n        }\n\n        if (!link?.dataroom) {\n          return NextResponse.json(\n            { error: \"Dataroom not found\" },\n            { status: 404 },\n          );\n        }\n\n        if (!link?.dataroom?.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this dataroom\" },\n            { status: 403 },\n          );\n        }\n\n        if (!link?.team?.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled\" },\n            { status: 403 },\n          );\n        }\n\n        teamId = link?.dataroom?.teamId;\n      } else if (documentId) {\n        // Verify link access for document\n        const link = await prisma.link.findUnique({\n          where: { id: linkId, documentId },\n          select: {\n            enableAIAgents: true,\n            isArchived: true,\n            document: { select: { teamId: true, agentsEnabled: true } },\n            team: { select: { agentsEnabled: true } },\n          },\n        });\n\n        if (!link) {\n          return NextResponse.json(\n            { error: \"Link not found\" },\n            { status: 404 },\n          );\n        }\n\n        if (link?.isArchived) {\n          return NextResponse.json(\n            { error: \"Link is archived\" },\n            { status: 403 },\n          );\n        }\n\n        // Check if link has AI enabled\n        if (!link.enableAIAgents) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this link\" },\n            { status: 403 },\n          );\n        }\n\n        if (!link.document?.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled for this document\" },\n            { status: 403 },\n          );\n        }\n\n        if (!link?.team?.agentsEnabled) {\n          return NextResponse.json(\n            { error: \"AI agents are not enabled\" },\n            { status: 403 },\n          );\n        }\n\n        if (!link.document || !link.document?.teamId) {\n          return NextResponse.json(\n            { error: \"Document not found\" },\n            { status: 400 },\n          );\n        }\n\n        teamId = link.document?.teamId;\n      } else {\n        return NextResponse.json(\n          { error: \"Either documentId or dataroomId is required\" },\n          { status: 400 },\n        );\n      }\n\n      // Verify viewer exists\n      const viewer = await prisma.viewer.findUnique({\n        where: { id: viewerId, teamId },\n      });\n\n      if (!viewer) {\n        return NextResponse.json(\n          { error: \"Viewer not found\" },\n          { status: 404 },\n        );\n      }\n    } else {\n      return NextResponse.json(\n        { error: \"Authentication required\" },\n        { status: 401 },\n      );\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Get vector store ID\n    let vectorStoreId: string | undefined;\n\n    if (dataroomId) {\n      const dataroom = await prisma.dataroom.findUnique({\n        where: { id: dataroomId },\n        select: { vectorStoreId: true },\n      });\n      vectorStoreId = dataroom?.vectorStoreId || undefined;\n    } else if (documentId) {\n      const team = await prisma.team.findUnique({\n        where: { id: teamId },\n        select: { vectorStoreId: true },\n      });\n      vectorStoreId = team?.vectorStoreId || undefined;\n    }\n\n    // Create the chat\n    const chat = await createChat({\n      teamId,\n      documentId,\n      dataroomId,\n      linkId,\n      viewId,\n      userId,\n      viewerId,\n      vectorStoreId,\n      title,\n    });\n\n    return NextResponse.json(chat, { status: 201 });\n  } catch (error) {\n    console.error(\"Error creating chat:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n/**\n * GET /api/ai/chat\n * List chats with filters\n * Supports both internal users (via session) and external viewers (via viewerId param)\n */\nexport async function GET(req: NextRequest) {\n  try {\n    const session = await getServerSession(authOptions);\n    const searchParams = req.nextUrl.searchParams;\n\n    const chatListQuerySchema = z.object({\n      viewerId: z.string().cuid().optional(),\n      teamId: z.string().cuid().optional(),\n      documentId: z.string().cuid().optional(),\n      dataroomId: z.string().cuid().optional(),\n      linkId: z.string().cuid().optional(),\n      viewId: z.string().cuid().optional(),\n    });\n\n    const queryObj = {\n      viewerId: searchParams.get(\"viewerId\") ?? undefined,\n      teamId: searchParams.get(\"teamId\") ?? undefined,\n      documentId: searchParams.get(\"documentId\") ?? undefined,\n      dataroomId: searchParams.get(\"dataroomId\") ?? undefined,\n      linkId: searchParams.get(\"linkId\") ?? undefined,\n      viewId: searchParams.get(\"viewId\") ?? undefined,\n    };\n\n    const { viewerId, teamId, documentId, dataroomId, linkId, viewId } =\n      chatListQuerySchema.parse(queryObj);\n\n    // Combined flow: logged-in user with viewerId\n    // This handles the case where a team member is also viewing as a viewer\n    if (session && viewerId && (dataroomId || documentId)) {\n      const userId = (session.user as CustomUser).id;\n\n      // Verify viewer exists\n      const viewer = await prisma.viewer.findUnique({\n        where: { id: viewerId },\n        select: {\n          teamId: true,\n        },\n      });\n\n      if (!viewer) {\n        return NextResponse.json(\n          { error: \"Viewer not found\" },\n          { status: 404 },\n        );\n      }\n\n      // Verify user is member of team that the viewer is associated with\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId: viewer.teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n      }\n\n      // Check if AI feature is enabled for this team\n      const features = await getFeatureFlags({ teamId: viewer.teamId });\n      if (!features.ai) {\n        return NextResponse.json(\n          { error: \"AI features are not available for this team\" },\n          { status: 403 },\n        );\n      }\n\n      // Build where clause - find chats with this userId OR viewerId\n      const baseWhere: any = {};\n\n      if (documentId) {\n        baseWhere.documentId = documentId;\n      }\n\n      if (dataroomId) {\n        baseWhere.dataroomId = dataroomId;\n      }\n\n      if (linkId) {\n        baseWhere.linkId = linkId;\n      }\n\n      // Fetch chats that belong to either userId or viewerId\n      const chats = await prisma.chat.findMany({\n        where: {\n          ...baseWhere,\n          OR: [{ userId }, { viewerId }],\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        take: 50,\n      });\n\n      return NextResponse.json(chats);\n    }\n\n    // External viewer flow (not logged in)\n    if (viewerId && linkId && viewId) {\n      // Build where clause for viewer's chats\n      const where: any = {\n        viewerId,\n        linkId,\n      };\n\n      if (dataroomId) {\n        // verify dataroom session\n        const dataroomSession = await verifyDataroomSession(\n          req,\n          linkId,\n          dataroomId,\n        );\n\n        if (!dataroomSession || dataroomSession.viewerId !== viewerId) {\n          return NextResponse.json(\n            { error: \"Unauthorized - invalid session\" },\n            { status: 401 },\n          );\n        }\n\n        where.dataroomId = dataroomId;\n      }\n\n      if (documentId) {\n        // verify document session\n        const view = await prisma.view.findUnique({\n          where: { id: viewId, linkId, documentId },\n          select: {\n            viewerId: true,\n          },\n        });\n\n        if (!view || view.viewerId !== viewerId) {\n          return NextResponse.json(\n            { error: \"Unauthorized - invalid session\" },\n            { status: 401 },\n          );\n        }\n\n        where.documentId = documentId;\n      }\n\n      // Fetch viewer's chats\n      const chats = await prisma.chat.findMany({\n        where,\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        take: 50,\n        select: {\n          id: true,\n          title: true,\n          createdAt: true,\n          lastMessageAt: true,\n        },\n      });\n\n      return NextResponse.json(chats);\n    }\n\n    // Internal user flow (logged in, no viewerId)\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Verify user is member of team\n    const userTeam = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!userTeam) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Build where clause\n    const where: any = {\n      teamId,\n      userId, // Only show user's own chats\n    };\n\n    if (documentId) {\n      where.documentId = documentId;\n    }\n\n    if (dataroomId) {\n      where.dataroomId = dataroomId;\n    }\n\n    // Fetch chats\n    const chats = await prisma.chat.findMany({\n      where,\n      orderBy: {\n        createdAt: \"desc\",\n      },\n      take: 50,\n    });\n\n    return NextResponse.json(chats);\n  } catch (error) {\n    console.error(\"Error fetching chats:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/ai/store/runs/[runId]/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { runs } from \"@trigger.dev/sdk/v3\";\nimport { getServerSession } from \"next-auth\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * GET /api/ai/store/runs/[runId]\n * Get the status of a Trigger.dev run for polling\n */\nexport async function GET(\n  req: NextRequest,\n  { params }: { params: { runId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { runId } = params;\n\n    const run = await runs.retrieve(runId);\n\n    // Verify the user is authorized to access this run\n    const runTeamId = run.metadata?.teamId as string | undefined;\n    const userTeams = await prisma.userTeam.findMany({\n      where: { userId: (session.user as CustomUser).id },\n      select: { teamId: true },\n    });\n    const userTeamIds = userTeams.map((ut) => ut.teamId);\n\n    if (!runTeamId || !userTeamIds.includes(runTeamId)) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 403 });\n    }\n\n    return NextResponse.json({\n      id: run.id,\n      status: run.status,\n      metadata: run.metadata,\n      isCompleted: run.isCompleted,\n      isFailed: run.isFailed,\n      output: run.output,\n    });\n  } catch (error) {\n    console.error(\"Error retrieving run status:\", error);\n    return NextResponse.json(\n      { error: \"Failed to retrieve run status\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/ai/store/teams/[teamId]/datarooms/[dataroomId]/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport {\n  SUPPORTED_AI_CONTENT_TYPES,\n  addFileToVectorStoreTask,\n  processDocumentForAITask,\n} from \"@/ee/features/ai/lib/trigger\";\nimport { createDataroomVectorStore } from \"@/ee/features/ai/lib/vector-stores/create-dataroom-vector-store\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * POST /api/ai/store/teams/[teamId]/datarooms/[dataroomId]\n * Index all documents in a dataroom into its vector store\n * Returns batch runIds for status tracking via polling\n */\nexport async function POST(\n  req: NextRequest,\n  { params }: { params: { dataroomId: string; teamId: string } },\n) {\n  try {\n    const { dataroomId, teamId } = params;\n    const session = await getServerSession(authOptions);\n\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Verify user is member of team\n    const userTeam = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!userTeam) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Get dataroom and documents\n    const dataroom = await prisma.dataroom.findUnique({\n      where: {\n        id: dataroomId,\n        teamId,\n      },\n      include: {\n        team: {\n          select: {\n            agentsEnabled: true,\n          },\n        },\n        documents: {\n          include: {\n            document: {\n              select: {\n                id: true,\n                name: true,\n                versions: {\n                  where: { isPrimary: true },\n                  take: 1,\n                  select: {\n                    id: true,\n                    fileId: true,\n                    contentType: true,\n                    storageType: true,\n                    originalFile: true,\n                    file: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!dataroom) {\n      return NextResponse.json(\n        { error: \"Dataroom not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Check if team has AI enabled\n    if (!dataroom.team.agentsEnabled) {\n      return NextResponse.json(\n        { error: \"AI agents are not enabled for this team\" },\n        { status: 403 },\n      );\n    }\n\n    if (!dataroom.agentsEnabled) {\n      return NextResponse.json(\n        { error: \"AI agents are not enabled for this dataroom\" },\n        { status: 403 },\n      );\n    }\n\n    // Create or get vector store\n    let vectorStoreId = dataroom.vectorStoreId;\n\n    if (!vectorStoreId) {\n      vectorStoreId = await createDataroomVectorStore({\n        dataroomId,\n        teamId: dataroom.teamId,\n        name: dataroom.name,\n      });\n\n      // Update dataroom with vector store ID\n      await prisma.dataroom.update({\n        where: { id: dataroomId },\n        data: { vectorStoreId },\n      });\n    }\n\n    // Track triggered runs for status polling\n    const triggeredRuns: Array<{\n      documentId: string;\n      documentName: string;\n      runId: string;\n    }> = [];\n    const skippedDocuments: string[] = [];\n    const errors: string[] = [];\n\n    // Trigger processing for each document\n    for (const dataroomDoc of dataroom.documents) {\n      const document = dataroomDoc.document;\n      const primaryVersion = document.versions[0];\n\n      if (!primaryVersion) {\n        errors.push(`No primary version for document: ${document.name}`);\n        continue;\n      }\n\n      // Skip if already indexed in this dataroom\n      if (dataroomDoc.vectorStoreFileId) {\n        skippedDocuments.push(document.name);\n        continue;\n      }\n\n      // Check if document type is supported\n      const contentType = primaryVersion.contentType || \"\";\n      if (!SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {\n        errors.push(\n          `Unsupported file type for document: ${document.name} (${contentType})`,\n        );\n        continue;\n      }\n\n      try {\n        // Determine file path\n        const filePath =\n          primaryVersion.originalFile && contentType !== \"application/pdf\"\n            ? primaryVersion.originalFile\n            : primaryVersion.file;\n\n        const fileMetadata = {\n          teamId: dataroom.teamId,\n          documentId: document.id,\n          documentName: document.name,\n          versionId: primaryVersion.id,\n          dataroomId: dataroom.id,\n          dataroomDocumentId: dataroomDoc.id,\n          dataroomFolderId: dataroomDoc.folderId || \"root\",\n        };\n\n        let handle;\n\n        // If document already has fileId, just add to vector store\n        if (primaryVersion.fileId) {\n          handle = await addFileToVectorStoreTask.trigger({\n            fileId: primaryVersion.fileId,\n            vectorStoreId,\n            metadata: fileMetadata,\n          });\n        } else {\n          // Trigger full processing\n          handle = await processDocumentForAITask.trigger(\n            {\n              documentId: document.id,\n              documentVersionId: primaryVersion.id,\n              teamId: dataroom.teamId,\n              vectorStoreId,\n              documentName: document.name,\n              filePath,\n              storageType: primaryVersion.storageType,\n              contentType,\n              metadata: fileMetadata,\n            },\n            {\n              idempotencyKey: `ai-index-dataroom-${dataroom.id}-${primaryVersion.id}`,\n              tags: [\n                `team_${dataroom.teamId}`,\n                `dataroom_${dataroom.id}`,\n                `document_${document.id}`,\n                `version_${primaryVersion.id}`,\n              ],\n            },\n          );\n        }\n\n        triggeredRuns.push({\n          documentId: document.id,\n          documentName: document.name,\n          runId: handle.id,\n        });\n      } catch (error) {\n        console.error(\n          `Error triggering processing for ${document.name}:`,\n          error,\n        );\n        errors.push(\n          `Failed to trigger processing for: ${document.name} - ${error instanceof Error ? error.message : \"Unknown error\"}`,\n        );\n      }\n    }\n\n    return NextResponse.json({\n      success: true,\n      vectorStoreId,\n      totalDocuments: dataroom.documents.length,\n      triggeredCount: triggeredRuns.length,\n      skippedCount: skippedDocuments.length,\n      runs: triggeredRuns,\n      skipped: skippedDocuments.length > 0 ? skippedDocuments : undefined,\n      errors: errors.length > 0 ? errors : undefined,\n    });\n  } catch (error) {\n    console.error(\"Error indexing dataroom:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/ai/store/teams/[teamId]/documents/[documentId]/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport {\n  addFileToVectorStoreTask,\n  processDocumentForAITask,\n  SUPPORTED_AI_CONTENT_TYPES,\n} from \"@/ee/features/ai/lib/trigger\";\nimport { createTeamVectorStore } from \"@/ee/features/ai/lib/vector-stores/create-team-vector-store\";\nimport { removeFileFromVectorStore } from \"@/ee/features/ai/lib/vector-stores/remove-file-from-vector-store\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * POST /api/ai/store/teams/[teamId]/documents/[documentId]\n * Index a single document into the team vector store\n * Returns runId for status tracking via polling\n */\nexport async function POST(\n  req: NextRequest,\n  { params }: { params: { documentId: string; teamId: string } },\n) {\n  try {\n    const { documentId, teamId } = params;\n    const session = await getServerSession(authOptions);\n\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Verify user is member of team\n    const userTeam = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!userTeam) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Get document and version\n    const document = await prisma.document.findUnique({\n      where: { id: documentId, teamId },\n      include: {\n        team: {\n          select: {\n            agentsEnabled: true,\n            vectorStoreId: true,\n            name: true,\n          },\n        },\n        versions: {\n          where: { isPrimary: true },\n          take: 1,\n        },\n      },\n    });\n\n    if (!document) {\n      return NextResponse.json(\n        { error: \"Document not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Check if team has AI enabled\n    if (!document.team.agentsEnabled) {\n      return NextResponse.json(\n        { error: \"AI agents are not enabled for this team\" },\n        { status: 403 },\n      );\n    }\n\n    if (!document.agentsEnabled) {\n      return NextResponse.json(\n        { error: \"AI agents are not enabled for this document\" },\n        { status: 403 },\n      );\n    }\n\n    const primaryVersion = document.versions[0];\n\n    if (!primaryVersion) {\n      return NextResponse.json(\n        { error: \"No primary version found for this document\" },\n        { status: 400 },\n      );\n    }\n\n    // Create or get team vector store\n    let vectorStoreId = document.team.vectorStoreId;\n\n    if (!vectorStoreId) {\n      vectorStoreId = await createTeamVectorStore(\n        document.teamId,\n        document.team.name,\n      );\n\n      // Update team with vector store ID\n      await prisma.team.update({\n        where: { id: document.teamId },\n        data: { vectorStoreId },\n      });\n    }\n\n    // Check if document type is supported\n    const contentType = primaryVersion.contentType || \"\";\n    if (!SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {\n      return NextResponse.json(\n        {\n          error: `Unsupported file type: ${contentType}. Supported types: PDF, Excel, and images (JPEG, PNG, WebP).`,\n        },\n        { status: 400 },\n      );\n    }\n\n    // Determine file path - use originalFile for non-PDF, file for PDF\n    const filePath =\n      primaryVersion.originalFile && contentType !== \"application/pdf\"\n        ? primaryVersion.originalFile\n        : primaryVersion.file;\n\n    const fileMetadata = {\n      teamId: document.teamId,\n      documentId: document.id,\n      documentName: document.name,\n      versionId: primaryVersion.id,\n      folderId: document.folderId || \"root\",\n    };\n\n    // Check if document already has a fileId - use lightweight task\n    if (primaryVersion.fileId) {\n      // Document already processed, just add to vector store\n      const handle = await addFileToVectorStoreTask.trigger({\n        fileId: primaryVersion.fileId,\n        vectorStoreId,\n        metadata: fileMetadata,\n      });\n\n      return NextResponse.json({\n        success: true,\n        runId: handle.id,\n        isReprocessing: false,\n      });\n    }\n\n    // Trigger full document processing task\n    const handle = await processDocumentForAITask.trigger(\n      {\n        documentId: document.id,\n        documentVersionId: primaryVersion.id,\n        teamId: document.teamId,\n        vectorStoreId,\n        documentName: document.name,\n        filePath,\n        storageType: primaryVersion.storageType,\n        contentType,\n        metadata: fileMetadata,\n      },\n      {\n        idempotencyKey: `ai-index-${document.teamId}-${primaryVersion.id}`,\n        tags: [\n          `team_${document.teamId}`,\n          `document_${document.id}`,\n          `version_${primaryVersion.id}`,\n        ],\n      },\n    );\n\n    return NextResponse.json({\n      success: true,\n      runId: handle.id,\n      isReprocessing: true,\n    });\n  } catch (error) {\n    console.error(\"Error indexing document:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n/**\n * DELETE /api/ai/store/teams/[teamId]/documents/[documentId]\n * Remove a document from the team vector store\n */\nexport async function DELETE(\n  req: NextRequest,\n  { params }: { params: { documentId: string; teamId: string } },\n) {\n  try {\n    const { documentId, teamId } = params;\n    const session = await getServerSession(authOptions);\n\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Verify user is member of team\n    const userTeam = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!userTeam) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Get document and verify user access\n    const document = await prisma.document.findUnique({\n      where: { id: documentId, teamId },\n      include: {\n        team: {\n          select: {\n            vectorStoreId: true,\n          },\n        },\n        versions: {\n          where: { isPrimary: true },\n          take: 1,\n          select: {\n            id: true,\n            vectorStoreFileId: true,\n          },\n        },\n      },\n    });\n\n    if (!document) {\n      return NextResponse.json(\n        { error: \"Document not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId: document.teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    const primaryVersion = document.versions[0];\n    const vectorStoreId = document.team.vectorStoreId;\n\n    if (!primaryVersion?.vectorStoreFileId || !vectorStoreId) {\n      return NextResponse.json(\n        { error: \"Document is not indexed\" },\n        { status: 400 },\n      );\n    }\n\n    // Remove file from vector store\n    await removeFileFromVectorStore(\n      vectorStoreId,\n      primaryVersion.vectorStoreFileId,\n    );\n\n    // Clear vector store file ID\n    await prisma.documentVersion.update({\n      where: { id: primaryVersion.id },\n      data: { vectorStoreFileId: null },\n    });\n\n    return NextResponse.json({ success: true });\n  } catch (error) {\n    console.error(\"Error removing document from vector store:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/ai/store/teams/[teamId]/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { getVectorStoreInfo } from \"@/ee/features/ai/lib/vector-stores/get-vector-store-info\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * GET /api/ai/store/teams/[teamId]\n * Get team vector store information\n */\nexport async function GET(\n  req: NextRequest,\n  { params }: { params: { teamId: string } },\n) {\n  try {\n    const { teamId } = params;\n    const session = await getServerSession(authOptions);\n\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Verify user is member of team\n    const userTeam = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!userTeam) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // Check if AI feature is enabled for this team\n    const features = await getFeatureFlags({ teamId });\n    if (!features.ai) {\n      return NextResponse.json(\n        { error: \"AI features are not available for this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Get team with vector store ID\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      select: {\n        vectorStoreId: true,\n        agentsEnabled: true,\n      },\n    });\n\n    if (!team) {\n      return NextResponse.json({ error: \"Team not found\" }, { status: 404 });\n    }\n\n    if (!team.vectorStoreId) {\n      return NextResponse.json({\n        agentsEnabled: team.agentsEnabled,\n        vectorStoreId: null,\n        info: null,\n      });\n    }\n\n    // Get vector store info\n    const info = await getVectorStoreInfo(team.vectorStoreId);\n\n    return NextResponse.json({\n      agentsEnabled: team.agentsEnabled,\n      vectorStoreId: team.vectorStoreId,\n      info,\n    });\n  } catch (error) {\n    console.error(\"Error fetching team vector store info:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/auth/saml/authorize/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport type { OAuthReq } from \"@boxyhq/saml-jackson\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(req: Request) {\n  try {\n    const { oauthController } = await jackson();\n\n    const url = new URL(req.url);\n    const requestParams = Object.fromEntries(\n      url.searchParams.entries(),\n    ) as unknown as OAuthReq;\n\n    const { redirect_url, authorize_form } =\n      await oauthController.authorize(requestParams);\n\n    if (redirect_url) {\n      return NextResponse.redirect(redirect_url, { status: 302 });\n    } else if (authorize_form) {\n      return new Response(authorize_form, {\n        headers: { \"Content-Type\": \"text/html; charset=utf-8\" },\n      });\n    }\n\n    return NextResponse.json(\n      { error: \"No redirect URL returned\" },\n      { status: 400 },\n    );\n  } catch (error: any) {\n    console.error(\"[SAML] Authorize error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\nexport async function POST(req: Request) {\n  try {\n    const { oauthController } = await jackson();\n\n    const contentType = req.headers.get(\"content-type\") || \"\";\n\n    let body: Record<string, any>;\n\n    if (contentType.includes(\"application/x-www-form-urlencoded\")) {\n      const formData = await req.formData();\n      body = Object.fromEntries(formData.entries());\n    } else {\n      body = await req.json();\n    }\n\n    const { redirect_url, authorize_form } =\n      await oauthController.authorize(body as unknown as OAuthReq);\n\n    if (redirect_url) {\n      return NextResponse.redirect(redirect_url, { status: 302 });\n    } else if (authorize_form) {\n      return new Response(authorize_form, {\n        headers: { \"Content-Type\": \"text/html; charset=utf-8\" },\n      });\n    }\n\n    return NextResponse.json(\n      { error: \"No redirect URL returned\" },\n      { status: 400 },\n    );\n  } catch (error: any) {\n    console.error(\"[SAML] Authorize error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/auth/saml/callback/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const { oauthController } = await jackson();\n\n    const formData = await req.formData();\n    const RelayState = (formData.get(\"RelayState\") as string) || \"\";\n    const SAMLResponse = (formData.get(\"SAMLResponse\") as string) || \"\";\n\n    const { redirect_url } = await oauthController.samlResponse({\n      RelayState,\n      SAMLResponse,\n    });\n\n    if (!redirect_url) {\n      return NextResponse.json(\n        { error: \"No redirect URL returned\" },\n        { status: 400 },\n      );\n    }\n\n    return NextResponse.redirect(redirect_url, { status: 302 });\n  } catch (error: any) {\n    console.error(\"[SAML] Callback error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/auth/saml/token/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\n// These imports fix crypto module bundling issues with Jackson in Next.js.\n// Without them, the serverless function bundle tree-shakes away jose's crypto\n// primitives, causing ERR_CRYPTO_INVALID_KEYLEN at runtime.\n// See: https://github.com/ory/polis/blob/main/pages/api/import-hack.ts\nimport * as jose from \"jose\";\nimport { NextResponse } from \"next/server\";\nimport * as openidClient from \"openid-client\";\n\n// Reference the imports so they aren't removed by tree-shaking\nconst _dependencies = [jose, openidClient];\nvoid _dependencies;\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const { oauthController } = await jackson();\n\n    const formData = await req.formData();\n    const body = Object.fromEntries(formData.entries());\n\n    const token = await oauthController.token(body as any);\n\n    return NextResponse.json(token);\n  } catch (error: any) {\n    console.error(\"[SAML] Token error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/auth/saml/userinfo/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\n// Force-include crypto dependencies (same workaround as token route)\nimport * as jose from \"jose\";\nimport { NextResponse } from \"next/server\";\nimport * as openidClient from \"openid-client\";\n\nconst _dependencies = [jose, openidClient];\nvoid _dependencies;\n\n// Prevent Next.js from statically generating this route at build time —\n// it requires a live database connection via Jackson.\nexport const dynamic = \"force-dynamic\";\n\nexport async function GET(req: Request) {\n  try {\n    const { oauthController } = await jackson();\n\n    const authHeader = req.headers.get(\"Authorization\");\n    if (!authHeader) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    // RFC 6750: token type is case-insensitive\n    const token = authHeader.replace(/^bearer\\s+/i, \"\");\n    const userInfo = await oauthController.userInfo(token);\n\n    return NextResponse.json(userInfo);\n  } catch (error: any) {\n    console.error(\"[SAML] UserInfo error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/auth/saml/verify/route.ts",
    "content": "import { jackson, jacksonProduct } from \"@/lib/jackson\";\nimport prisma from \"@/lib/prisma\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n/**\n * POST /api/auth/saml/verify\n * Verifies that a team has SSO configured.\n * Accepts either `slug` (preferred, user-friendly) or `teamId` (fallback).\n * Returns only the teamId — no team names, provider info, or other metadata.\n */\nexport async function POST(req: Request) {\n  try {\n    const body = await req.json();\n    const { slug, teamId } = body;\n\n    if (!slug && !teamId) {\n      return NextResponse.json(\n        { error: \"Team slug or ID is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Look up team by slug first, then by ID\n    const team = slug\n      ? await prisma.team.findUnique({\n          where: { slug },\n          select: { id: true, ssoEnabled: true },\n        })\n      : await prisma.team.findUnique({\n          where: { id: teamId },\n          select: { id: true, ssoEnabled: true },\n        });\n\n    const ssoUnavailable = NextResponse.json(\n      { error: \"SSO is not available for this team.\" },\n      { status: 404 },\n    );\n\n    if (!team || !team.ssoEnabled) {\n      return ssoUnavailable;\n    }\n\n    // Check Jackson for actual SAML connections\n    const { apiController } = await jackson();\n\n    const connections = await apiController.getConnections({\n      tenant: team.id,\n      product: jacksonProduct,\n    });\n\n    if (!connections || connections.length === 0) {\n      return ssoUnavailable;\n    }\n\n    // Only return the team ID — no names, providers, or other metadata\n    return NextResponse.json({ data: { teamId: team.id } });\n  } catch (error: any) {\n    console.error(\"[SAML] Verify error:\", error);\n    return NextResponse.json(\n      { error: \"Something went wrong\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/faqs/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { z } from \"zod\";\n\nimport { verifyDataroomSession } from \"@/lib/auth/dataroom-auth\";\nimport prisma from \"@/lib/prisma\";\n\n// Validation schema for query parameters\nconst visitorFAQParamsSchema = z.object({\n  linkId: z.string().cuid(\"Invalid link ID format\"),\n  dataroomId: z.string().cuid(\"Invalid dataroom ID format\"),\n  documentId: z.string().cuid(\"Invalid document ID format\").nullish(), // This is actually dataroomDocumentId\n});\n\nexport interface VisitorFAQResponse {\n  id: string;\n  editedQuestion: string;\n  answer: string;\n  documentPageNumber?: number;\n  documentVersionNumber?: number;\n  createdAt: string;\n  document?: {\n    name: string;\n  };\n}\n\n// GET /api/faqs?linkId=xxx&dataroomId=xxx - List published FAQs for visitors\nexport async function GET(req: NextRequest) {\n  try {\n    const searchParams = req.nextUrl.searchParams;\n\n    // Validate query parameters\n    const paramValidation = visitorFAQParamsSchema.safeParse({\n      linkId: searchParams.get(\"linkId\"),\n      dataroomId: searchParams.get(\"dataroomId\"),\n      documentId: searchParams.get(\"documentId\"), // This is actually dataroomDocumentId\n    });\n\n    if (!paramValidation.success) {\n      return NextResponse.json(\n        {\n          error: \"Invalid parameters\",\n          details: paramValidation.error.errors[0]?.message,\n        },\n        { status: 400 },\n      );\n    }\n\n    const { linkId, dataroomId, documentId } = paramValidation.data;\n\n    // Verify dataroom session\n    const session = await verifyDataroomSession(req, linkId, dataroomId);\n    if (!session) {\n      return NextResponse.json(\n        { error: \"Unauthorized - invalid or expired session\" },\n        { status: 401 },\n      );\n    }\n\n    // Build where clause based on visibility filters\n    const whereClause: any = {\n      dataroomId,\n      status: \"PUBLISHED\",\n    };\n\n    // Apply visibility filters\n    const visibilityFilters: any[] = [\n      { visibilityMode: \"PUBLIC_DATAROOM\" }, // Always include dataroom-wide FAQs\n    ];\n\n    if (linkId) {\n      visibilityFilters.push({\n        visibilityMode: \"PUBLIC_LINK\",\n        linkId: linkId,\n      });\n    }\n\n    if (documentId) {\n      visibilityFilters.push({\n        visibilityMode: \"PUBLIC_DOCUMENT\",\n        dataroomDocumentId: documentId,\n      });\n    }\n\n    whereClause.OR = visibilityFilters;\n\n    // Fetch published FAQs\n    const faqs = await prisma.dataroomFaqItem.findMany({\n      where: whereClause,\n      select: {\n        id: true,\n        editedQuestion: true,\n        answer: true,\n        documentPageNumber: true,\n        documentVersionNumber: true,\n        createdAt: true,\n        dataroomDocument: {\n          select: {\n            document: {\n              select: {\n                name: true,\n              },\n            },\n          },\n        },\n      },\n      orderBy: { createdAt: \"desc\" },\n    });\n\n    // Format response\n    const response: VisitorFAQResponse[] = faqs.map((faq: any) => ({\n      id: faq.id,\n      editedQuestion: faq.editedQuestion,\n      answer: faq.answer,\n      documentPageNumber: faq.documentPageNumber || undefined,\n      documentVersionNumber: faq.documentVersionNumber || undefined,\n      createdAt: faq.createdAt.toISOString(),\n      document: faq.dataroomDocument?.document\n        ? {\n            name: faq.dataroomDocument.document.name,\n          }\n        : undefined,\n    }));\n\n    return NextResponse.json(response);\n  } catch (error) {\n    console.error(\"Error fetching visitor FAQs:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/links/[id]/upload/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { processDocument } from \"@/lib/api/documents/process-document\";\nimport { verifyDataroomSession } from \"@/lib/auth/dataroom-auth\";\nimport { DocumentData } from \"@/lib/documents/create-document\";\nimport prisma from \"@/lib/prisma\";\nimport { sendDataroomUploadNotificationTask } from \"@/lib/trigger/dataroom-upload-notification\";\nimport { sanitizePlainText } from \"@/lib/utils/sanitize-html\";\nimport { supportsAdvancedExcelMode } from \"@/lib/utils/get-content-type\";\nimport { runs } from \"@trigger.dev/sdk/v3\";\nimport { waitUntil } from \"@vercel/functions\";\n\n/**\n * GET /api/links/[id]/upload?dataroomId=xxx\n * Returns the viewer's previously uploaded documents for this dataroom.\n */\nexport async function GET(\n  request: NextRequest,\n  { params }: { params: { id: string } },\n) {\n  try {\n    const linkId = params.id;\n    const dataroomId = request.nextUrl.searchParams.get(\"dataroomId\");\n\n    if (!linkId || !dataroomId) {\n      return NextResponse.json(\n        { message: \"Missing required parameters\" },\n        { status: 400 },\n      );\n    }\n\n    // Verify the dataroom session\n    const dataroomSession = await verifyDataroomSession(\n      request,\n      linkId,\n      dataroomId,\n    );\n\n    if (!dataroomSession || !dataroomSession.viewerId) {\n      return NextResponse.json(\n        { message: \"Unauthorized\" },\n        { status: 401 },\n      );\n    }\n\n    const { viewerId } = dataroomSession;\n\n    // Fetch the viewer's uploads for this dataroom\n    const uploads = await prisma.documentUpload.findMany({\n      where: {\n        viewerId,\n        dataroomId,\n        linkId,\n      },\n      select: {\n        id: true,\n        documentId: true,\n        dataroomDocumentId: true,\n        originalFilename: true,\n        uploadedAt: true,\n        document: {\n          select: {\n            id: true,\n            name: true,\n            type: true,\n            versions: {\n              where: { isPrimary: true },\n              select: {\n                id: true,\n                hasPages: true,\n              },\n              take: 1,\n            },\n          },\n        },\n        dataroomDocument: {\n          select: {\n            folderId: true,\n          },\n        },\n      },\n      orderBy: { uploadedAt: \"desc\" },\n    });\n\n    const formattedUploads = uploads.map((upload) => {\n      const fileType = upload.document?.type ?? \"\";\n      const hasPages = upload.document?.versions?.[0]?.hasPages ?? false;\n      const needsProcessing = [\"pdf\", \"docs\", \"slides\"].includes(fileType);\n      const isComplete = !needsProcessing || hasPages;\n\n      return {\n        id: upload.id,\n        documentId: upload.documentId,\n        dataroomDocumentId: upload.dataroomDocumentId,\n        documentVersionId: upload.document?.versions?.[0]?.id ?? null,\n        name: upload.originalFilename ?? upload.document?.name ?? \"Unknown\",\n        fileType,\n        folderId: upload.dataroomDocument?.folderId ?? null,\n        uploadedAt: upload.uploadedAt,\n        status: isComplete ? \"complete\" : \"processing\",\n      };\n    });\n\n    return NextResponse.json({ uploads: formattedUploads });\n  } catch (error) {\n    console.error(\"Error fetching viewer uploads:\", error);\n    return NextResponse.json(\n      { message: \"Error fetching uploads\" },\n      { status: 500 },\n    );\n  }\n}\n\nexport async function POST(\n  request: NextRequest,\n  { params }: { params: { id: string } },\n) {\n  try {\n    const linkId = params.id;\n    const body = await request.json();\n    const { documentData, dataroomId, folderId } = body as {\n      documentData: DocumentData;\n      dataroomId: string;\n      folderId?: string;\n    };\n\n    if (!linkId || !documentData || !dataroomId) {\n      return NextResponse.json(\n        { message: \"Missing required parameters\" },\n        { status: 400 },\n      );\n    }\n\n    // 0. Verify the dataroom session\n    const dataroomSession = await verifyDataroomSession(\n      request,\n      linkId,\n      dataroomId,\n    );\n\n    if (!dataroomSession || !dataroomSession.viewerId) {\n      return NextResponse.json(\n        { message: \"You need to be logged in to upload a document.\" },\n        { status: 401 },\n      );\n    }\n\n    // Check if the link exists and has visitor upload enabled\n    const link = await prisma.link.findUnique({\n      where: { id: linkId, dataroomId },\n      select: {\n        id: true,\n        name: true,\n        enableUpload: true,\n        enableNotification: true,\n        uploadFolderId: true,\n        dataroomId: true,\n        teamId: true,\n        team: {\n          select: {\n            plan: true,\n            enableExcelAdvancedMode: true,\n          },\n        },\n      },\n    });\n\n    if (\n      !link ||\n      !link.enableUpload ||\n      link.dataroomId !== dataroomId ||\n      !link.teamId\n    ) {\n      return NextResponse.json(\n        { message: \"Uploads not allowed for this link\" },\n        { status: 403 },\n      );\n    }\n\n    const { viewerId, viewId } = dataroomSession;\n\n    // Check if the viewer exists\n    const viewer = await prisma.viewer.findUnique({\n      where: {\n        id: viewerId,\n        teamId: link.teamId,\n        views: { some: { id: viewId } },\n      },\n      select: { id: true },\n    });\n\n    if (!viewer) {\n      return NextResponse.json(\n        { message: \"Viewer not found\" },\n        { status: 404 },\n      );\n    }\n\n    if (typeof documentData.name !== \"string\") {\n      return NextResponse.json(\n        { message: \"Document name is required\" },\n        { status: 400 },\n      );\n    }\n\n    const sanitizedDocumentName = sanitizePlainText(documentData.name);\n    if (!sanitizedDocumentName) {\n      return NextResponse.json(\n        { message: \"Document name is required\" },\n        { status: 400 },\n      );\n    }\n\n    if (sanitizedDocumentName.length > 255) {\n      return NextResponse.json(\n        { message: \"Document name too long\" },\n        { status: 400 },\n      );\n    }\n\n    const updatedDocumentData = {\n      ...documentData,\n      name: sanitizedDocumentName,\n      enableExcelAdvancedMode: documentData.supportedFileType === \"sheet\" &&\n        link.team?.enableExcelAdvancedMode &&\n        supportsAdvancedExcelMode(documentData.contentType),\n    };\n\n    // 1. Create the document\n    const document = await processDocument({\n      documentData: updatedDocumentData,\n      teamId: link.teamId,\n      teamPlan: link.team?.plan ?? \"free\",\n      isExternalUpload: true,\n    });\n\n    // 2. Create the dataroom document\n    // If folderId is provided and link has no uploadFolderId, use folderId as the dataroomFolderId\n    // Otherwise, use the link's uploadFolderId\n    // or null if it doesn't exist\n    let dataroomFolderId: string | null = folderId ?? null;\n    if (link.uploadFolderId) {\n      const dataroomFolder = await prisma.dataroomFolder.findUnique({\n        where: {\n          id: link.uploadFolderId,\n          dataroomId,\n        },\n        select: {\n          id: true,\n        },\n      });\n      dataroomFolderId = dataroomFolder?.id ?? null;\n    }\n\n    const newDataroomDocument = await prisma.dataroomDocument.create({\n      data: {\n        dataroomId: dataroomId,\n        documentId: document.id,\n        folderId: dataroomFolderId,\n      },\n    });\n\n    // 3. Create the DocumentUpload record to track the upload details\n    await prisma.documentUpload.create({\n      data: {\n        documentId: document.id,\n        viewerId: viewerId,\n        viewId: viewId,\n        linkId: linkId,\n        originalFilename: document.name,\n        fileSize: documentData.fileSize ?? 0,\n        numPages: document.numPages,\n        mimeType: document.contentType,\n        dataroomId: dataroomId,\n        dataroomDocumentId: newDataroomDocument.id,\n        teamId: link.teamId,\n      },\n    });\n\n    // 4. Send upload notification to team if enabled\n    if (link.enableNotification) {\n      try {\n        // Cancel any existing pending notification runs for this viewer+dataroom+link\n        // Note: runs.list tag filter uses OR logic, so we must post-filter\n        // to ensure we only cancel runs matching ALL three tags\n        const requiredTags = [\n          `dataroom_${dataroomId}`,\n          `link_${linkId}`,\n          `viewer_${viewerId}`,\n        ];\n        const allRuns = await runs.list({\n          taskIdentifier: [\"send-dataroom-upload-notification\"],\n          tag: requiredTags,\n          status: [\"DELAYED\", \"QUEUED\"],\n          period: \"10m\",\n        });\n\n        const matchingRuns = allRuns.data.filter((run) =>\n          requiredTags.every((tag) => run.tags?.includes(tag)),\n        );\n\n        await Promise.all(matchingRuns.map((run) => runs.cancel(run.id)));\n\n        // Trigger a new notification with 5-minute delay to batch uploads\n        waitUntil(\n          sendDataroomUploadNotificationTask.trigger(\n            {\n              dataroomId,\n              linkId,\n              viewerId,\n              teamId: link.teamId,\n            },\n            {\n              idempotencyKey: `upload-notification-${link.teamId}-${dataroomId}-${linkId}-${viewerId}-${newDataroomDocument.id}`,\n              tags: [\n                `team_${link.teamId}`,\n                `dataroom_${dataroomId}`,\n                `link_${linkId}`,\n                `viewer_${viewerId}`,\n              ],\n              delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay\n            },\n          ),\n        );\n      } catch (error) {\n        console.error(\"Error triggering upload notification:\", error);\n      }\n    }\n\n    // Return document data for optimistic UI rendering\n    return NextResponse.json({\n      success: true,\n      document: {\n        id: document.id,\n        name: document.name,\n        dataroomDocumentId: newDataroomDocument.id,\n        documentVersionId: document.versions[0]?.id,\n        folderId: dataroomFolderId,\n        fileType: document.type,\n        hasPages: (document.numPages ?? 0) > 0,\n        createdAt: document.createdAt,\n      },\n    });\n  } catch (error) {\n    console.error(\"Error uploading document:\", error);\n    return NextResponse.json(\n      { message: \"Error uploading document\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/scim/v2.0/[...directory]/route.ts",
    "content": "import { jackson } from \"@/lib/jackson\";\nimport prisma from \"@/lib/prisma\";\nimport type { DirectorySyncEvent } from \"@boxyhq/saml-jackson\";\nimport { createHash } from \"crypto\";\nimport { headers } from \"next/headers\";\nimport { NextResponse } from \"next/server\";\n\nexport const dynamic = \"force-dynamic\";\n\n/** Return a truncated SHA-256 hex digest (first 12 chars) for log-safe pseudonymisation. */\nfunction hashEmail(email: string): string {\n  return createHash(\"sha256\").update(email).digest(\"hex\").slice(0, 12);\n}\n\nconst handler = async (\n  req: Request,\n  { params }: { params: Promise<{ directory: string[] }> },\n) => {\n  try {\n    const resolvedParams = await params;\n    const headersList = await headers();\n    const authHeader = headersList.get(\"Authorization\");\n    const apiSecret = authHeader ? authHeader.split(\" \")[1] : null;\n\n    const url = new URL(req.url);\n    const query = Object.fromEntries(url.searchParams.entries());\n\n    const [directoryId, path, resourceId] = resolvedParams.directory;\n\n    let body: any = {};\n    try {\n      body = await req.json();\n    } catch {\n      body = {};\n    }\n\n    const { directorySyncController } = await jackson();\n\n    const request = {\n      method: req.method as \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\",\n      body,\n      directoryId,\n      resourceId,\n      resourceType: (path === \"Users\" ? \"users\" : \"groups\") as\n        | \"users\"\n        | \"groups\",\n      apiSecret,\n      query: {\n        count: query.count ? parseInt(query.count) : undefined,\n        startIndex: query.startIndex ? parseInt(query.startIndex) : undefined,\n        filter: query.filter as string,\n      },\n    };\n\n    const { status, data } = await directorySyncController.requests.handle(\n      request,\n      handleSCIMEvents,\n    );\n\n    return NextResponse.json(data, { status });\n  } catch (error: any) {\n    console.error(\"[SCIM] Request error:\", error);\n    return NextResponse.json(\n      {\n        schemas: [\"urn:ietf:params:scim:api:messages:2.0:Error\"],\n        detail: \"Internal server error\",\n        status: 500,\n      },\n      { status: 500 },\n    );\n  }\n};\n\nexport {\n  handler as DELETE,\n  handler as GET,\n  handler as PATCH,\n  handler as POST,\n  handler as PUT,\n};\n\n// ──────────────────────────────────────────────────────────\n// SCIM Event Handler — sync changes to the main app DB\n// ──────────────────────────────────────────────────────────\nasync function handleSCIMEvents(event: DirectorySyncEvent) {\n  const { event: eventType, data, tenant } = event;\n\n  // Verify the team exists and has SSO enabled\n  const team = await prisma.team.findUnique({\n    where: { id: tenant },\n    select: { id: true, plan: true, ssoEnabled: true },\n  });\n\n  if (!team || !team.ssoEnabled) {\n    console.warn(\n      `[SCIM] Ignoring event for tenant ${tenant} — SSO not enabled`,\n    );\n    return;\n  }\n\n  // Plan gate: only datarooms-premium or higher\n  const allowedPlans = [\"datarooms-premium\", \"datarooms-premium+old\"];\n  if (!allowedPlans.includes(team.plan)) {\n    console.warn(\n      `[SCIM] Ignoring event for tenant ${tenant} — plan ${team.plan} not eligible`,\n    );\n    return;\n  }\n\n  if (!(\"email\" in data) || !data.email) {\n    return;\n  }\n\n  // Normalize once so look-ups/upserts always use a consistent lowercase key\n  const email = data.email.trim().toLowerCase();\n\n  try {\n    switch (eventType) {\n      case \"user.created\": {\n        console.log(\n          `[SCIM] User created: user_${hashEmail(email)} for tenant ${tenant}`,\n        );\n\n        const user = await prisma.user.upsert({\n          where: { email },\n          create: {\n            email,\n            name: [data.first_name, data.last_name].filter(Boolean).join(\" \"),\n          },\n          update: {},\n        });\n\n        await prisma.userTeam.upsert({\n          where: {\n            userId_teamId: {\n              userId: user.id,\n              teamId: tenant,\n            },\n          },\n          update: {},\n          create: {\n            userId: user.id,\n            teamId: tenant,\n            role: \"MEMBER\",\n          },\n        });\n        break;\n      }\n\n      case \"user.updated\": {\n        console.log(\n          `[SCIM] User updated: user_${hashEmail(email)} for tenant ${tenant}`,\n        );\n\n        // Handle Azure AD's active/inactive (can be boolean or string in any casing)\n        const rawActive = (data as any).active;\n        const normalizedActive =\n          rawActive === undefined\n            ? undefined\n            : typeof rawActive === \"string\"\n              ? rawActive.toLowerCase() === \"true\"\n              : Boolean(rawActive);\n\n        const isActive = normalizedActive === true;\n        const isInactive = normalizedActive === false;\n\n        if (isInactive) {\n          // Deactivated — remove from team (same as user.deleted)\n          const user = await prisma.user.findUnique({\n            where: { email },\n          });\n\n          if (user) {\n            await Promise.all([\n              prisma.link\n                .updateMany({\n                  where: {\n                    teamId: tenant,\n                    ownerId: user.id,\n                  },\n                  data: {\n                    ownerId: null,\n                  },\n                })\n                .catch(() => {\n                  console.warn(\n                    `[SCIM] Could not reset link ownership for user_${hashEmail(email)}`,\n                  );\n                }),\n              prisma.userTeam\n                .delete({\n                  where: {\n                    userId_teamId: {\n                      userId: user.id,\n                      teamId: tenant,\n                    },\n                  },\n                })\n                .catch(() => {\n                  console.warn(\n                    `[SCIM] Could not remove team membership for user_${hashEmail(email)}`,\n                  );\n                }),\n            ]);\n          }\n        } else if (isActive) {\n          // Reactivated — re-add to team\n          const user = await prisma.user.upsert({\n            where: { email },\n            create: {\n              email,\n              name: [data.first_name, data.last_name]\n                .filter(Boolean)\n                .join(\" \"),\n            },\n            update: {\n              name:\n                [data.first_name, data.last_name].filter(Boolean).join(\" \") ||\n                undefined,\n            },\n          });\n\n          await prisma.userTeam.upsert({\n            where: {\n              userId_teamId: {\n                userId: user.id,\n                teamId: tenant,\n              },\n            },\n            update: {},\n            create: {\n              userId: user.id,\n              teamId: tenant,\n              role: \"MEMBER\",\n            },\n          });\n        } else {\n          // Just a name/attribute update\n          await prisma.user\n            .update({\n              where: { email },\n              data: {\n                name:\n                  [data.first_name, data.last_name]\n                    .filter(Boolean)\n                    .join(\" \") || undefined,\n              },\n            })\n            .catch(() => {\n              console.warn(\n                `[SCIM] Could not update user user_${hashEmail(email)} — user not found`,\n              );\n            });\n        }\n        break;\n      }\n\n      case \"user.deleted\": {\n        console.log(\n          `[SCIM] User deleted: user_${hashEmail(email)} for tenant ${tenant}`,\n        );\n\n        const deletedUser = await prisma.user.findUnique({\n          where: { email },\n        });\n\n        if (deletedUser) {\n          await Promise.all([\n            prisma.link\n              .updateMany({\n                where: {\n                  teamId: tenant,\n                  ownerId: deletedUser.id,\n                },\n                data: {\n                  ownerId: null,\n                },\n              })\n              .catch(() => {\n                console.warn(\n                  `[SCIM] Could not reset link ownership for user_${hashEmail(email)}`,\n                );\n              }),\n            prisma.userTeam\n              .delete({\n                where: {\n                  userId_teamId: {\n                    userId: deletedUser.id,\n                    teamId: tenant,\n                  },\n                },\n              })\n              .catch(() => {\n                console.warn(\n                  `[SCIM] Could not remove team membership for user_${hashEmail(email)}`,\n                );\n              }),\n          ]);\n        }\n        break;\n      }\n\n      case \"group.created\":\n      case \"group.updated\":\n      case \"group.deleted\":\n      case \"group.user_added\":\n      case \"group.user_removed\": {\n        console.log(`[SCIM] Group event ${eventType} for tenant ${tenant}`);\n        break;\n      }\n    }\n  } catch (error) {\n    console.error(`[SCIM] Error handling event ${eventType}:`, error);\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/teams/[teamId]/directory-sync/route.ts",
    "content": "import { jackson, jacksonProduct } from \"@/lib/jackson\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { getServerSession } from \"next-auth/next\";\nimport { NextResponse } from \"next/server\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\n\nconst SSO_ELIGIBLE_PLANS = [\"datarooms-premium\", \"datarooms-premium+old\"];\n\nfunction isJacksonUnavailableError(error: unknown): boolean {\n  const message = error instanceof Error ? error.message : String(error);\n  return (\n    message.includes(\"error connecting to engine\") ||\n    message.includes(\"Missing Jackson DB URL\") ||\n    message.includes(\"ENOENT: no such file or directory, open 'system'\")\n  );\n}\n\nasync function getAuthenticatedAdmin(teamId: string) {\n  const session = await getServerSession(authOptions);\n  if (!session) return null;\n\n  const userId = (session.user as CustomUser).id;\n\n  const teamAccess = await prisma.userTeam.findUnique({\n    where: { userId_teamId: { userId, teamId } },\n    select: { role: true },\n  });\n\n  if (!teamAccess || teamAccess.role !== \"ADMIN\") return null;\n\n  const team = await prisma.team.findUnique({\n    where: { id: teamId },\n    select: { id: true, plan: true, ssoEnabled: true },\n  });\n\n  if (!team) return null;\n\n  return { userId, team };\n}\n\n// GET /api/teams/:teamId/directory-sync — list SCIM directories\nexport async function GET(\n  req: Request,\n  { params }: { params: Promise<{ teamId: string }> },\n) {\n  const { teamId } = await params;\n  const auth = await getAuthenticatedAdmin(teamId);\n  if (!auth) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  try {\n    if (!auth.team.ssoEnabled || !SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {\n      return NextResponse.json({ directories: [] });\n    }\n\n    const { directorySyncController } = await jackson();\n\n    const { data, error } =\n      await directorySyncController.directories.getByTenantAndProduct(\n        teamId,\n        jacksonProduct,\n      );\n\n    if (error) {\n      return NextResponse.json({ error: error.message }, { status: 400 });\n    }\n\n    return NextResponse.json({ directories: data });\n  } catch (error: any) {\n    if (isJacksonUnavailableError(error)) {\n      console.warn(\"[SCIM] Jackson unavailable, returning empty directories\", error);\n      return NextResponse.json({ directories: [] });\n    }\n\n    console.error(\"[SCIM] Get directories error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// POST /api/teams/:teamId/directory-sync — create a SCIM directory connection\nexport async function POST(\n  req: Request,\n  { params }: { params: Promise<{ teamId: string }> },\n) {\n  const { teamId } = await params;\n  const auth = await getAuthenticatedAdmin(teamId);\n  if (!auth) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  // Plan gate\n  if (!SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {\n    return NextResponse.json(\n      { error: \"SCIM Directory Sync requires a Datarooms Premium plan\" },\n      { status: 403 },\n    );\n  }\n\n  // Feature flag gate\n  if (!auth.team.ssoEnabled) {\n    return NextResponse.json(\n      { error: \"SSO is not enabled for this team\" },\n      { status: 403 },\n    );\n  }\n\n  try {\n    const { directorySyncController } = await jackson();\n    const body = await req.json();\n    const { name, type, currentDirectoryId } = body;\n\n    // Create the new directory first; only delete the old one on success\n    const result = await directorySyncController.directories.create({\n      tenant: teamId,\n      product: jacksonProduct,\n      name: name || \"Papermark SCIM Directory\",\n      type: type || \"azure-scim-v2\",\n    });\n\n    if (result.error) {\n      return NextResponse.json(\n        { error: result.error.message },\n        { status: 400 },\n      );\n    }\n\n    // If replacing an existing directory, delete the old one after successful create\n    if (currentDirectoryId) {\n      await directorySyncController.directories.delete(currentDirectoryId);\n    }\n\n    return NextResponse.json(result, { status: 201 });\n  } catch (error: any) {\n    console.error(\"[SCIM] Create directory error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// DELETE /api/teams/:teamId/directory-sync — delete a SCIM directory\nexport async function DELETE(\n  req: Request,\n  { params }: { params: Promise<{ teamId: string }> },\n) {\n  const { teamId } = await params;\n  const auth = await getAuthenticatedAdmin(teamId);\n  if (!auth) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  try {\n    const { directorySyncController } = await jackson();\n    const body = await req.json();\n    const { directoryId } = body;\n\n    if (!directoryId) {\n      return NextResponse.json(\n        { error: \"directoryId is required\" },\n        { status: 400 },\n      );\n    }\n\n    const { error } =\n      await directorySyncController.directories.delete(directoryId);\n\n    if (error) {\n      return NextResponse.json({ error: error.message }, { status: 400 });\n    }\n\n    return NextResponse.json({ ok: true });\n  } catch (error: any) {\n    console.error(\"[SCIM] Delete directory error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/teams/[teamId]/saml/route.ts",
    "content": "import { jackson, jacksonProduct, samlAudience } from \"@/lib/jackson\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { isGenericDomain } from \"@/lib/utils/email-domain\";\nimport { getServerSession } from \"next-auth/next\";\nimport { NextResponse } from \"next/server\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\n\nconst SSO_ELIGIBLE_PLANS = [\"datarooms-premium\", \"datarooms-premium+old\"];\n\nfunction isJacksonUnavailableError(error: unknown): boolean {\n  const message = error instanceof Error ? error.message : String(error);\n  return (\n    message.includes(\"error connecting to engine\") ||\n    message.includes(\"Missing Jackson DB URL\") ||\n    message.includes(\"ENOENT: no such file or directory, open 'system'\")\n  );\n}\n\nasync function getAuthenticatedAdmin(teamId: string) {\n  const session = await getServerSession(authOptions);\n  if (!session) return null;\n\n  const userId = (session.user as CustomUser).id;\n\n  const teamAccess = await prisma.userTeam.findUnique({\n    where: { userId_teamId: { userId, teamId } },\n    select: { role: true },\n  });\n\n  if (!teamAccess || teamAccess.role !== \"ADMIN\") return null;\n\n  const team = await prisma.team.findUnique({\n    where: { id: teamId },\n    select: { id: true, plan: true, ssoEnabled: true, ssoEmailDomain: true, ssoEnforcedAt: true, slug: true },\n  });\n\n  if (!team) return null;\n\n  return { userId, team, email: (session.user as CustomUser).email! };\n}\n\n// GET /api/teams/:teamId/saml — list SAML connections + issuer/acs info\nexport async function GET(\n  req: Request,\n  { params }: { params: Promise<{ teamId: string }> },\n) {\n  const { teamId } = await params;\n  const auth = await getAuthenticatedAdmin(teamId);\n  if (!auth) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  try {\n    if (!auth.team.ssoEnabled || !SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {\n      return NextResponse.json({\n        connections: [],\n        issuer: samlAudience,\n        acs: `${process.env.NEXTAUTH_URL}/api/auth/saml/callback`,\n        ssoEmailDomain: auth.team.ssoEmailDomain,\n        ssoEnforcedAt: auth.team.ssoEnforcedAt,\n        slug: auth.team.slug,\n      });\n    }\n\n    const { apiController } = await jackson();\n\n    const connections = await apiController.getConnections({\n      tenant: teamId,\n      product: jacksonProduct,\n    });\n\n    return NextResponse.json({\n      connections,\n      issuer: samlAudience,\n      acs: `${process.env.NEXTAUTH_URL}/api/auth/saml/callback`,\n      ssoEmailDomain: auth.team.ssoEmailDomain,\n      ssoEnforcedAt: auth.team.ssoEnforcedAt,\n      slug: auth.team.slug,\n    });\n  } catch (error: any) {\n    if (isJacksonUnavailableError(error)) {\n      console.warn(\"[SAML] Jackson unavailable, returning empty connections\", error);\n      return NextResponse.json({\n        connections: [],\n        issuer: samlAudience,\n        acs: `${process.env.NEXTAUTH_URL}/api/auth/saml/callback`,\n        ssoEmailDomain: auth.team.ssoEmailDomain,\n        ssoEnforcedAt: auth.team.ssoEnforcedAt,\n        slug: auth.team.slug,\n      });\n    }\n\n    console.error(\"[SAML] Get connections error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// POST /api/teams/:teamId/saml — create a new SAML connection\nexport async function POST(\n  req: Request,\n  { params }: { params: Promise<{ teamId: string }> },\n) {\n  const { teamId } = await params;\n  const auth = await getAuthenticatedAdmin(teamId);\n  if (!auth) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  // Plan gate\n  if (!SSO_ELIGIBLE_PLANS.includes(auth.team.plan)) {\n    return NextResponse.json(\n      { error: \"SSO requires a Datarooms Premium plan\" },\n      { status: 403 },\n    );\n  }\n\n  // Feature flag gate\n  if (!auth.team.ssoEnabled) {\n    return NextResponse.json(\n      { error: \"SSO is not enabled for this team\" },\n      { status: 403 },\n    );\n  }\n\n  try {\n    const { apiController } = await jackson();\n    const body = await req.json();\n    const { rawMetadata, encodedRawMetadata, metadataUrl, domain } = body;\n\n    if (!rawMetadata && !metadataUrl && !encodedRawMetadata) {\n      return NextResponse.json(\n        {\n          error:\n            \"Either rawMetadata, encodedRawMetadata, or metadataUrl is required\",\n        },\n        { status: 400 },\n      );\n    }\n\n    // Normalize the explicit domain provided by the admin (if any)\n    const explicitDomain = typeof domain === \"string\"\n      ? domain.trim().toLowerCase().replace(/^@/, \"\")\n      : undefined;\n\n    // Validate explicit domain format if provided\n    if (explicitDomain && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(explicitDomain)) {\n      return NextResponse.json(\n        { error: \"Invalid domain format. Please provide a valid domain (e.g., example.com).\" },\n        { status: 400 },\n      );\n    }\n\n    // Reject public / free email provider domains – SSO should only be\n    // configured for organisation-owned domains.\n    if (explicitDomain && isGenericDomain(explicitDomain)) {\n      return NextResponse.json(\n        {\n          error:\n            \"Public email domains (e.g., gmail.com, outlook.com) cannot be used for SSO. Please provide your organization's domain.\",\n        },\n        { status: 400 },\n      );\n    }\n\n    const connection = await apiController.createSAMLConnection({\n      defaultRedirectUrl: `${process.env.NEXTAUTH_URL}/auth/saml`,\n      redirectUrl: process.env.NEXTAUTH_URL as string,\n      tenant: teamId,\n      product: jacksonProduct,\n      rawMetadata: rawMetadata || undefined,\n      encodedRawMetadata: encodedRawMetadata || undefined,\n      metadataUrl: metadataUrl || undefined,\n    });\n\n    // Attempt to extract a domain hint from the IdP metadata returned by the\n    // SAML connection.  Standard IdP entity IDs / SSO URLs sometimes contain\n    // the organisation's own domain (e.g. for self-hosted IdPs).  We use this\n    // as an additional validation signal – if the admin supplied a domain we\n    // check it is consistent; if not, we fall back to the metadata hint only\n    // when it looks like a real organisation domain (not a generic IdP host).\n    let metadataDomain: string | undefined;\n    try {\n      const idpMeta = (connection as any)?.idpMetadata;\n      const candidateUrls: string[] = [\n        idpMeta?.entityID,\n        idpMeta?.sso?.postUrl,\n        idpMeta?.sso?.redirectUrl,\n      ].filter(Boolean);\n\n      const genericIdpHosts = new Set([\n        \"accounts.google.com\",\n        \"login.microsoftonline.com\",\n        \"sts.windows.net\",\n        \"idp.ssocircle.com\",\n        \"www.okta.com\",\n        \"dev.okta.com\",\n        \"auth0.com\",\n        \"onelogin.com\",\n        \"pingone.com\",\n      ]);\n\n      for (const raw of candidateUrls) {\n        try {\n          const host = new URL(raw).hostname.toLowerCase();\n          // Skip well-known generic IdP hosts and public email domains\n          if (\n            [...genericIdpHosts].some((g) => host === g || host.endsWith(`.${g}`)) ||\n            isGenericDomain(host)\n          ) {\n            continue;\n          }\n          // Must have at least two labels (e.g. \"company.com\")\n          if (host.split(\".\").length >= 2) {\n            metadataDomain = host;\n            break;\n          }\n        } catch {\n          // not a valid URL – skip\n        }\n      }\n    } catch {\n      // metadata extraction is best-effort\n    }\n\n    // Determine the validated domain to persist:\n    //  1. Prefer the explicitly admin-provided domain.\n    //  2. Fall back to a domain extracted from metadata (if non-generic).\n    //  3. If neither is available, do NOT store a domain.\n    const validatedDomain = explicitDomain || metadataDomain || undefined;\n\n    // Only persist ssoEmailDomain when we have a validated value\n    if (validatedDomain) {\n      await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          ssoEmailDomain: validatedDomain,\n        },\n      });\n    }\n\n    return NextResponse.json(connection, { status: 201 });\n  } catch (error: any) {\n    console.error(\"[SAML] Create connection error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// PATCH /api/teams/:teamId/saml — update SSO enforcement settings\nexport async function PATCH(\n  req: Request,\n  { params }: { params: Promise<{ teamId: string }> },\n) {\n  const { teamId } = await params;\n  const auth = await getAuthenticatedAdmin(teamId);\n  if (!auth) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  try {\n    const body = await req.json();\n    const { enforced } = body;\n\n    if (typeof enforced !== \"boolean\") {\n      return NextResponse.json(\n        { error: \"'enforced' must be a boolean\" },\n        { status: 400 },\n      );\n    }\n\n    // Can only enforce if there's an ssoEmailDomain set (which is set when SAML is configured)\n    if (enforced && !auth.team.ssoEmailDomain) {\n      return NextResponse.json(\n        {\n          error:\n            \"Cannot enforce SSO without a configured email domain. Please configure SAML first.\",\n        },\n        { status: 400 },\n      );\n    }\n\n    // Verify there are active SAML connections before enforcing\n    if (enforced) {\n      const { apiController } = await jackson();\n      const connections = await apiController.getConnections({\n        tenant: teamId,\n        product: jacksonProduct,\n      });\n\n      if (!connections || connections.length === 0) {\n        return NextResponse.json(\n          {\n            error:\n              \"Cannot enforce SSO without an active SAML connection. Please configure SAML first.\",\n          },\n          { status: 400 },\n        );\n      }\n    }\n\n    const now = enforced ? new Date() : null;\n\n    await prisma.team.update({\n      where: { id: teamId },\n      data: {\n        ssoEnforcedAt: now,\n      },\n    });\n\n    return NextResponse.json({\n      enforced,\n      ssoEnforcedAt: now?.toISOString() ?? null,\n    });\n  } catch (error: any) {\n    console.error(\"[SAML] Update enforcement error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// DELETE /api/teams/:teamId/saml — remove a SAML connection\nexport async function DELETE(\n  req: Request,\n  { params }: { params: Promise<{ teamId: string }> },\n) {\n  const { teamId } = await params;\n  const auth = await getAuthenticatedAdmin(teamId);\n  if (!auth) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  try {\n    const { apiController } = await jackson();\n    const body = await req.json();\n    const { clientID, clientSecret } = body;\n\n    if (!clientID || !clientSecret) {\n      return NextResponse.json(\n        { error: \"clientID and clientSecret are required\" },\n        { status: 400 },\n      );\n    }\n\n    // Ownership check: verify the connection belongs to this team before deleting\n    const existingConnections = await apiController.getConnections({\n      clientID,\n    });\n\n    const connection = Array.isArray(existingConnections)\n      ? existingConnections[0]\n      : existingConnections;\n\n    if (!connection) {\n      return NextResponse.json(\n        { error: \"SAML connection not found\" },\n        { status: 404 },\n      );\n    }\n\n    if (connection.tenant !== teamId) {\n      return NextResponse.json(\n        { error: \"You do not have permission to delete this connection\" },\n        { status: 403 },\n      );\n    }\n\n    await apiController.deleteConnections({\n      clientID,\n      clientSecret,\n      tenant: teamId,\n      product: jacksonProduct,\n    });\n\n    // Check if there are remaining connections\n    const remaining = await apiController.getConnections({\n      tenant: teamId,\n      product: jacksonProduct,\n    });\n\n    if (!remaining || (Array.isArray(remaining) && remaining.length === 0)) {\n      // No more connections — clear SSO domain and enforcement\n      await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          ssoEmailDomain: null,\n          ssoEnforcedAt: null,\n        },\n      });\n    }\n\n    return NextResponse.json({ ok: true });\n  } catch (error: any) {\n    console.error(\"[SAML] Delete connection error:\", error);\n    return NextResponse.json(\n      { error: error.message || \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/workflow-entry/domains/[...domainSlug]/route.ts",
    "content": "import { cookies } from \"next/headers\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nimport { WorkflowEngine } from \"@/ee/features/workflows/lib/engine\";\nimport {\n  AccessRequestSchema,\n  VerifyEmailRequestSchema,\n} from \"@/ee/features/workflows/lib/types\";\nimport { ipAddress, waitUntil } from \"@vercel/functions\";\nimport { z } from \"zod\";\n\nimport {\n  collectFingerprintHeaders,\n  createDataroomSession,\n  generateSessionFingerprint,\n} from \"@/lib/auth/dataroom-auth\";\nimport { createLinkSession } from \"@/lib/auth/link-session\";\nimport { sendOtpVerificationEmail } from \"@/lib/emails/send-email-otp-verification\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { generateOTP } from \"@/lib/utils/generate-otp\";\nimport { LOCALHOST_IP } from \"@/lib/utils/geo\";\n\n// POST /app/(ee)/api/workflow-entry/domains/[domain]/[slug]/[action]\n// where action is \"verify\" or \"access\"\nexport async function POST(\n  req: NextRequest,\n  { params }: { params: { domainSlug: string[] } },\n) {\n  try {\n    const domainSlug = params.domainSlug;\n\n    if (!domainSlug || domainSlug.length < 3) {\n      return NextResponse.json(\n        { error: \"Invalid URL format. Expected: domain/slug/action\" },\n        { status: 400 },\n      );\n    }\n\n    const domain = domainSlug[0];\n    const slug = domainSlug[1];\n    const action = domainSlug[2]; // \"verify\" or \"access\"\n\n    if (action !== \"verify\" && action !== \"access\") {\n      return NextResponse.json(\n        { error: \"Invalid action. Must be 'verify' or 'access'\" },\n        { status: 400 },\n      );\n    }\n\n    // Find workflow by entry link's domain and slug\n    const link = await prisma.link.findUnique({\n      where: {\n        domainSlug_slug: {\n          domainSlug: domain,\n          slug: slug,\n        },\n        linkType: \"WORKFLOW_LINK\",\n      },\n      select: {\n        id: true,\n        isArchived: true,\n        deletedAt: true,\n        workflow: {\n          select: {\n            id: true,\n            isActive: true,\n            name: true,\n            teamId: true,\n            entryLinkId: true,\n          },\n        },\n      },\n    });\n\n    if (!link || !link.workflow) {\n      return NextResponse.json(\n        { error: \"Workflow entry link not found\" },\n        { status: 404 },\n      );\n    }\n\n    if (!link.workflow.isActive) {\n      return NextResponse.json(\n        { error: \"This workflow is currently inactive\" },\n        { status: 403 },\n      );\n    }\n\n    if (link.isArchived || link.deletedAt) {\n      return NextResponse.json(\n        { error: \"This link is no longer available\" },\n        { status: 404 },\n      );\n    }\n\n    // Handle verify action\n    if (action === \"verify\") {\n      return handleVerify(req, link);\n    }\n\n    // Handle access action\n    if (action === \"access\") {\n      return handleAccess(req, link);\n    }\n\n    return NextResponse.json({ error: \"Invalid action\" }, { status: 400 });\n  } catch (error) {\n    console.error(\"Error in workflow entry endpoint:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// Handle verify (send OTP)\nasync function handleVerify(req: NextRequest, link: any) {\n  const body = await req.json();\n\n  // Validate request body\n  const validation = VerifyEmailRequestSchema.safeParse(body);\n  if (!validation.success) {\n    return NextResponse.json(\n      { error: \"Invalid email address\" },\n      { status: 400 },\n    );\n  }\n\n  const { email } = validation.data;\n\n  // Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding\n  const { success: emailLimitSuccess } = await ratelimit(1, \"30 s\").limit(\n    `workflow-otp:${link.id}:${email}`,\n  );\n  if (!emailLimitSuccess) {\n    return NextResponse.json(\n      {\n        error:\n          \"Please wait before requesting another code. Try again in 30 seconds.\",\n      },\n      { status: 429 },\n    );\n  }\n\n  // Additional IP-based rate limit (10 per minute) to prevent abuse across different emails\n  const ipAddressValue = ipAddress(req);\n  const { success } = await ratelimit(10, \"1 m\").limit(\n    `workflow-otp:${ipAddressValue}`,\n  );\n  if (!success) {\n    return NextResponse.json(\n      { error: \"Too many requests. Please try again later.\" },\n      { status: 429 },\n    );\n  }\n\n  // Delete any existing OTP codes for this workflow entry + email\n  await prisma.verificationToken.deleteMany({\n    where: {\n      identifier: `workflow-otp:${link.id}:${email}`,\n    },\n  });\n\n  // Generate and store OTP\n  const otpCode = generateOTP();\n  const expiresAt = new Date();\n  expiresAt.setMinutes(expiresAt.getMinutes() + 10); // expires in 10 minutes\n\n  await prisma.verificationToken.create({\n    data: {\n      token: otpCode,\n      identifier: `workflow-otp:${link.id}:${email}`,\n      expires: expiresAt,\n    },\n  });\n\n  // Send OTP email\n  waitUntil(\n    sendOtpVerificationEmail(email, otpCode, false, link.workflow.teamId),\n  );\n\n  return NextResponse.json({\n    success: true,\n    message: \"Verification code sent to your email\",\n  });\n}\n\n// Handle access (verify OTP and execute workflow)\nasync function handleAccess(req: NextRequest, link: any) {\n  const body = await req.json();\n\n  // Validate request body\n  const validation = AccessRequestSchema.safeParse(body);\n  if (!validation.success) {\n    return NextResponse.json(\n      { error: \"Invalid request data\" },\n      { status: 400 },\n    );\n  }\n\n  const { email, code } = validation.data;\n\n  // Rate limiting\n  const ipAddressValue = ipAddress(req) ?? LOCALHOST_IP;\n  const { success } = await ratelimit(10, \"1 m\").limit(\n    `workflow-verify:${ipAddressValue}`,\n  );\n  if (!success) {\n    return NextResponse.json(\n      { error: \"Too many requests. Please try again later.\" },\n      { status: 429 },\n    );\n  }\n\n  // Verify OTP code\n  const verification = await prisma.verificationToken.findUnique({\n    where: {\n      token: code,\n      identifier: `workflow-otp:${link.id}:${email}`,\n    },\n  });\n\n  if (!verification) {\n    return NextResponse.json(\n      { error: \"Invalid verification code\", resetVerification: true },\n      { status: 401 },\n    );\n  }\n\n  // Check OTP expiration\n  if (Date.now() > verification.expires.getTime()) {\n    await prisma.verificationToken.delete({\n      where: { token: code },\n    });\n    return NextResponse.json(\n      { error: \"Verification code expired\", resetVerification: true },\n      { status: 401 },\n    );\n  }\n\n  // Delete OTP after successful verification\n  await prisma.verificationToken.delete({\n    where: { token: code },\n  });\n\n  // Execute workflow engine\n  const engine = new WorkflowEngine();\n  const userAgent = req.headers.get(\"user-agent\") ?? \"unknown\";\n  const referrer = req.headers.get(\"referer\") ?? undefined;\n\n  const executionResult = await engine.execute(link.workflow.entryLinkId, {\n    visitorEmail: email,\n    visitorIp: ipAddressValue,\n    userAgent,\n    referrer,\n  });\n\n  if (!executionResult.success) {\n    return NextResponse.json(\n      { error: executionResult.error || \"Workflow execution failed\" },\n      { status: 400 },\n    );\n  }\n\n  // Find or create viewer\n  let viewer = await prisma.viewer.findUnique({\n    where: {\n      teamId_email: {\n        teamId: link.workflow.teamId,\n        email: email,\n      },\n    },\n    select: { id: true },\n  });\n\n  if (!viewer) {\n    viewer = await prisma.viewer.create({\n      data: {\n        email: email,\n        verified: true,\n        teamId: link.workflow.teamId,\n      },\n      select: { id: true },\n    });\n  }\n\n  // Create a view record for the workflow execution\n  const view = await prisma.view.create({\n    data: {\n      linkId: link.id,\n      viewerEmail: email,\n      viewerId: viewer.id,\n      verified: true,\n      teamId: link.workflow.teamId,\n      documentId: executionResult.targetDocumentId ?? undefined,\n      dataroomId: executionResult.targetDataroomId ?? undefined,\n      viewType:\n        executionResult.targetLinkType === \"DATAROOM_LINK\"\n          ? \"DATAROOM_VIEW\"\n          : \"DOCUMENT_VIEW\",\n    },\n    select: { id: true },\n  });\n\n  // Create link session for the target link\n  const linkFingerprint = generateSessionFingerprint(\n    collectFingerprintHeaders(req.headers),\n  );\n  const { token: sessionToken, expiresAt } = await createLinkSession(\n    executionResult.targetLinkId!,\n    executionResult.targetLinkType!,\n    view.id,\n    email,\n    ipAddressValue,\n    userAgent,\n    true, // verified\n    viewer.id, // viewerId\n    executionResult.targetDocumentId,\n    executionResult.targetDataroomId,\n    linkFingerprint,\n  );\n\n  // Parse target URL safely with fallback\n  let targetPath = \"/\";\n  let cookieFlagId = executionResult.targetLinkId;\n\n  if (executionResult.targetUrl) {\n    try {\n      const parsedUrl = new URL(executionResult.targetUrl);\n      targetPath = parsedUrl.pathname;\n      const pathSegment = parsedUrl.pathname.split(\"/\").pop();\n      if (pathSegment) {\n        cookieFlagId = pathSegment;\n      }\n    } catch (error) {\n      console.error(\"Failed to parse target URL, using fallback values\");\n    }\n  }\n\n  // Set link session cookie (httpOnly)\n  cookies().set(`pm_ls_${executionResult.targetLinkId}`, sessionToken, {\n    httpOnly: true,\n    secure: process.env.NODE_ENV === \"production\",\n    sameSite: \"lax\",\n    expires: new Date(expiresAt),\n    path: \"/\",\n  });\n\n  // Set client-readable flag cookie for auto-login detection\n  const flagCookieId = `pm_link_flag_${cookieFlagId}`;\n  cookies().set(flagCookieId, \"true\", {\n    httpOnly: false, // Client-readable\n    secure: process.env.NODE_ENV === \"production\",\n    sameSite: \"lax\",\n    expires: new Date(expiresAt),\n    path: targetPath,\n  });\n\n  // If routing to a dataroom, also create and set dataroom session cookie\n  if (\n    executionResult.targetLinkType === \"DATAROOM_LINK\" &&\n    executionResult.targetDataroomId &&\n    viewer\n  ) {\n    const fingerprint = generateSessionFingerprint(\n      collectFingerprintHeaders(req.headers),\n    );\n    const dataroomSession = await createDataroomSession(\n      executionResult.targetDataroomId,\n      executionResult.targetLinkId!,\n      view.id,\n      ipAddressValue,\n      true, // verified\n      viewer.id,\n      fingerprint,\n    );\n\n    cookies().set(\n      `pm_drs_${executionResult.targetLinkId}`,\n      dataroomSession.token,\n      {\n        httpOnly: true,\n        secure: process.env.NODE_ENV === \"production\",\n        sameSite: \"lax\",\n        expires: new Date(dataroomSession.expiresAt),\n        path: \"/\",\n      },\n    );\n\n    // Set client-readable flag cookie for dataroom\n    const dataroomFlagId = `pm_drs_flag_${cookieFlagId}`;\n    cookies().set(dataroomFlagId, \"true\", {\n      httpOnly: false, // Client-readable\n      secure: process.env.NODE_ENV === \"production\",\n      sameSite: \"lax\",\n      expires: new Date(dataroomSession.expiresAt),\n      path: targetPath,\n    });\n  }\n\n  return NextResponse.json({\n    success: true,\n    targetUrl: executionResult.targetUrl,\n    targetLinkId: executionResult.targetLinkId,\n    targetLinkType: executionResult.targetLinkType,\n  });\n}\n"
  },
  {
    "path": "app/(ee)/api/workflow-entry/link/[entryLinkId]/access/route.ts",
    "content": "import { cookies } from \"next/headers\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nimport { WorkflowEngine } from \"@/ee/features/workflows/lib/engine\";\nimport { AccessRequestSchema } from \"@/ee/features/workflows/lib/types\";\nimport { ipAddress } from \"@vercel/functions\";\nimport { z } from \"zod\";\n\nimport {\n  collectFingerprintHeaders,\n  createDataroomSession,\n  generateSessionFingerprint,\n} from \"@/lib/auth/dataroom-auth\";\nimport { createLinkSession } from \"@/lib/auth/link-session\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { LOCALHOST_IP } from \"@/lib/utils/geo\";\n\n// POST /app/(ee)/api/workflow-entry/[entryLinkId]/access - Verify OTP and execute workflow\nexport async function POST(\n  req: NextRequest,\n  { params }: { params: { entryLinkId: string } },\n) {\n  try {\n    const { entryLinkId } = params;\n    const body = await req.json();\n\n    // Validate entryLinkId format\n    const linkIdValidation = z.string().cuid().safeParse(entryLinkId);\n    if (!linkIdValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid link ID format\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate request body\n    const validation = AccessRequestSchema.safeParse(body);\n    if (!validation.success) {\n      return NextResponse.json(\n        { error: \"Invalid request data\" },\n        { status: 400 },\n      );\n    }\n\n    const { email, code } = validation.data;\n\n    // Rate limiting\n    const ipAddressValue = ipAddress(req) ?? LOCALHOST_IP;\n    const { success } = await ratelimit(10, \"1 m\").limit(\n      `workflow-verify:${ipAddressValue}`,\n    );\n    if (!success) {\n      return NextResponse.json(\n        { error: \"Too many requests. Please try again later.\" },\n        { status: 429 },\n      );\n    }\n\n    // Find workflow by entry link\n    const workflow = await prisma.workflow.findUnique({\n      where: { entryLinkId },\n      select: {\n        id: true,\n        isActive: true,\n        teamId: true,\n        entryLink: {\n          select: {\n            id: true,\n            isArchived: true,\n            deletedAt: true,\n          },\n        },\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow entry link not found\" },\n        { status: 404 },\n      );\n    }\n\n    if (!workflow.isActive) {\n      return NextResponse.json(\n        { error: \"This workflow is currently inactive\" },\n        { status: 403 },\n      );\n    }\n\n    if (workflow.entryLink.isArchived || workflow.entryLink.deletedAt) {\n      return NextResponse.json(\n        { error: \"This link is no longer available\" },\n        { status: 404 },\n      );\n    }\n\n    // Verify OTP code\n    const verification = await prisma.verificationToken.findUnique({\n      where: {\n        token: code,\n        identifier: `workflow-otp:${entryLinkId}:${email}`,\n      },\n    });\n\n    if (!verification) {\n      return NextResponse.json(\n        { error: \"Invalid verification code\", resetVerification: true },\n        { status: 401 },\n      );\n    }\n\n    // Check OTP expiration\n    if (Date.now() > verification.expires.getTime()) {\n      await prisma.verificationToken.delete({\n        where: { token: code },\n      });\n      return NextResponse.json(\n        { error: \"Verification code expired\", resetVerification: true },\n        { status: 401 },\n      );\n    }\n\n    // Delete OTP after successful verification\n    await prisma.verificationToken.delete({\n      where: { token: code },\n    });\n\n    // Execute workflow engine\n    const engine = new WorkflowEngine();\n    const userAgent = req.headers.get(\"user-agent\") ?? \"unknown\";\n    const referrer = req.headers.get(\"referer\") ?? undefined;\n\n    const executionResult = await engine.execute(entryLinkId, {\n      visitorEmail: email,\n      visitorIp: ipAddressValue,\n      userAgent,\n      referrer,\n    });\n\n    if (!executionResult.success) {\n      return NextResponse.json(\n        { error: executionResult.error || \"Workflow execution failed\" },\n        { status: 400 },\n      );\n    }\n\n    // Find or create viewer\n    let viewer = await prisma.viewer.findUnique({\n      where: {\n        teamId_email: {\n          teamId: workflow.teamId,\n          email: email,\n        },\n      },\n      select: { id: true },\n    });\n\n    if (!viewer) {\n      viewer = await prisma.viewer.create({\n        data: {\n          email: email,\n          verified: true,\n          teamId: workflow.teamId,\n        },\n        select: { id: true },\n      });\n    }\n\n    // Create a view record for the workflow execution\n    const view = await prisma.view.create({\n      data: {\n        linkId: entryLinkId,\n        viewerEmail: email,\n        viewerId: viewer.id,\n        verified: true,\n        teamId: workflow.teamId,\n        viewType:\n          executionResult.targetLinkType === \"DATAROOM_LINK\"\n            ? \"DATAROOM_VIEW\"\n            : \"DOCUMENT_VIEW\",\n        documentId: executionResult.targetDocumentId ?? undefined,\n        dataroomId: executionResult.targetDataroomId ?? undefined,\n      },\n      select: { id: true },\n    });\n\n    // Create link session for the target link\n    const linkFingerprint = generateSessionFingerprint(\n      collectFingerprintHeaders(req.headers),\n    );\n    const { token: sessionToken, expiresAt } = await createLinkSession(\n      executionResult.targetLinkId!,\n      executionResult.targetLinkType!,\n      view.id,\n      email,\n      ipAddressValue,\n      userAgent,\n      true, // verified\n      viewer.id, // viewerId\n      executionResult.targetDocumentId,\n      executionResult.targetDataroomId,\n      linkFingerprint,\n    );\n\n    // Parse target URL safely with fallback\n    let targetPath = `/view/${executionResult.targetLinkId}`;\n    let cookieFlagId = executionResult.targetLinkId;\n\n    if (executionResult.targetUrl) {\n      try {\n        const parsedUrl = new URL(executionResult.targetUrl);\n        targetPath = parsedUrl.pathname;\n        const pathSegment = parsedUrl.pathname.split(\"/\").pop();\n        if (pathSegment) {\n          cookieFlagId = pathSegment;\n        }\n      } catch (error) {\n        console.error(\"Failed to parse target URL, using fallback values\");\n      }\n    }\n\n    // Set link session cookie (httpOnly)\n    cookies().set(`pm_ls_${executionResult.targetLinkId}`, sessionToken, {\n      httpOnly: true,\n      secure: process.env.NODE_ENV === \"production\",\n      sameSite: \"lax\",\n      expires: new Date(expiresAt),\n      path: \"/\",\n    });\n\n    // Set client-readable flag cookie for auto-login detection\n    const flagCookieId = `pm_link_flag_${cookieFlagId}`;\n    cookies().set(flagCookieId, \"true\", {\n      httpOnly: false, // Client-readable\n      secure: process.env.NODE_ENV === \"production\",\n      sameSite: \"lax\",\n      expires: new Date(expiresAt),\n      path: targetPath,\n    });\n\n    // If routing to a dataroom, also create and set dataroom session cookie\n    if (\n      executionResult.targetLinkType === \"DATAROOM_LINK\" &&\n      executionResult.targetDataroomId &&\n      viewer\n    ) {\n      const fingerprint = generateSessionFingerprint(\n        collectFingerprintHeaders(req.headers),\n      );\n      const dataroomSession = await createDataroomSession(\n        executionResult.targetDataroomId,\n        executionResult.targetLinkId!,\n        view.id,\n        ipAddressValue,\n        true, // verified\n        viewer.id,\n        fingerprint,\n      );\n\n      cookies().set(\n        `pm_drs_${executionResult.targetLinkId}`,\n        dataroomSession.token,\n        {\n          httpOnly: true,\n          secure: process.env.NODE_ENV === \"production\",\n          sameSite: \"lax\",\n          expires: new Date(dataroomSession.expiresAt),\n          path: \"/\",\n        },\n      );\n\n      // Set client-readable flag cookie for dataroom\n      const dataroomFlagId = `pm_drs_flag_${cookieFlagId}`;\n      cookies().set(dataroomFlagId, \"true\", {\n        httpOnly: false, // Client-readable\n        secure: process.env.NODE_ENV === \"production\",\n        sameSite: \"lax\",\n        expires: new Date(dataroomSession.expiresAt),\n        path: targetPath,\n      });\n    }\n\n    return NextResponse.json({\n      success: true,\n      targetUrl: executionResult.targetUrl,\n      targetLinkId: executionResult.targetLinkId,\n      targetLinkType: executionResult.targetLinkType,\n    });\n  } catch (error) {\n    console.error(\"Error verifying workflow access:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/workflow-entry/link/[entryLinkId]/verify/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { ipAddress, waitUntil } from \"@vercel/functions\";\nimport { z } from \"zod\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { sendOtpVerificationEmail } from \"@/lib/emails/send-email-otp-verification\";\nimport { generateOTP } from \"@/lib/utils/generate-otp\";\nimport { validateEmail } from \"@/lib/utils/validate-email\";\nimport { VerifyEmailRequestSchema } from \"@/ee/features/workflows/lib/types\";\n\n// POST /app/(ee)/api/workflow-entry/[entryLinkId]/verify - Send OTP\nexport async function POST(\n  req: NextRequest,\n  { params }: { params: { entryLinkId: string } },\n) {\n  try {\n    const { entryLinkId } = params;\n    const body = await req.json();\n\n    // Validate entryLinkId format\n    const linkIdValidation = z.string().cuid().safeParse(entryLinkId);\n    if (!linkIdValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid link ID format\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate request body\n    const validation = VerifyEmailRequestSchema.safeParse(body);\n    if (!validation.success) {\n      return NextResponse.json(\n        { error: \"Invalid email address\" },\n        { status: 400 },\n      );\n    }\n\n    const { email } = validation.data;\n\n    // Find workflow by entry link\n    const workflow = await prisma.workflow.findUnique({\n      where: { entryLinkId },\n      select: {\n        id: true,\n        isActive: true,\n        name: true,\n        teamId: true,\n        entryLink: {\n          select: {\n            id: true,\n            isArchived: true,\n            deletedAt: true,\n          },\n        },\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow entry link not found\" },\n        { status: 404 },\n      );\n    }\n\n    if (!workflow.isActive) {\n      return NextResponse.json(\n        { error: \"This workflow is currently inactive\" },\n        { status: 403 },\n      );\n    }\n\n    if (workflow.entryLink.isArchived || workflow.entryLink.deletedAt) {\n      return NextResponse.json(\n        { error: \"This link is no longer available\" },\n        { status: 404 },\n      );\n    }\n\n    // Rate limit per email/entryLink combination (1 per 30 seconds) to prevent OTP flooding\n    const { success: emailLimitSuccess } = await ratelimit(1, \"30 s\").limit(\n      `workflow-otp:${entryLinkId}:${email}`,\n    );\n    if (!emailLimitSuccess) {\n      return NextResponse.json(\n        {\n          error:\n            \"Please wait before requesting another code. Try again in 30 seconds.\",\n        },\n        { status: 429 },\n      );\n    }\n\n    // Additional IP-based rate limit (10 per minute) to prevent abuse across different emails\n    const ipAddressValue = ipAddress(req);\n    const { success } = await ratelimit(10, \"1 m\").limit(\n      `workflow-otp:${ipAddressValue}`,\n    );\n    if (!success) {\n      return NextResponse.json(\n        { error: \"Too many requests. Please try again later.\" },\n        { status: 429 },\n      );\n    }\n\n    // Delete any existing OTP codes for this workflow entry + email\n    await prisma.verificationToken.deleteMany({\n      where: {\n        identifier: `workflow-otp:${entryLinkId}:${email}`,\n      },\n    });\n\n    // Generate and store OTP\n    const otpCode = generateOTP();\n    const expiresAt = new Date();\n    expiresAt.setMinutes(expiresAt.getMinutes() + 10); // expires in 10 minutes\n\n    await prisma.verificationToken.create({\n      data: {\n        token: otpCode,\n        identifier: `workflow-otp:${entryLinkId}:${email}`,\n        expires: expiresAt,\n      },\n    });\n\n    // Send OTP email\n    waitUntil(\n      sendOtpVerificationEmail(email, otpCode, false, workflow.teamId),\n    );\n\n    return NextResponse.json({\n      success: true,\n      message: \"Verification code sent to your email\",\n    });\n  } catch (error) {\n    console.error(\"Error sending workflow verification code:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n"
  },
  {
    "path": "app/(ee)/api/workflows/[workflowId]/executions/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { getServerSession } from \"next-auth\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { z } from \"zod\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// GET /app/(ee)/api/workflows/[workflowId]/executions?teamId=xxx - List workflow executions\nexport async function GET(\n  req: NextRequest,\n  { params }: { params: { workflowId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Parse and validate pagination parameters\n    const rawPage = Number.parseInt(searchParams.get(\"page\") || \"1\", 10);\n    const rawLimit = Number.parseInt(searchParams.get(\"limit\") || \"20\", 10);\n\n    // Apply defaults for invalid values and enforce constraints\n    const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;\n    const limit = Number.isNaN(rawLimit) || rawLimit < 1 \n      ? 20 \n      : Math.min(Math.max(rawLimit, 1), 100); // Min 1, Max 100\n    const skip = (page - 1) * limit;\n\n    // Validate IDs format\n    const idsValidation = z.object({\n      workflowId: z.string().cuid(),\n      teamId: z.string().cuid(),\n    }).safeParse({ workflowId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid ID format\" },\n        { status: 400 },\n      );\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Check workflow exists and belongs to team\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n        teamId: teamId,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Fetch executions with pagination\n    const [executions, totalCount] = await Promise.all([\n      prisma.workflowExecution.findMany({\n        where: { workflowId },\n        include: {\n          stepLogs: {\n            select: {\n              id: true,\n              workflowStepId: true,\n              conditionsMatched: true,\n              executedAt: true,\n              duration: true,\n              error: true,\n            },\n            orderBy: { executedAt: \"asc\" },\n          },\n        },\n        orderBy: { startedAt: \"desc\" },\n        skip,\n        take: limit,\n      }),\n      prisma.workflowExecution.count({\n        where: { workflowId },\n      }),\n    ]);\n\n    return NextResponse.json({\n      executions,\n      pagination: {\n        page,\n        limit,\n        totalCount,\n        totalPages: Math.ceil(totalCount / limit),\n      },\n    });\n  } catch (error) {\n    console.error(\"Error fetching workflow executions:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n"
  },
  {
    "path": "app/(ee)/api/workflows/[workflowId]/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport {\n  UpdateWorkflowRequestSchema,\n  formatZodError,\n} from \"@/ee/features/workflows/lib/validation\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { customAlphabet } from \"nanoid\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// GET /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Get single workflow with details\nexport async function GET(\n  req: NextRequest,\n  { params }: { params: { workflowId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z\n      .object({\n        workflowId: z.string().cuid(),\n        teamId: z.string().cuid(),\n      })\n      .safeParse({ workflowId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json({ error: \"Invalid ID format\" }, { status: 400 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Fetch workflow\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n        teamId: teamId, // Ensure workflow belongs to the team\n      },\n      include: {\n        entryLink: {\n          select: {\n            id: true,\n            slug: true,\n            domainSlug: true,\n          },\n        },\n        steps: {\n          orderBy: { stepOrder: \"asc\" },\n        },\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Build entry URL\n    const entryUrl =\n      workflow.entryLink.domainSlug && workflow.entryLink.slug\n        ? `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`\n        : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${workflow.entryLink.id}`;\n\n    return NextResponse.json({\n      ...workflow,\n      entryUrl,\n    });\n  } catch (error) {\n    console.error(\"Error fetching workflow:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// PATCH /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Update workflow\nexport async function PATCH(\n  req: NextRequest,\n  { params }: { params: { workflowId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z\n      .object({\n        workflowId: z.string().cuid(),\n        teamId: z.string().cuid(),\n      })\n      .safeParse({ workflowId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json({ error: \"Invalid ID format\" }, { status: 400 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    const body = await req.json();\n\n    // Validate request body\n    const validation = UpdateWorkflowRequestSchema.safeParse(body);\n    if (!validation.success) {\n      return NextResponse.json(\n        {\n          error: \"Invalid request data\",\n          details: formatZodError(validation.error),\n        },\n        { status: 400 },\n      );\n    }\n\n    // Check workflow exists and belongs to team\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n        teamId: teamId,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Update workflow\n    const updatedWorkflow = await prisma.workflow.update({\n      where: { id: workflowId },\n      data: validation.data,\n      include: {\n        entryLink: {\n          select: {\n            id: true,\n            slug: true,\n            domainSlug: true,\n          },\n        },\n      },\n    });\n\n    return NextResponse.json(updatedWorkflow);\n  } catch (error) {\n    console.error(\"Error updating workflow:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// DELETE /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Delete workflow\nexport async function DELETE(\n  req: NextRequest,\n  { params }: { params: { workflowId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z\n      .object({\n        workflowId: z.string().cuid(),\n        teamId: z.string().cuid(),\n      })\n      .safeParse({ workflowId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json({ error: \"Invalid ID format\" }, { status: 400 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Fetch workflow to check existence and get entryLinkId\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n        teamId: teamId,\n      },\n      select: {\n        id: true,\n        entryLinkId: true,\n        entryLink: {\n          select: {\n            slug: true,\n          },\n        },\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Generate a random suffix for the deleted slug to free up the original slug\n    const generateDeletedSuffix = customAlphabet(\n      \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n      6,\n    );\n\n    // Delete workflow and entry link in transaction\n    // Note: Steps and executions will cascade delete via Prisma relations\n    await prisma.$transaction([\n      // Delete workflow first (cascade deletes steps and executions)\n      prisma.workflow.delete({\n        where: { id: workflowId },\n      }),\n      // Soft delete entry link and rename slug so it can be reused\n      prisma.link.update({\n        where: { id: workflow.entryLinkId },\n        data: {\n          deletedAt: new Date(),\n          isArchived: true,\n          ...(workflow.entryLink?.slug && {\n            slug: `${workflow.entryLink.slug}-DELETED-${generateDeletedSuffix()}`,\n          }),\n        },\n      }),\n    ]);\n\n    return NextResponse.json({ success: true });\n  } catch (error) {\n    console.error(\"Error deleting workflow:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/workflows/[workflowId]/steps/[stepId]/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport {\n  UpdateWorkflowStepRequestSchema,\n  formatZodError,\n  validateActions,\n  validateConditions,\n} from \"@/ee/features/workflows/lib/validation\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// PATCH /app/(ee)/api/workflows/[workflowId]/steps/[stepId]?teamId=xxx - Update step\nexport async function PATCH(\n  req: NextRequest,\n  { params }: { params: { workflowId: string; stepId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId, stepId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z\n      .object({\n        workflowId: z.string().cuid(),\n        stepId: z.string().cuid(),\n        teamId: z.string().cuid(),\n      })\n      .safeParse({ workflowId, stepId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json({ error: \"Invalid ID format\" }, { status: 400 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    const body = await req.json();\n\n    // Validate request body\n    const validation = UpdateWorkflowStepRequestSchema.safeParse(body);\n    if (!validation.success) {\n      return NextResponse.json(\n        {\n          error: \"Invalid request data\",\n          details: formatZodError(validation.error),\n        },\n        { status: 400 },\n      );\n    }\n\n    // Validate conditions if provided\n    let validatedConditions: any | undefined;\n    if (validation.data.conditions) {\n      const conditionsValidation = validateConditions(\n        validation.data.conditions,\n      );\n      if (!conditionsValidation.valid) {\n        return NextResponse.json(\n          { error: conditionsValidation.error },\n          { status: 400 },\n        );\n      }\n      validatedConditions = conditionsValidation.data;\n    }\n\n    // Validate actions if provided\n    let validatedActions: any[] | undefined;\n    if (validation.data.actions) {\n      const actionsValidation = validateActions(validation.data.actions);\n      if (!actionsValidation.valid) {\n        return NextResponse.json(\n          { error: actionsValidation.error },\n          { status: 400 },\n        );\n      }\n\n      // Use actionsValidation.data so enrichment persists\n      validatedActions = actionsValidation.data;\n\n      // Validate target link exists and belongs to the team\n      const routeAction = validatedActions.find((a) => a.type === \"route\");\n      if (routeAction && routeAction.targetLinkId) {\n        // First get the workflow to get teamId\n        const workflow = await prisma.workflow.findUnique({\n          where: { id: workflowId },\n          select: { teamId: true },\n        });\n\n        if (workflow) {\n          const targetLink = await prisma.link.findUnique({\n            where: {\n              id: routeAction.targetLinkId,\n              teamId: workflow.teamId,\n            },\n          });\n\n          if (!targetLink) {\n            return NextResponse.json(\n              { error: \"Target link not found or not accessible\" },\n              { status: 400 },\n            );\n          }\n\n          // Update action with target details (mutates validatedActions)\n          if (\n            targetLink.linkType === \"DOCUMENT_LINK\" &&\n            targetLink.documentId\n          ) {\n            routeAction.targetDocumentId = targetLink.documentId;\n          } else if (\n            targetLink.linkType === \"DATAROOM_LINK\" &&\n            targetLink.dataroomId\n          ) {\n            routeAction.targetDataroomId = targetLink.dataroomId;\n          }\n        }\n      }\n    }\n\n    // Check step exists and workflow belongs to team\n    const step = await prisma.workflowStep.findUnique({\n      where: {\n        id: stepId,\n        workflowId: workflowId,\n      },\n      select: {\n        id: true,\n        workflow: {\n          select: {\n            id: true,\n            teamId: true,\n          },\n        },\n      },\n    });\n\n    if (!step || step.workflow.teamId !== teamId) {\n      return NextResponse.json({ error: \"Step not found\" }, { status: 404 });\n    }\n\n    // Extract emails and domains from conditions to sync with link allowList (if conditions updated)\n    let allowListItems: string[] | undefined;\n    if (validatedConditions) {\n      allowListItems = [];\n      if (validatedConditions.items) {\n        validatedConditions.items.forEach((condition: any) => {\n          const values = Array.isArray(condition.value)\n            ? condition.value\n            : [condition.value];\n          if (condition.type === \"domain\") {\n            // Add @ prefix for domains in allowList\n            allowListItems!.push(...values.map((v: string) => `@${v}`));\n          } else if (condition.type === \"email\") {\n            allowListItems!.push(...values);\n          }\n        });\n      }\n    }\n\n    // Get target link ID (either from update or existing step)\n    let targetLinkId: string | undefined;\n    if (validatedActions) {\n      const routeAction = validatedActions.find((a) => a.type === \"route\");\n      targetLinkId = routeAction?.targetLinkId;\n    } else {\n      // Get from existing step\n      const existingStep = await prisma.workflowStep.findUnique({\n        where: { id: stepId },\n        select: { actions: true },\n      });\n      const existingActions = existingStep?.actions as any[];\n      const existingRouteAction = existingActions?.find(\n        (a) => a.type === \"route\",\n      );\n      targetLinkId = existingRouteAction?.targetLinkId;\n    }\n\n    // Build update data with validated conditions and actions (if provided)\n    const updateData: any = { ...validation.data };\n    if (validatedConditions) {\n      updateData.conditions = validatedConditions;\n    }\n    if (validatedActions) {\n      updateData.actions = validatedActions;\n    }\n\n    // Update step and optionally update link allowList in transaction\n    const updates: any[] = [\n      prisma.workflowStep.update({\n        where: { id: stepId },\n        data: updateData as any,\n      }),\n    ];\n\n    // If we have allowList updates and a target link, sync the link\n    if (allowListItems !== undefined && targetLinkId) {\n      updates.push(\n        prisma.link.update({\n          where: { id: targetLinkId },\n          data: { allowList: allowListItems },\n        }),\n      );\n    }\n\n    const [updatedStep] = await prisma.$transaction(updates);\n\n    return NextResponse.json(updatedStep);\n  } catch (error) {\n    console.error(\"Error updating workflow step:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// DELETE /app/(ee)/api/workflows/[workflowId]/steps/[stepId]?teamId=xxx - Delete step\nexport async function DELETE(\n  req: NextRequest,\n  { params }: { params: { workflowId: string; stepId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId, stepId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z\n      .object({\n        workflowId: z.string().cuid(),\n        stepId: z.string().cuid(),\n        teamId: z.string().cuid(),\n      })\n      .safeParse({ workflowId, stepId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json({ error: \"Invalid ID format\" }, { status: 400 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Check step exists and workflow belongs to team\n    const step = await prisma.workflowStep.findUnique({\n      where: {\n        id: stepId,\n        workflowId: workflowId,\n      },\n      select: {\n        id: true,\n        stepOrder: true,\n        workflow: {\n          select: {\n            id: true,\n            teamId: true,\n          },\n        },\n      },\n    });\n\n    if (!step || step.workflow.teamId !== teamId) {\n      return NextResponse.json({ error: \"Step not found\" }, { status: 404 });\n    }\n\n    // Get steps that need reordering (those after the deleted step)\n    const stepsToReorder = await prisma.workflowStep.findMany({\n      where: {\n        workflowId,\n        stepOrder: { gt: step.stepOrder },\n      },\n      select: {\n        id: true,\n        stepOrder: true,\n      },\n    });\n\n    // Delete step and reorder remaining steps in a transaction\n    await prisma.$transaction([\n      prisma.workflowStep.delete({\n        where: { id: stepId },\n      }),\n      ...stepsToReorder.map((s) =>\n        prisma.workflowStep.update({\n          where: { id: s.id },\n          data: { stepOrder: s.stepOrder - 1 },\n        }),\n      ),\n    ]);\n\n    return NextResponse.json({ success: true });\n  } catch (error) {\n    console.error(\"Error deleting workflow step:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/(ee)/api/workflows/[workflowId]/steps/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\nimport { getServerSession } from \"next-auth\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { z } from \"zod\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport {\n  CreateWorkflowStepRequestSchema,\n  ReorderStepsRequestSchema,\n  formatZodError,\n  validateConditions,\n  validateActions,\n} from \"@/ee/features/workflows/lib/validation\";\nimport { ReorderStepsRequest } from \"@/ee/features/workflows/lib/types\";\n\n// GET /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - List all steps\nexport async function GET(\n  req: NextRequest,\n  { params }: { params: { workflowId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z.object({\n      workflowId: z.string().cuid(),\n      teamId: z.string().cuid(),\n    }).safeParse({ workflowId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid ID format\" },\n        { status: 400 },\n      );\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Check workflow exists and belongs to team\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n        teamId: teamId,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Fetch steps\n    const steps = await prisma.workflowStep.findMany({\n      where: { workflowId },\n      orderBy: { stepOrder: \"asc\" },\n    });\n\n    // Enrich steps with target link details\n    const enrichedSteps = await Promise.all(\n      steps.map(async (step) => {\n        const actions = step.actions as any[];\n        const routeAction = actions.find((a) => a.type === \"route\");\n\n        if (routeAction && routeAction.targetLinkId) {\n          const targetLink = await prisma.link.findUnique({\n            where: { id: routeAction.targetLinkId },\n            select: {\n              id: true,\n              name: true,\n              slug: true,\n              domainSlug: true,\n              linkType: true,\n            },\n          });\n\n          return {\n            ...step,\n            targetLink,\n          };\n        }\n\n        return step;\n      }),\n    );\n\n    return NextResponse.json(enrichedSteps);\n  } catch (error) {\n    console.error(\"Error fetching workflow steps:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// POST /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - Create a new step\nexport async function POST(\n  req: NextRequest,\n  { params }: { params: { workflowId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z.object({\n      workflowId: z.string().cuid(),\n      teamId: z.string().cuid(),\n    }).safeParse({ workflowId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid ID format\" },\n        { status: 400 },\n      );\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    const body = await req.json();\n\n    // Validate request body\n    const validation = CreateWorkflowStepRequestSchema.safeParse(body);\n    if (!validation.success) {\n      return NextResponse.json(\n        {\n          error: \"Invalid request data\",\n          details: formatZodError(validation.error),\n        },\n        { status: 400 },\n      );\n    }\n\n    const { name, conditions, actions } = validation.data;\n\n    // Validate conditions and actions\n    const conditionsValidation = validateConditions(conditions);\n    if (!conditionsValidation.valid) {\n      return NextResponse.json(\n        { error: conditionsValidation.error },\n        { status: 400 },\n      );\n    }\n\n    const actionsValidation = validateActions(actions);\n    if (!actionsValidation.valid) {\n      return NextResponse.json(\n        { error: actionsValidation.error },\n        { status: 400 },\n      );\n    }\n\n    // Check workflow exists and belongs to team\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n        teamId: teamId,\n      },\n      select: {\n        id: true,\n        teamId: true,\n        steps: {\n          select: { stepOrder: true },\n          orderBy: { stepOrder: \"desc\" },\n          take: 1,\n        },\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Validate target link exists and belongs to the team\n    // Use actionsValidation.data so enrichment persists\n    const routeAction = actionsValidation.data.find((a) => a.type === \"route\");\n    if (routeAction && routeAction.targetLinkId) {\n      const targetLink = await prisma.link.findUnique({\n        where: {\n          id: routeAction.targetLinkId,\n          teamId: workflow.teamId,\n        },\n      });\n\n      if (!targetLink) {\n        return NextResponse.json(\n          { error: \"Target link not found or not accessible\" },\n          { status: 400 },\n        );\n      }\n\n      // Update action with target details (mutates actionsValidation.data)\n      if (targetLink.linkType === \"DOCUMENT_LINK\" && targetLink.documentId) {\n        routeAction.targetDocumentId = targetLink.documentId;\n      } else if (\n        targetLink.linkType === \"DATAROOM_LINK\" &&\n        targetLink.dataroomId\n      ) {\n        routeAction.targetDataroomId = targetLink.dataroomId;\n      }\n    }\n\n    // Calculate next step order\n    const maxStepOrder = workflow.steps[0]?.stepOrder ?? -1;\n    const nextStepOrder = maxStepOrder + 1;\n\n    // Extract emails and domains from conditions to sync with link allowList\n    const allowListItems: string[] = [];\n    if (conditionsValidation.data.items) {\n      conditionsValidation.data.items.forEach((condition: any) => {\n        const values = Array.isArray(condition.value)\n          ? condition.value\n          : [condition.value];\n        if (condition.type === \"domain\") {\n          // Add @ prefix for domains in allowList\n          allowListItems.push(...values.map((v: string) => `@${v}`));\n        } else if (condition.type === \"email\") {\n          allowListItems.push(...values);\n        }\n      });\n    }\n\n    // Create step and conditionally update target link's allowList in transaction\n    const transactionSteps: any[] = [\n      prisma.workflowStep.create({\n        data: {\n          workflowId,\n          name,\n          stepOrder: nextStepOrder,\n          stepType: \"ROUTER\",\n          conditions: conditionsValidation.data as any,\n          actions: actionsValidation.data as any,\n        },\n      }),\n    ];\n\n    // Only update link allowList if we have a route action with a target link\n    if (routeAction && routeAction.targetLinkId) {\n      transactionSteps.push(\n        prisma.link.update({\n          where: { id: routeAction.targetLinkId },\n          data: {\n            allowList: allowListItems,\n          },\n        }),\n      );\n    }\n\n    const [newStep] = await prisma.$transaction(transactionSteps);\n\n    return NextResponse.json(newStep, { status: 201 });\n  } catch (error) {\n    console.error(\"Error creating workflow step:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// PUT /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - Reorder steps\nexport async function PUT(\n  req: NextRequest,\n  { params }: { params: { workflowId: string } },\n) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { workflowId } = params;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate IDs format\n    const idsValidation = z.object({\n      workflowId: z.string().cuid(),\n      teamId: z.string().cuid(),\n    }).safeParse({ workflowId, teamId });\n\n    if (!idsValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid ID format\" },\n        { status: 400 },\n      );\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    const body = await req.json();\n\n    // Validate request body\n    const validation = ReorderStepsRequestSchema.safeParse(body);\n    if (!validation.success) {\n      return NextResponse.json(\n        {\n          error: \"Invalid request data\",\n          details: formatZodError(validation.error),\n        },\n        { status: 400 },\n      );\n    }\n\n    // Check workflow exists and belongs to team\n    const workflow = await prisma.workflow.findUnique({\n      where: {\n        id: workflowId,\n        teamId: teamId,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!workflow) {\n      return NextResponse.json(\n        { error: \"Workflow not found\" },\n        { status: 404 },\n      );\n    }\n\n    // Update step orders in a transaction\n    await prisma.$transaction(\n      validation.data.steps.map((step) =>\n        prisma.workflowStep.update({\n          where: {\n            id: step.stepId,\n            workflowId, // Ensure step belongs to this workflow\n          },\n          data: {\n            stepOrder: step.stepOrder,\n          },\n        }),\n      ),\n    );\n\n    return NextResponse.json({ success: true });\n  } catch (error) {\n    console.error(\"Error reordering workflow steps:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n"
  },
  {
    "path": "app/(ee)/api/workflows/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport {\n  CreateWorkflowRequestSchema,\n  formatZodError,\n} from \"@/ee/features/workflows/lib/validation\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// GET /app/(ee)/api/workflows?teamId=xxx - List all workflows for a team\nexport async function GET(req: NextRequest) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate teamId format\n    const teamIdValidation = z.string().cuid().safeParse(teamId);\n    if (!teamIdValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid teamId format\" },\n        { status: 400 },\n      );\n    }\n\n    // Check user is part of the team\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: (session.user as CustomUser).id,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    // Fetch workflows with entry link and step count\n    const workflows = await prisma.workflow.findMany({\n      where: { teamId },\n      include: {\n        entryLink: {\n          select: {\n            id: true,\n            slug: true,\n            domainSlug: true,\n          },\n        },\n        _count: {\n          select: {\n            steps: true,\n            executions: true,\n          },\n        },\n      },\n      orderBy: { createdAt: \"desc\" },\n    });\n\n    return NextResponse.json(workflows);\n  } catch (error) {\n    console.error(\"Error fetching workflows:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n\n// POST /app/(ee)/api/workflows?teamId=xxx - Create a new workflow\nexport async function POST(req: NextRequest) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const searchParams = req.nextUrl.searchParams;\n    const teamId = searchParams.get(\"teamId\");\n\n    if (!teamId) {\n      return NextResponse.json(\n        { error: \"teamId parameter is required\" },\n        { status: 400 },\n      );\n    }\n\n    // Validate teamId format\n    const teamIdValidation = z.string().cuid().safeParse(teamId);\n    if (!teamIdValidation.success) {\n      return NextResponse.json(\n        { error: \"Invalid teamId format\" },\n        { status: 400 },\n      );\n    }\n\n    // Check user is part of the team using userTeam table\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return NextResponse.json(\n        { error: \"Unauthorized to access this team\" },\n        { status: 403 },\n      );\n    }\n\n    const body = await req.json();\n\n    // Validate request body\n    const validation = CreateWorkflowRequestSchema.safeParse(body);\n    if (!validation.success) {\n      return NextResponse.json(\n        {\n          error: \"Invalid request data\",\n          details: formatZodError(validation.error),\n        },\n        { status: 400 },\n      );\n    }\n\n    const { name, description, domain, slug } = validation.data;\n\n    // Get team details for plan check\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      select: {\n        id: true,\n        plan: true,\n      },\n    });\n\n    if (!team) {\n      return NextResponse.json({ error: \"Team not found\" }, { status: 404 });\n    }\n\n    // Check if workflows feature flag is enabled\n    const featureFlags = await getFeatureFlags({ teamId });\n    if (!featureFlags.workflows) {\n      return NextResponse.json(\n        { error: \"This feature is not available for your team\" },\n        { status: 403 },\n      );\n    }\n\n    // Check plan - require Business or DataRooms plan\n    if (team.plan === \"free\" || team.plan === \"pro\") {\n      return NextResponse.json(\n        {\n          error: \"Workflows require a Business or Data Rooms plan\",\n          requiresUpgrade: true,\n        },\n        { status: 403 },\n      );\n    }\n\n    // Validate domain and slug\n    let domainId: string | null = null;\n    let domainSlug: string | null = null;\n\n    if (domain && slug) {\n      // Check if domain exists and belongs to team\n      const domainRecord = await prisma.domain.findUnique({\n        where: {\n          slug: domain,\n          teamId: teamId,\n        },\n        select: { id: true, slug: true },\n      });\n\n      if (!domainRecord) {\n        return NextResponse.json(\n          { error: \"Domain not found or not associated with this team\" },\n          { status: 400 },\n        );\n      }\n\n      domainId = domainRecord.id;\n      domainSlug = domainRecord.slug;\n\n      // Check if slug is already in use on this domain\n      const existingLink = await prisma.link.findUnique({\n        where: {\n          domainSlug_slug: {\n            slug: slug,\n            domainSlug: domain,\n          },\n        },\n      });\n\n      if (existingLink) {\n        return NextResponse.json(\n          { error: \"This slug is already in use on the selected domain\" },\n          { status: 400 },\n        );\n      }\n    }\n\n    // Create workflow with entry link in a transaction\n    const workflow = await prisma.$transaction(async (tx) => {\n      // Create entry link\n      const entryLink = await tx.link.create({\n        data: {\n          linkType: \"WORKFLOW_LINK\",\n          teamId,\n          ownerId: userId,\n          name: `${name} - Entry Link`,\n          slug: slug || null,\n          domainId: domainId,\n          domainSlug: domainSlug,\n          emailProtected: true, // Workflows always require email\n          emailAuthenticated: true, // Workflows always require OTP\n          allowDownload: false,\n          enableNotification: false,\n        },\n      });\n\n      // Create workflow\n      const newWorkflow = await tx.workflow.create({\n        data: {\n          name,\n          description,\n          teamId,\n          entryLinkId: entryLink.id,\n          isActive: true,\n        },\n        include: {\n          entryLink: {\n            select: {\n              id: true,\n              slug: true,\n              domainSlug: true,\n            },\n          },\n        },\n      });\n\n      return newWorkflow;\n    });\n\n    // Build entry URL\n    const entryUrl =\n      workflow.entryLink.domainSlug && workflow.entryLink.slug\n        ? `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`\n        : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${workflow.entryLink.id}`;\n\n    return NextResponse.json(\n      {\n        ...workflow,\n        entryUrl,\n      },\n      { status: 201 },\n    );\n  } catch (error) {\n    console.error(\"Error creating workflow:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/auth/verify-code/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { fetchAndDeleteLoginCodeData } from \"@/lib/emails/send-verification-request\";\nimport { ratelimit } from \"@/lib/redis\";\n\n// Rate limiters\nconst emailRateLimit = ratelimit(5, \"1 m\"); // 5 attempts per minute per email\nconst ipRateLimit = ratelimit(10, \"1 m\"); // 10 attempts per minute per IP\n\nfunction getClientIp(request: NextRequest): string {\n  const forwarded = request.headers.get(\"x-forwarded-for\");\n  const realIp = request.headers.get(\"x-real-ip\");\n  return forwarded?.split(\",\")[0]?.trim() || realIp || \"unknown\";\n}\n\n// POST: Verify via email + code\nexport async function POST(request: NextRequest) {\n  try {\n    const body = await request.json();\n    const { email, code } = body;\n\n    // Type checks first to prevent calling .trim() on non-strings\n    if (typeof email !== \"string\" || typeof code !== \"string\") {\n      return NextResponse.json(\n        { error: \"Email and code are required.\" },\n        { status: 400 },\n      );\n    }\n\n    // Normalize after type check\n    const normalizedEmail = email.trim().toLowerCase();\n    const normalizedCode = code.trim().toUpperCase();\n\n    // Validate non-empty email and exact code length\n    if (!normalizedEmail || normalizedCode.length !== 10) {\n      return NextResponse.json(\n        { error: \"Invalid email or code format.\" },\n        { status: 400 },\n      );\n    }\n\n    const ip = getClientIp(request);\n\n    // Check both rate limits\n    const [emailLimit, ipLimit] = await Promise.all([\n      emailRateLimit.limit(`verify_code:${normalizedEmail}`),\n      ipRateLimit.limit(`verify_code:ip:${ip}`),\n    ]);\n\n    if (!emailLimit.success) {\n      return NextResponse.json(\n        {\n          error: \"Too many attempts. Please wait before trying again.\",\n          retryAfter: Math.ceil((emailLimit.reset - Date.now()) / 1000),\n          remaining: 0,\n        },\n        { status: 429 },\n      );\n    }\n\n    if (!ipLimit.success) {\n      return NextResponse.json(\n        {\n          error: \"Too many attempts. Please wait before trying again.\",\n          retryAfter: Math.ceil((ipLimit.reset - Date.now()) / 1000),\n          remaining: 0,\n        },\n        { status: 429 },\n      );\n    }\n\n    // Atomically fetch and delete to prevent TOCTOU race condition\n    const loginCodeData = await fetchAndDeleteLoginCodeData(\n      normalizedEmail,\n      normalizedCode,\n    );\n\n    if (!loginCodeData) {\n      return NextResponse.json(\n        {\n          error: \"Invalid code. Please check your email and try again.\",\n          remaining: emailLimit.remaining,\n        },\n        { status: 401 },\n      );\n    }\n\n    const { callbackUrl } = loginCodeData;\n\n    return NextResponse.json({ callbackUrl });\n  } catch (error) {\n    console.error(\"Error verifying code:\", error);\n    return NextResponse.json(\n      { error: \"Verification failed. Please try again.\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/cron/dataroom-digest/daily/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { receiver } from \"@/lib/cron\";\nimport { processDataroomDigest } from \"@/lib/emails/process-dataroom-digest\";\nimport { log } from \"@/lib/utils\";\n\n// Runs daily at 9 AM UTC (0 9 * * *)\nexport const maxDuration = 300;\n\nexport async function POST(req: Request) {\n  const body = await req.json();\n  if (process.env.VERCEL === \"1\") {\n    const isValid = await receiver.verify({\n      signature: req.headers.get(\"Upstash-Signature\") || \"\",\n      body: JSON.stringify(body),\n    });\n    if (!isValid) {\n      return new Response(\"Unauthorized\", { status: 401 });\n    }\n  }\n\n  try {\n    const result = await processDataroomDigest(\"daily\");\n    return NextResponse.json({ success: true, ...result });\n  } catch (error) {\n    await log({\n      message: `Daily dataroom digest cron failed. \\n\\nError: ${(error as Error).message}`,\n      type: \"cron\",\n      mention: true,\n    });\n    return NextResponse.json({ error: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "app/api/cron/dataroom-digest/weekly/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { receiver } from \"@/lib/cron\";\nimport { processDataroomDigest } from \"@/lib/emails/process-dataroom-digest\";\nimport { log } from \"@/lib/utils\";\n\n// Runs weekly on Monday at 9 AM UTC (0 9 * * 1)\nexport const maxDuration = 300;\n\nexport async function POST(req: Request) {\n  const body = await req.json();\n  if (process.env.VERCEL === \"1\") {\n    const isValid = await receiver.verify({\n      signature: req.headers.get(\"Upstash-Signature\") || \"\",\n      body: JSON.stringify(body),\n    });\n    if (!isValid) {\n      return new Response(\"Unauthorized\", { status: 401 });\n    }\n  }\n\n  try {\n    const result = await processDataroomDigest(\"weekly\");\n    return NextResponse.json({ success: true, ...result });\n  } catch (error) {\n    await log({\n      message: `Weekly dataroom digest cron failed. \\n\\nError: ${(error as Error).message}`,\n      type: \"cron\",\n      mention: true,\n    });\n    return NextResponse.json({ error: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "app/api/cron/domains/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { receiver } from \"@/lib/cron\";\nimport {\n  getConfigResponse,\n  getDomainResponse,\n  verifyDomain,\n} from \"@/lib/domains\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nimport { handleDomainUpdates } from \"./utils\";\n\n/**\n * Cron to check if domains are verified.\n * If a domain is invalid for more than 14 days, we send a reminder email to the owner.\n * If a domain is invalid for more than 28 days, we send a second and final reminder email to the owner.\n * If a domain is invalid for more than 30 days, we delete it from the database.\n **/\n// Runs once per day at 12pm (0 12 * * *)\n\nexport const maxDuration = 300; // 5 minutes in seconds\n\nexport async function POST(req: Request) {\n  const body = await req.json();\n  if (process.env.VERCEL === \"1\") {\n    const isValid = await receiver.verify({\n      signature: req.headers.get(\"Upstash-Signature\") || \"\",\n      body: JSON.stringify(body),\n    });\n    if (!isValid) {\n      return new Response(\"Unauthorized\", { status: 401 });\n    }\n  }\n\n  try {\n    const domains = await prisma.domain.findMany({\n      where: {\n        slug: {\n          not: {\n            in: [\"papermark.io\", \"papermark.com\"],\n          },\n        },\n      },\n      select: {\n        slug: true,\n        verified: true,\n        createdAt: true,\n        userId: true,\n        teamId: true,\n        _count: {\n          select: {\n            links: true,\n          },\n        },\n      },\n      orderBy: {\n        lastChecked: \"asc\", // earliest first\n      },\n    });\n\n    const results = await Promise.allSettled(\n      domains.map(async (domain) => {\n        const { slug, verified, createdAt, _count } = domain;\n        const [domainJson, configJson] = await Promise.all([\n          getDomainResponse(slug),\n          getConfigResponse(slug),\n        ]);\n\n        let newVerified;\n\n        if (domainJson?.error?.code === \"not_found\") {\n          newVerified = false;\n        } else if (!domainJson.verified) {\n          const verificationJson = await verifyDomain(slug);\n          if (verificationJson && verificationJson.verified) {\n            newVerified = true;\n          } else {\n            newVerified = false;\n          }\n        } else if (!configJson.misconfigured) {\n          newVerified = true;\n        } else {\n          newVerified = false;\n        }\n\n        const prismaResponse = await prisma.domain.update({\n          where: {\n            slug,\n          },\n          data: {\n            verified: newVerified,\n            lastChecked: new Date(),\n          },\n        });\n\n        const changed = newVerified !== verified;\n\n        const updates = await handleDomainUpdates({\n          domain: slug,\n          createdAt,\n          verified: newVerified,\n          changed,\n          linksCount: _count.links,\n        });\n\n        return {\n          domain,\n          previousStatus: verified,\n          currentStatus: newVerified,\n          changed,\n          updates,\n          prismaResponse,\n        };\n      }),\n    );\n    return NextResponse.json(results);\n  } catch (error) {\n    await log({\n      message: `Domains cron failed. \\n\\nError: ${(error as Error).message}`,\n      type: \"cron\",\n      mention: true,\n    });\n    return NextResponse.json({ error: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "app/api/cron/domains/utils.ts",
    "content": "import { deleteDomain } from \"@/lib/api/domains\";\nimport { limiter } from \"@/lib/cron\";\nimport { sendDeletedDomainEmail } from \"@/lib/emails/send-deleted-domain\";\nimport { sendInvalidDomainEmail } from \"@/lib/emails/send-invalid-domain\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nexport const handleDomainUpdates = async ({\n  domain,\n  createdAt,\n  verified,\n  changed,\n  linksCount,\n}: {\n  domain: string;\n  createdAt: Date;\n  verified: boolean;\n  changed: boolean;\n  linksCount: number;\n}) => {\n  if (changed) {\n    await log({\n      message: `Domain *${domain}* changed status to *${verified}*`,\n      type: \"cron\",\n    });\n  }\n\n  if (verified) return;\n\n  const invalidDays = Math.floor(\n    (new Date().getTime() - new Date(createdAt).getTime()) / (1000 * 3600 * 24),\n  );\n\n  // do nothing if domain is invalid for less than 14 days\n  if (invalidDays != 1 && invalidDays < 14) return;\n\n  const team = await prisma.team.findFirst({\n    where: {\n      domains: {\n        some: {\n          slug: domain,\n        },\n      },\n    },\n    select: {\n      id: true,\n      name: true,\n      sentEmails: {\n        where: {\n          type: {\n            in: [\n              \"FIRST_DAY_DOMAIN_REMINDER_EMAIL\",\n              \"FIRST_DOMAIN_INVALID_EMAIL\",\n              \"SECOND_DOMAIN_INVALID_EMAIL\",\n            ],\n          },\n        },\n        select: {\n          type: true,\n          domainSlug: true,\n        },\n      },\n      users: {\n        where: { role: \"ADMIN\" },\n        select: {\n          user: {\n            select: { email: true },\n          },\n        },\n      },\n    },\n  });\n  if (!team) {\n    await log({\n      message: `Domain *${domain}* is invalid but not associated with any user, skipping.`,\n      type: \"cron\",\n      mention: true,\n    });\n    return;\n  }\n\n  // create an array of tuples with email type and domain slug\n  const sentEmails = team.sentEmails.map((email) => [\n    email.type,\n    email.domainSlug,\n  ]);\n  const userEmail = team.users[0].user.email!;\n\n  // if domain is invalid for more than 30 days, check if we can delete it\n  if (invalidDays >= 30) {\n    // if there are still links associated with the domain,\n    // and those links have views associated with them,\n    // don't delete the domain (manual inspection required)\n    if (linksCount > 0) {\n      const linksViews = await prisma.link.findMany({\n        where: {\n          domainSlug: domain,\n        },\n        select: {\n          _count: {\n            select: {\n              views: true,\n            },\n          },\n        },\n      });\n\n      const totalLinksViews = linksViews.reduce(\n        (acc, link) => acc + link._count.views,\n        0,\n      );\n\n      if (totalLinksViews > 0) {\n        await log({\n          message: `Domain *${domain}* has been invalid for > 30 days and has links with clicks, skipping.`,\n          type: \"cron\",\n          mention: true,\n        });\n        return;\n      }\n    }\n    // else, delete the domain and send email\n    return await Promise.allSettled([\n      deleteDomain(domain),\n      log({\n        message: `Domain *${domain}* has been invalid for > 30 days and ${\n          linksCount > 0 ? \"has links but no link clicks\" : \"has no links\"\n        }, deleting.`,\n        type: \"cron\",\n        mention: true,\n      }),\n      limiter.schedule(() => sendDeletedDomainEmail(userEmail, domain)),\n    ]);\n  }\n\n  // if domain is invalid for more than 28 days, send email\n  if (invalidDays >= 28) {\n    const sentSecondDomainInvalidEmail = sentEmails.some(\n      ([type, domainSlug]) =>\n        type === \"SECOND_DOMAIN_INVALID_EMAIL\" && domainSlug === domain,\n    );\n    if (!sentSecondDomainInvalidEmail) {\n      return await Promise.allSettled([\n        log({\n          message: `Domain *${domain}* is invalid for ${invalidDays} days, email sent.`,\n          type: \"cron\",\n        }),\n        limiter.schedule(() =>\n          sendInvalidDomainEmail(userEmail, domain, invalidDays),\n        ),\n        prisma.sentEmail.create({\n          data: {\n            type: \"SECOND_DOMAIN_INVALID_EMAIL\",\n            teamId: team.id,\n            recipient: userEmail,\n            domainSlug: domain,\n          },\n        }),\n      ]);\n    }\n  }\n\n  // if domain is invalid for more than 14 days, send email\n  if (invalidDays >= 14) {\n    const sentFirstDomainInvalidEmail = sentEmails.some(\n      ([type, domainSlug]) =>\n        type === \"FIRST_DOMAIN_INVALID_EMAIL\" && domainSlug === domain,\n    );\n    if (!sentFirstDomainInvalidEmail) {\n      return await Promise.allSettled([\n        log({\n          message: `Domain *${domain}* is invalid for ${invalidDays} days, email sent.`,\n          type: \"cron\",\n        }),\n        limiter.schedule(() =>\n          sendInvalidDomainEmail(userEmail, domain, invalidDays),\n        ),\n        prisma.sentEmail.create({\n          data: {\n            type: \"FIRST_DOMAIN_INVALID_EMAIL\",\n            teamId: team.id,\n            recipient: userEmail,\n            domainSlug: domain,\n          },\n        }),\n      ]);\n    }\n  }\n\n  // if domain is invalid after the first day, send email\n  if (invalidDays == 1) {\n    const sentFirstDayDomainReminderEmail = sentEmails.some(\n      ([type, domainSlug]) =>\n        type === \"FIRST_DAY_DOMAIN_REMINDER_EMAIL\" && domainSlug === domain,\n    );\n    if (!sentFirstDayDomainReminderEmail) {\n      return await Promise.allSettled([\n        log({\n          message: `Domain *${domain}* is invalid for ${invalidDays} days, email sent.`,\n          type: \"cron\",\n        }),\n        limiter.schedule(() =>\n          sendInvalidDomainEmail(userEmail, domain, invalidDays),\n        ),\n        prisma.sentEmail.create({\n          data: {\n            type: \"FIRST_DAY_DOMAIN_REMINDER_EMAIL\",\n            teamId: team.id,\n            recipient: userEmail,\n            domainSlug: domain,\n          },\n        }),\n      ]);\n    }\n  }\n\n  return;\n};\n"
  },
  {
    "path": "app/api/cron/welcome-user/route.ts",
    "content": "import { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport { sendWelcomeEmail } from \"@/lib/emails/send-welcome\";\nimport prisma from \"@/lib/prisma\";\nimport { subscribe } from \"@/lib/resend\";\n\nexport const dynamic = \"force-dynamic\";\n\nexport async function POST(req: Request) {\n  try {\n    const rawBody = await req.text();\n    await verifyQstashSignature({ req, rawBody });\n\n    const { userId } = JSON.parse(rawBody);\n\n    const user = await prisma.user.findUnique({\n      where: {\n        id: userId,\n      },\n      select: {\n        name: true,\n        email: true,\n      },\n    });\n\n    if (!user) {\n      return new Response(\"User not found. Skipping...\", { status: 200 });\n    }\n\n    // this shouldn't happen but just in case\n    if (!user.email) {\n      return new Response(\"User email not found. Skipping...\", { status: 200 });\n    }\n\n    await Promise.allSettled([\n      // send welcome email\n      sendWelcomeEmail({\n        user: {\n          email: user.email,\n          name: user.name,\n        },\n      }),\n      // subscribe user to the mailing list\n      subscribe(user.email),\n    ]);\n\n    return new Response(\"Welcome email sent and user subscribed.\", {\n      status: 200,\n    });\n  } catch (error) {\n    console.error(error);\n    return new Response(\n      \"Error sending welcome email and subscribing user to mailing list.\",\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/cron/year-in-review/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { receiver } from \"@/lib/cron\";\nimport { log } from \"@/lib/utils\";\nimport { processEmailQueue } from \"@/lib/year-in-review/send-emails\";\n\n// Runs every hour (0 * * * *)\nexport const maxDuration = 300; // 5 minutes in seconds\n\nexport async function POST(req: Request) {\n  const body = await req.json();\n  if (process.env.VERCEL === \"1\") {\n    const isValid = await receiver.verify({\n      signature: req.headers.get(\"Upstash-Signature\") || \"\",\n      body: JSON.stringify(body),\n    });\n    if (!isValid) {\n      return new Response(\"Unauthorized\", { status: 401 });\n    }\n  }\n\n  try {\n    await processEmailQueue();\n    return NextResponse.json({ success: true });\n  } catch (error) {\n    await log({\n      message: `Year in review email cron failed. \\n\\nError: ${(error as Error).message}`,\n      type: \"cron\",\n      mention: true,\n    });\n    return NextResponse.json({ error: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "app/api/csp-report/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nexport async function POST(request: Request) {\n  const report = await request.json();\n\n  // Log the report or send to your logging service\n  // console.log(\"CSP Violation:\", report);\n\n  // You could send this to your logging service\n  // await fetch('your-logging-service', {\n  //   method: 'POST',\n  //   body: JSON.stringify(report)\n  // })\n\n  return NextResponse.json({ success: true });\n}\n"
  },
  {
    "path": "app/api/feature-flags/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(request: Request) {\n  const { searchParams } = new URL(request.url);\n  const teamId = searchParams.get(\"teamId\");\n\n  try {\n    const features = await getFeatureFlags({ teamId: teamId || undefined });\n    return NextResponse.json(features);\n  } catch (error) {\n    console.error(\"Error fetching feature flags:\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch feature flags\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/help/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nexport async function GET(request: Request) {\n  const { searchParams } = new URL(request.url);\n  const query = searchParams.get(\"q\");\n\n  try {\n    const response = await fetch(\n      `${process.env.NEXT_PUBLIC_MARKETING_URL}/api/help`,\n    );\n\n    if (!response.ok) {\n      const errorText = await response.text();\n      console.error(\"Help center response:\", {\n        status: response.status,\n        statusText: response.statusText,\n        body: errorText,\n      });\n      throw new Error(`Failed to fetch articles: ${response.statusText}`);\n    }\n\n    const { articles } = await response.json();\n\n    // Filter articles based on search query if provided\n    const filteredArticles = query\n      ? articles.filter(\n          (article: any) =>\n            article.data.title.toLowerCase().includes(query.toLowerCase()) ||\n            article.data.description\n              ?.toLowerCase()\n              .includes(query.toLowerCase()),\n        )\n      : articles;\n\n    return NextResponse.json({ articles: filteredArticles });\n  } catch (error) {\n    console.error(\"Error in help search:\", error);\n    return NextResponse.json(\n      { error: \"Failed to fetch articles\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/integrations/slack/oauth/authorize/route.ts",
    "content": "import { NextResponse } from \"next/server\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { getSlackInstallationUrl } from \"@/lib/integrations/slack/install\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { getSearchParams } from \"@/lib/utils/get-search-params\";\n\nconst oAuthAuthorizeSchema = z.object({\n  teamId: z.string().cuid(),\n});\n\nexport async function GET(req: Request) {\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const { teamId } = oAuthAuthorizeSchema.parse(getSearchParams(req.url));\n    const userId = (session.user as CustomUser).id;\n\n    const userTeam = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!userTeam) {\n      return NextResponse.json({ error: \"Access denied\" }, { status: 403 });\n    }\n\n    const oauthUrl = await getSlackInstallationUrl(teamId);\n\n    return NextResponse.json({\n      oauthUrl,\n    });\n  } catch (error) {\n    console.error(\"Slack OAuth authorization error:\", error);\n    return NextResponse.json(\n      { error: \"Internal server error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/integrations/slack/oauth/callback/route.ts",
    "content": "import { redirect } from \"next/navigation\";\nimport { NextResponse } from \"next/server\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Team } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth\";\nimport z from \"zod\";\n\nimport { installIntegration } from \"@/lib/integrations/install\";\nimport { getSlackEnv } from \"@/lib/integrations/slack/env\";\nimport { SlackCredential } from \"@/lib/integrations/slack/types\";\nimport { encryptSlackToken } from \"@/lib/integrations/slack/utils\";\nimport prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\nimport { CustomUser } from \"@/lib/types\";\nimport { getSearchParams } from \"@/lib/utils/get-search-params\";\n\nexport const dynamic = \"force-dynamic\";\n\nconst oAuthCallbackSchema = z.object({\n  code: z.string(),\n  state: z.string(),\n});\n\nexport const GET = async (req: Request) => {\n  const env = getSlackEnv();\n\n  let team: Pick<Team, \"id\" | \"plan\"> | null = null;\n\n  try {\n    const session = await getServerSession(authOptions);\n    if (!session) {\n      return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    const { code, state } = oAuthCallbackSchema.parse(getSearchParams(req.url));\n\n    // Find workspace that initiated the Stripe app install\n    const stateKey = `slack:install:state:${state}`;\n    const teamId = await redis.get<string>(stateKey);\n\n    if (!teamId) {\n      return NextResponse.json({ error: \"Invalid state\" }, { status: 400 });\n    }\n    await redis.del(stateKey);\n\n    team = await prisma.team.findUniqueOrThrow({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId,\n          },\n        },\n      },\n      select: {\n        id: true,\n        plan: true,\n      },\n    });\n\n    if (!team) {\n      return NextResponse.json({ error: \"Access denied\" }, { status: 403 });\n    }\n\n    const body = new URLSearchParams({\n      code,\n      client_id: env.SLACK_CLIENT_ID,\n      client_secret: env.SLACK_CLIENT_SECRET,\n      redirect_uri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/integrations/slack/oauth/callback`,\n    });\n    const ac = new AbortController();\n    const to = setTimeout(() => ac.abort(), 10000);\n    const response = await fetch(\"https://slack.com/api/oauth.v2.access\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n        Accept: \"application/json\",\n      },\n      body: body.toString(),\n      signal: ac.signal,\n    }).finally(() => clearTimeout(to));\n\n    const data = await response.json();\n    if (!data?.ok) {\n      return NextResponse.json(\n        { error: `Slack OAuth error: ${data?.error || \"unknown\"}` },\n        { status: 400 },\n      );\n    }\n\n    const credentials: SlackCredential = {\n      appId: data.app_id,\n      botUserId: data.bot_user_id,\n      scope: data.scope,\n      accessToken: encryptSlackToken(data.access_token),\n      tokenType: data.token_type,\n      authUser: data.authed_user,\n      team: data.team,\n    };\n\n    await installIntegration({\n      integrationId: env.SLACK_INTEGRATION_ID,\n      userId,\n      teamId,\n      credentials,\n    });\n  } catch (e: any) {\n    return NextResponse.json({ error: e.message }, { status: 500 });\n  }\n\n  redirect(`/settings/slack?success=true`);\n};\n"
  },
  {
    "path": "app/api/og/route.tsx",
    "content": "/* eslint-disable @next/next/no-img-element */\nimport { ImageResponse } from \"next/og\";\nimport { NextRequest } from \"next/server\";\n\nexport const runtime = \"edge\";\n\n/**\n * @name Headline Template\n * @description Make it pop with a headline\n */\nexport async function GET(request: NextRequest) {\n  const searchParams = request.nextUrl.searchParams;\n  const title = searchParams.get(\"title\") || \"Papermark Document\";\n  const Inter = await fetch(\n    new URL(\"@/public/_static/Inter-Bold.ttf\", import.meta.url),\n  ).then((res) => res.arrayBuffer());\n\n  return new ImageResponse(\n    (\n      <div tw=\"flex flex-col items-center justify-between w-full h-full bg-white text-black p-12\">\n        <div tw=\"text-[32px] flex items-center tracking-tighter\">Papermark</div>\n        <div tw=\"text-[42px] text-center\">{title}</div>\n        <div tw=\"text-[32px] flex items-center\">\n          Open-Source Document Sharing\n        </div>\n      </div>\n    ),\n    {\n      width: 1200,\n      height: 630,\n      headers: {\n        \"Cache-Control\": \"public, max-age=3600, immutable\",\n      },\n      fonts: [\n        {\n          name: \"Inter\",\n          data: Inter,\n        },\n      ],\n    },\n  );\n}\n"
  },
  {
    "path": "app/api/og/yir/route.tsx",
    "content": "import { ImageResponse } from \"next/og\";\nimport { NextRequest } from \"next/server\";\n\nexport const runtime = \"edge\";\n\nexport async function GET(req: NextRequest) {\n  const inter = await fetch(\n    new URL(\"@/styles/Inter-Regular.ttf\", import.meta.url),\n  ).then((res) => res.arrayBuffer());\n\n  const interBold = await fetch(\n    new URL(\"@/public/_static/Inter-Bold.ttf\", import.meta.url),\n  ).then((res) => res.arrayBuffer());\n\n  const year = req.nextUrl.searchParams.get(\"year\") || \"2024\";\n  const minutesSpentOnDocs =\n    req.nextUrl.searchParams.get(\"minutesSpentOnDocs\") || \"1000\";\n  const uploadedDocuments =\n    req.nextUrl.searchParams.get(\"uploadedDocuments\") || \"100\";\n  const sharedLinks = req.nextUrl.searchParams.get(\"sharedLinks\") || \"10\";\n  const receivedViews = req.nextUrl.searchParams.get(\"receivedViews\") || \"1000\";\n\n  return new ImageResponse(\n    (\n      <div\n        tw=\"flex bg-black w-full h-full items-center justify-between\"\n        style={{ padding: \"48px\" }}\n      >\n        {/* Left Side Text */}\n        <div tw=\"flex flex-col text-white\" style={{ marginLeft: \"48px\" }}>\n          <div tw=\"flex text-7xl font-bold mb-4 tracking-tighter\">\n            Papermark\n          </div>\n          <div tw=\"flex text-5xl mb-4\">Year in Review</div>\n          <div tw=\"flex text-7xl font-bold\">{year}</div>\n        </div>\n\n        {/* Ticket Container */}\n        <div\n          tw=\"flex flex-col bg-[#fb7a00] rounded-3xl relative overflow-hidden justify-between\"\n          style={{\n            width: \"400px\",\n            height: \"480px\",\n            marginRight: \"48px\",\n            boxShadow: \"0 0 100px -15px rgba(251, 122, 0, 0.3)\",\n            border: \"1px solid rgba(255, 255, 255, 0.1)\",\n          }}\n        >\n          {/* Header Section */}\n          <div tw=\"flex items-start p-8 items-center\">\n            <div tw=\"flex text-2xl font-bold text-white tracking-tighter\">\n              Papermark\n            </div>\n          </div>\n\n          {/* Main Content */}\n          <div tw=\"flex flex-col p-8 border-t border-white/20 h-[240px] justify-center\">\n            <div tw=\"flex text-7xl font-bold text-white mb-2\">\n              {minutesSpentOnDocs}\n            </div>\n            <div tw=\"flex text-2xl font-normal text-white/80\">\n              minutes viewed\n            </div>\n          </div>\n\n          {/* Year */}\n          <div tw=\"flex p-8 text-xl text-white/80 border-t border-white/20 items-center\">\n            {year}\n          </div>\n\n          {/* Footer */}\n          <div tw=\"flex p-8 text-sm text-white/60 border-t border-white/20 items-center\">\n            YEAR IN REVIEW\n          </div>\n        </div>\n      </div>\n    ),\n    {\n      width: 1200,\n      height: 630,\n      headers: {\n        \"Cache-Control\": \"public, max-age=3600, immutable\",\n      },\n      fonts: [\n        {\n          name: \"Inter\",\n          data: inter,\n          weight: 400,\n          style: \"normal\",\n        },\n        {\n          name: \"Inter\",\n          data: interBold,\n          weight: 700,\n          style: \"normal\",\n        },\n      ],\n    },\n  );\n}\n"
  },
  {
    "path": "app/api/verify/login-link/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\n// Legacy magic link route - redirect to new code-based flow\nexport async function GET(request: NextRequest) {\n  return NextResponse.redirect(new URL(\"/auth/email\", request.url));\n}\n"
  },
  {
    "path": "app/api/views/pages/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { z } from \"zod\";\n\nimport { verifyDataroomSession } from \"@/lib/auth/dataroom-auth\";\nimport { getFile } from \"@/lib/files/get-file\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { log } from \"@/lib/utils\";\n\nconst MAX_PAGES_PER_REQUEST = 15;\nconst VIEW_MAX_AGE_MS = 23 * 60 * 60 * 1000; // 23 hours\n\nconst requestSchema = z.object({\n  viewId: z.string().cuid(),\n  documentVersionId: z.string().cuid(),\n  pageNumbers: z\n    .array(z.number().int().positive())\n    .min(1)\n    .max(MAX_PAGES_PER_REQUEST),\n});\n\nexport async function POST(request: NextRequest) {\n  try {\n    const body = await request.json();\n\n    const parsed = requestSchema.safeParse(body);\n    if (!parsed.success) {\n      return NextResponse.json(\n        { message: \"Invalid request.\" },\n        { status: 400 },\n      );\n    }\n\n    const { viewId, documentVersionId, pageNumbers } = parsed.data;\n\n    // Run rate limit, view lookup, and version lookup in parallel\n    const [rateLimitResult, view, documentVersion] = await Promise.all([\n      ratelimit(60, \"1 m\").limit(`view-pages:${viewId}`),\n      prisma.view.findUnique({\n        where: { id: viewId },\n        select: {\n          id: true,\n          documentId: true,\n          dataroomId: true,\n          linkId: true,\n          viewedAt: true,\n        },\n      }),\n      prisma.documentVersion.findUnique({\n        where: { id: documentVersionId },\n        select: { documentId: true },\n      }),\n    ]);\n\n    if (!rateLimitResult.success) {\n      return NextResponse.json(\n        { message: \"Too many requests. Please try again later.\" },\n        { status: 429 },\n      );\n    }\n\n    if (!view) {\n      return NextResponse.json({ message: \"View not found.\" }, { status: 404 });\n    }\n\n    if (Date.now() - view.viewedAt.getTime() > VIEW_MAX_AGE_MS) {\n      return NextResponse.json(\n        { message: \"View session expired.\" },\n        { status: 401 },\n      );\n    }\n\n    if (!documentVersion || documentVersion.documentId !== view.documentId) {\n      return NextResponse.json(\n        { message: \"Unauthorized access.\" },\n        { status: 403 },\n      );\n    }\n\n    // Validate dataroom session for dataroom document views\n    if (view.dataroomId && view.linkId) {\n      const session = await verifyDataroomSession(\n        request,\n        view.linkId,\n        view.dataroomId,\n      );\n      if (!session) {\n        return NextResponse.json(\n          { message: \"Invalid or expired session.\" },\n          { status: 401 },\n        );\n      }\n    }\n\n    const documentPages = await prisma.documentPage.findMany({\n      where: {\n        versionId: documentVersionId,\n        pageNumber: { in: pageNumbers },\n      },\n      select: {\n        file: true,\n        storageType: true,\n        pageNumber: true,\n      },\n    });\n\n    const pagesWithUrls = await Promise.all(\n      documentPages.map(async (page) => {\n        const { storageType, ...otherPage } = page;\n        return {\n          pageNumber: otherPage.pageNumber,\n          file: await getFile({ data: page.file, type: storageType }),\n        };\n      }),\n    );\n\n    return NextResponse.json({ pages: pagesWithUrls });\n  } catch (error) {\n    log({\n      message: `Failed to fetch page URLs. \\n\\n ${error}`,\n      type: \"error\",\n    });\n    return NextResponse.json(\n      { message: (error as Error).message },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/views/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { reportDeniedAccessAttempt } from \"@/ee/features/access-notifications\";\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\n// Import authOptions directly from the source\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { ipAddress, waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport { verifyPreviewSession } from \"@/lib/auth/preview-auth\";\nimport { PreviewSession } from \"@/lib/auth/preview-auth\";\nimport { sendOtpVerificationEmail } from \"@/lib/emails/send-email-otp-verification\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport { getFile } from \"@/lib/files/get-file\";\nimport { newId } from \"@/lib/id-helper\";\nimport { notifyDocumentView } from \"@/lib/integrations/slack/events\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { parseSheet } from \"@/lib/sheet\";\nimport { recordLinkView } from \"@/lib/tracking/record-link-view\";\nimport { CustomUser, WatermarkConfigSchema } from \"@/lib/types\";\nimport { checkPassword, decryptEncrpytedPassword, log } from \"@/lib/utils\";\nimport { isEmailMatched } from \"@/lib/utils/email-domain\";\nimport { generateOTP } from \"@/lib/utils/generate-otp\";\nimport { LOCALHOST_IP } from \"@/lib/utils/geo\";\nimport { checkGlobalBlockList } from \"@/lib/utils/global-block-list\";\nimport { validateEmail } from \"@/lib/utils/validate-email\";\n\nexport async function POST(request: NextRequest) {\n  try {\n    const body = await request.json();\n\n    // POST /api/views\n    const {\n      linkId,\n      documentId,\n      userId,\n      documentVersionId,\n      documentName,\n      hasPages,\n      ownerId,\n      startPage,\n      ...data\n    } = body as {\n      linkId: string;\n      documentId: string;\n      userId: string | null;\n      documentVersionId: string;\n      documentName: string;\n      hasPages: boolean;\n      ownerId: string;\n      startPage?: number;\n    };\n\n    const { email, password, name, hasConfirmedAgreement } = data as {\n      email: string;\n      password: string;\n      name?: string;\n      hasConfirmedAgreement?: boolean;\n    };\n\n    // Add customFields to the data extraction\n    const { customFields } = data as {\n      customFields?: { [key: string]: string };\n    };\n\n    // INFO: for using the advanced excel viewer\n    const { useAdvancedExcelViewer } = data as {\n      useAdvancedExcelViewer: boolean;\n    };\n\n    // previewToken is used to determine if the view is a preview and therefore should not be recorded\n    const { previewToken } = data as {\n      previewToken?: string;\n    };\n\n    // Email Verification Data\n    const { code, token, verifiedEmail } = data as {\n      code?: string;\n      token?: string;\n      verifiedEmail?: string;\n    };\n\n    // Fetch the link to verify the settings\n    const link = await prisma.link.findUnique({\n      where: {\n        id: linkId,\n      },\n      select: {\n        id: true,\n        name: true,\n        documentId: true,\n        emailProtected: true,\n        enableNotification: true,\n        emailAuthenticated: true,\n        password: true,\n        domainSlug: true,\n        isArchived: true,\n        deletedAt: true,\n        slug: true,\n        allowList: true,\n        denyList: true,\n        enableAgreement: true,\n        agreementId: true,\n        enableWatermark: true,\n        watermarkConfig: true,\n        teamId: true,\n        team: {\n          select: {\n            plan: true,\n            globalBlockList: true,\n            agentsEnabled: true,\n            pauseStartsAt: true,\n          },\n        },\n        customFields: {\n          select: {\n            identifier: true,\n            label: true,\n          },\n        },\n        document: {\n          select: {\n            agentsEnabled: true,\n          },\n        },\n        visitorGroups: {\n          select: {\n            visitorGroup: {\n              select: {\n                emails: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    // Check if link exists\n    if (!link) {\n      return NextResponse.json({ message: \"Link not found.\" }, { status: 404 });\n    }\n\n    // Check if link is archived\n    if (link.isArchived) {\n      return NextResponse.json(\n        { message: \"Link is no longer available.\" },\n        { status: 404 },\n      );\n    }\n\n    if (link.deletedAt) {\n      return NextResponse.json(\n        { message: \"Link has been deleted.\" },\n        { status: 404 },\n      );\n    }\n\n    let isEmailVerified: boolean = false;\n    let hashedVerificationToken: string | null = null;\n    // Check if the user is part of the team and therefore skip verification steps\n    let isTeamMember: boolean = false;\n    let isPreview: boolean = false;\n    if (userId && previewToken) {\n      const session = await getServerSession(authOptions);\n      if (!session) {\n        return NextResponse.json(\n          { message: \"You need to be logged in to preview the link.\" },\n          { status: 401 },\n        );\n      }\n\n      const sessionUserId = (session.user as CustomUser).id;\n      const teamMembership = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: sessionUserId,\n            teamId: link.teamId!,\n          },\n        },\n      });\n      if (teamMembership) {\n        isTeamMember = true;\n        isPreview = true;\n        isEmailVerified = true;\n      }\n    }\n\n    if (!isTeamMember) {\n      // Check if email is required for visiting the link\n      if (link.emailProtected) {\n        if (!email || email.trim() === \"\") {\n          return NextResponse.json(\n            { message: \"Email is required.\" },\n            { status: 400 },\n          );\n        }\n\n        // validate email\n        if (!validateEmail(email)) {\n          return NextResponse.json(\n            { message: \"Invalid email address.\" },\n            { status: 400 },\n          );\n        }\n      }\n\n      // Check if password is required for visiting the link\n      if (link.password) {\n        if (!password || password.trim() === \"\") {\n          return NextResponse.json(\n            { message: \"Password is required.\" },\n            { status: 400 },\n          );\n        }\n\n        let isPasswordValid: boolean = false;\n        const textParts: string[] = link.password.split(\":\");\n        if (!textParts || textParts.length !== 2) {\n          isPasswordValid = await checkPassword(password, link.password);\n        } else {\n          const decryptedPassword = decryptEncrpytedPassword(link.password);\n          isPasswordValid = decryptedPassword === password;\n        }\n\n        if (!isPasswordValid) {\n          return NextResponse.json(\n            { message: \"Invalid password.\" },\n            { status: 403 },\n          );\n        }\n      }\n\n      // Check if agreement is required for visiting the link\n      if (link.enableAgreement && !hasConfirmedAgreement) {\n        return NextResponse.json(\n          { message: \"Agreement to NDA is required.\" },\n          { status: 400 },\n        );\n      }\n\n      // Check global block list first - this overrides all other access controls\n      const globalBlockCheck = checkGlobalBlockList(\n        email,\n        link.team?.globalBlockList,\n      );\n      if (globalBlockCheck.error) {\n        return NextResponse.json(\n          { message: globalBlockCheck.error },\n          { status: 400 },\n        );\n      }\n      if (globalBlockCheck.isBlocked) {\n        waitUntil(reportDeniedAccessAttempt(link, email, \"global\"));\n\n        return NextResponse.json({ message: \"Access denied\" }, { status: 403 });\n      }\n\n      // Build combined allow list from individual emails + visitor groups\n      const visitorGroupEmails =\n        link.visitorGroups?.flatMap((vg) => vg.visitorGroup.emails) || [];\n      const combinedAllowList = [\n        ...(link.allowList || []),\n        ...visitorGroupEmails,\n      ];\n\n      // Check if email is allowed to visit the link\n      if (combinedAllowList.length > 0) {\n        // Determine if the email or its domain is allowed\n        const isAllowed = combinedAllowList.some((allowed) =>\n          isEmailMatched(email, allowed),\n        );\n\n        // Deny access if the email is not allowed\n        if (!isAllowed) {\n          waitUntil(reportDeniedAccessAttempt(link, email, \"allow\"));\n\n          return NextResponse.json(\n            { message: \"Unauthorized access\" },\n            { status: 403 },\n          );\n        }\n      }\n\n      // Check if email is denied to visit the link\n      if (link.denyList && link.denyList.length > 0) {\n        // Determine if the email or its domain is denied\n        const isDenied = link.denyList.some((denied) =>\n          isEmailMatched(email, denied),\n        );\n\n        // Deny access if the email is denied\n        if (isDenied) {\n          waitUntil(reportDeniedAccessAttempt(link, email, \"deny\"));\n\n          return NextResponse.json(\n            { message: \"Unauthorized access\" },\n            { status: 403 },\n          );\n        }\n      }\n\n      // Request OTP Code for email verification if\n      // 1) email verification is required and\n      // 2) code is not provided or token not provided\n      if (link.emailAuthenticated && !code && !token) {\n        const ipAddressValue = ipAddress(request);\n\n        // Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding\n        const { success: emailLimitSuccess } = await ratelimit(1, \"30 s\").limit(\n          `send-otp:${linkId}:${email}`,\n        );\n        if (!emailLimitSuccess) {\n          return NextResponse.json(\n            {\n              message:\n                \"Please wait before requesting another code. Try again in 30 seconds.\",\n            },\n            { status: 429 },\n          );\n        }\n\n        // Additional IP-based rate limit (10 per minute) to prevent abuse across different emails\n        const { success } = await ratelimit(10, \"1 m\").limit(\n          `send-otp:${ipAddressValue}`,\n        );\n        if (!success) {\n          return NextResponse.json(\n            { message: \"Too many requests. Please try again later.\" },\n            { status: 429 },\n          );\n        }\n\n        await prisma.verificationToken.deleteMany({\n          where: {\n            identifier: `otp:${linkId}:${email}`,\n          },\n        });\n\n        const otpCode = generateOTP();\n        const expiresAt = new Date();\n        expiresAt.setMinutes(expiresAt.getMinutes() + 10); // token expires at 10 minutes\n\n        await prisma.verificationToken.create({\n          data: {\n            token: otpCode,\n            identifier: `otp:${linkId}:${email}`,\n            expires: expiresAt,\n          },\n        });\n\n        waitUntil(\n          sendOtpVerificationEmail(email, otpCode, false, link.teamId!),\n        );\n        return NextResponse.json({\n          type: \"email-verification\",\n          message: \"Verification email sent.\",\n        });\n      }\n\n      if (link.emailAuthenticated && code) {\n        const ipAddressValue = ipAddress(request);\n        const { success } = await ratelimit(10, \"1 m\").limit(\n          `verify-otp:${ipAddressValue}`,\n        );\n        if (!success) {\n          return NextResponse.json(\n            { message: \"Too many requests. Please try again later.\" },\n            { status: 429 },\n          );\n        }\n\n        // Check if the OTP code is valid\n        const verification = await prisma.verificationToken.findUnique({\n          where: {\n            token: code,\n            identifier: `otp:${linkId}:${email}`,\n          },\n        });\n\n        if (!verification) {\n          return NextResponse.json(\n            {\n              message: \"Unauthorized access. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        // Check the OTP code's expiration date\n        if (Date.now() > verification.expires.getTime()) {\n          await prisma.verificationToken.delete({\n            where: {\n              token: code,\n            },\n          });\n          return NextResponse.json(\n            {\n              message: \"Access expired. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        // delete the OTP code after verification\n        await prisma.verificationToken.delete({\n          where: {\n            token: code,\n          },\n        });\n\n        // Create a email verification token for repeat access\n        const token = newId(\"email\");\n        hashedVerificationToken = hashToken(token);\n        const tokenExpiresAt = new Date();\n        tokenExpiresAt.setHours(tokenExpiresAt.getHours() + 23); // token expires at 23 hours\n        await prisma.verificationToken.create({\n          data: {\n            token: hashedVerificationToken,\n            identifier: `link-verification:${linkId}:${link.teamId}:${email}`,\n            expires: tokenExpiresAt,\n          },\n        });\n\n        isEmailVerified = true;\n      }\n\n      if (link.emailAuthenticated && token) {\n        const ipAddressValue = ipAddress(request);\n        const { success } = await ratelimit(10, \"1 m\").limit(\n          `verify-email:${ipAddressValue}`,\n        );\n        if (!success) {\n          return NextResponse.json(\n            { message: \"Too many requests. Please try again later.\" },\n            { status: 429 },\n          );\n        }\n\n        // Check if the long-term verification token is valid\n        const verification = await prisma.verificationToken.findUnique({\n          where: {\n            token: token,\n            identifier: `link-verification:${linkId}:${link.teamId}:${email}`,\n          },\n        });\n\n        if (!verification) {\n          return NextResponse.json(\n            {\n              message: \"Unauthorized access. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        // Check the long-term verification token's expiration date\n        if (Date.now() > verification.expires.getTime()) {\n          // delete the long-term verification token after verification\n          await prisma.verificationToken.delete({\n            where: {\n              token: token,\n            },\n          });\n          return NextResponse.json(\n            {\n              message: \"Access expired. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        isEmailVerified = true;\n      }\n    }\n\n    // Check if there's a valid preview session\n    let previewSession: PreviewSession | null = null;\n    if (!isPreview && previewToken) {\n      const session = await getServerSession(authOptions);\n      if (!session) {\n        return NextResponse.json(\n          { message: \"You need to be logged in to preview the link.\" },\n          { status: 401 },\n        );\n      }\n      previewSession = await verifyPreviewSession(\n        previewToken,\n        (session.user as CustomUser).id,\n        linkId,\n      );\n\n      console.log(\"previewSession\", previewSession);\n      if (!previewSession) {\n        return NextResponse.json(\n          {\n            message: \"Preview session expired or invalid. Request a new one.\",\n            resetPreview: true,\n          },\n          { status: 401 },\n        );\n      }\n      isPreview = true;\n    }\n\n    try {\n      let viewer: { id: string; verified: boolean } | null = null;\n      if (email && !isPreview) {\n        // find or create a viewer\n        console.time(\"find-viewer\");\n        viewer = await prisma.viewer.findUnique({\n          where: {\n            teamId_email: {\n              teamId: link.teamId!,\n              email: email,\n            },\n          },\n          select: { id: true, verified: true },\n        });\n        console.timeEnd(\"find-viewer\");\n\n        if (!viewer) {\n          console.time(\"create-viewer\");\n          viewer = await prisma.viewer.create({\n            data: {\n              email: email,\n              verified: isEmailVerified,\n              teamId: link.teamId!,\n            },\n            select: { id: true, verified: true },\n          });\n          console.timeEnd(\"create-viewer\");\n        } else if (!viewer.verified && isEmailVerified) {\n          await prisma.viewer.update({\n            where: { id: viewer.id },\n            data: { verified: isEmailVerified },\n          });\n        }\n      }\n\n      let newView: { id: string } | null = null;\n      if (!isPreview) {\n        console.time(\"create-view\");\n        newView = await prisma.view.create({\n          data: {\n            linkId: linkId,\n            viewerEmail: email,\n            viewerName: name,\n            documentId: documentId,\n            teamId: link.teamId!,\n            viewerId: viewer?.id ?? undefined,\n            verified: isEmailVerified,\n            ...(link.enableAgreement &&\n              link.agreementId &&\n              hasConfirmedAgreement && {\n                agreementResponse: {\n                  create: {\n                    agreementId: link.agreementId,\n                  },\n                },\n              }),\n            ...(customFields &&\n              link.customFields.length > 0 && {\n                customFieldResponse: {\n                  create: {\n                    data: link.customFields.map((field) => ({\n                      identifier: field.identifier,\n                      label: field.label,\n                      response: customFields[field.identifier] || \"\",\n                    })),\n                  },\n                },\n              }),\n          },\n          select: { id: true },\n        });\n        console.timeEnd(\"create-view\");\n      }\n\n      // if document version has pages, then return pages\n      // otherwise, return file from document version\n      let documentPages, documentVersion;\n      let sheetData;\n      const INITIAL_PAGES_TO_LOAD = 10;\n      // let documentPagesPromise, documentVersionPromise;\n      if (hasPages) {\n        const featureFlags = await getFeatureFlags({\n          teamId: link.teamId!,\n        });\n        const inDocumentLinks =\n          !link.team?.plan.includes(\"free\") || featureFlags.inDocumentLinks;\n\n        // get pages from document version\n        console.time(\"get-pages\");\n        documentPages = await prisma.documentPage.findMany({\n          where: { versionId: documentVersionId },\n          orderBy: { pageNumber: \"asc\" },\n          select: {\n            file: true,\n            storageType: true,\n            pageNumber: true,\n            embeddedLinks: inDocumentLinks,\n            pageLinks: inDocumentLinks,\n            metadata: true,\n          },\n        });\n\n        // Sign URLs for pages around the requested start page (or page 1 by default).\n        // Remaining page URLs are fetched on-demand by the client via /api/views/pages.\n        const centerIndex = Math.min(\n          Math.max(0, (startPage ?? 1) - 1),\n          Math.max(0, documentPages.length - 1),\n        );\n        const halfWindow = Math.floor(INITIAL_PAGES_TO_LOAD / 2);\n        const signStart = Math.max(0, centerIndex - halfWindow);\n        const signEnd = Math.min(\n          documentPages.length,\n          signStart + INITIAL_PAGES_TO_LOAD,\n        );\n\n        documentPages = await Promise.all(\n          documentPages.map(async (page, index) => {\n            const { storageType, ...otherPage } = page;\n            return {\n              ...otherPage,\n              file:\n                index >= signStart && index < signEnd\n                  ? await getFile({ data: page.file, type: storageType })\n                  : null,\n            };\n          }),\n        );\n\n        console.timeEnd(\"get-pages\");\n      } else {\n        // get file from document version\n        console.time(\"get-file\");\n        documentVersion = await prisma.documentVersion.findUnique({\n          where: { id: documentVersionId },\n          select: {\n            file: true,\n            storageType: true,\n            type: true,\n          },\n        });\n\n        if (!documentVersion) {\n          return NextResponse.json(\n            { message: \"Document version not found.\" },\n            { status: 404 },\n          );\n        }\n\n        if (\n          documentVersion.type === \"pdf\" ||\n          documentVersion.type === \"image\" ||\n          documentVersion.type === \"video\"\n        ) {\n          documentVersion.file = await getFile({\n            data: documentVersion.file,\n            type: documentVersion.storageType,\n          });\n        }\n\n        if (documentVersion.type === \"sheet\") {\n          if (useAdvancedExcelViewer) {\n            if (!documentVersion.file.includes(\"https://\")) {\n              // Get team-specific storage config for advanced distribution host\n              const storageConfig = await getTeamStorageConfigById(\n                link.teamId!,\n              );\n              documentVersion.file = `https://${storageConfig.advancedDistributionHost}/${documentVersion.file}`;\n            }\n          } else {\n            const fileUrl = await getFile({\n              data: documentVersion.file,\n              type: documentVersion.storageType,\n            });\n\n            const data = await parseSheet({ fileUrl });\n            sheetData = data;\n          }\n        }\n        console.timeEnd(\"get-file\");\n      }\n\n      const isPaused =\n        link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()\n          ? true\n          : false;\n\n      if (newView) {\n        // Record view in the background to avoid blocking the response\n        waitUntil(\n          // Record link view in Tinybird\n          recordLinkView({\n            req: request,\n            clickId: newId(\"linkView\"),\n            viewId: newView.id,\n            linkId,\n            documentId,\n            teamId: link.teamId!,\n            enableNotification: link.enableNotification,\n            isPaused,\n          }),\n        );\n        if (!isPreview) {\n          waitUntil(\n            notifyDocumentView({\n              teamId: link.teamId!,\n              documentId,\n              linkId,\n              viewerEmail: email ?? undefined,\n              viewerId: viewer?.id ?? undefined,\n              teamIsPaused: isPaused,\n            }).catch((error) => {\n              console.error(\"Error sending Slack notification:\", error);\n            }),\n          );\n        }\n      }\n\n      // Determine if AI agents should be enabled (requires both team and document level)\n      const agentsEnabled =\n        link.team?.agentsEnabled && link.document?.agentsEnabled;\n\n      const returnObject = {\n        message: \"View recorded\",\n        viewId: !isPreview && newView ? newView.id : undefined,\n        viewerId: viewer?.id ?? undefined,\n        isPreview: isPreview ? true : undefined,\n        file:\n          (documentVersion &&\n            (documentVersion.type === \"pdf\" ||\n              documentVersion.type === \"image\" ||\n              documentVersion.type === \"zip\" ||\n              documentVersion.type === \"video\" ||\n              documentVersion.type === \"link\")) ||\n          (documentVersion && useAdvancedExcelViewer)\n            ? documentVersion.file\n            : undefined,\n        pages: documentPages ? documentPages : undefined,\n        sheetData:\n          documentVersion &&\n          documentVersion.type === \"sheet\" &&\n          !useAdvancedExcelViewer\n            ? sheetData\n            : undefined,\n        fileType: documentVersion\n          ? documentVersion.type\n          : documentPages\n            ? \"pdf\"\n            : undefined,\n        watermarkConfig: link.enableWatermark\n          ? link.watermarkConfig\n          : undefined,\n        ipAddress:\n          link.enableWatermark &&\n          link.watermarkConfig &&\n          WatermarkConfigSchema.parse(link.watermarkConfig).text.includes(\n            \"{{ipAddress}}\",\n          )\n            ? process.env.VERCEL === \"1\"\n              ? ipAddress(request)\n              : LOCALHOST_IP\n            : undefined,\n        verificationToken: hashedVerificationToken ?? undefined,\n        ...(isTeamMember && { isTeamMember: true }),\n        ...(agentsEnabled && { agentsEnabled: true }),\n      };\n\n      return NextResponse.json(returnObject);\n    } catch (error) {\n      log({\n        message: `Failed to record view for ${linkId}. \\n\\n ${error}`,\n        type: \"error\",\n        mention: true,\n      });\n      return NextResponse.json(\n        { message: (error as Error).message },\n        { status: 500 },\n      );\n    }\n  } catch (error) {\n    log({\n      message: `Failed to process request. \\n\\n ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    return NextResponse.json(\n      { message: (error as Error).message },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/views-dataroom/route.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { reportDeniedAccessAttempt } from \"@/ee/features/access-notifications\";\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { ItemType, LinkAudienceType } from \"@prisma/client\";\nimport { ipAddress, waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport {\n  DataroomSession,\n  collectFingerprintHeaders,\n  createDataroomSession,\n  generateSessionFingerprint,\n} from \"@/lib/auth/dataroom-auth\";\nimport { verifyDataroomSession } from \"@/lib/auth/dataroom-auth\";\nimport { PreviewSession, verifyPreviewSession } from \"@/lib/auth/preview-auth\";\nimport { sendOtpVerificationEmail } from \"@/lib/emails/send-email-otp-verification\";\nimport { getFile } from \"@/lib/files/get-file\";\nimport { newId } from \"@/lib/id-helper\";\nimport {\n  notifyDataroomAccess,\n  notifyDocumentView,\n} from \"@/lib/integrations/slack/events\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { parseSheet } from \"@/lib/sheet\";\nimport { recordLinkView } from \"@/lib/tracking/record-link-view\";\nimport { CustomUser, WatermarkConfigSchema } from \"@/lib/types\";\nimport { checkPassword, decryptEncrpytedPassword, log } from \"@/lib/utils\";\nimport { extractEmailDomain, isEmailMatched } from \"@/lib/utils/email-domain\";\nimport { generateOTP } from \"@/lib/utils/generate-otp\";\nimport { LOCALHOST_IP } from \"@/lib/utils/geo\";\nimport { checkGlobalBlockList } from \"@/lib/utils/global-block-list\";\nimport { validateEmail } from \"@/lib/utils/validate-email\";\n\nexport async function POST(request: NextRequest) {\n  try {\n    const body = await request.json();\n\n    const {\n      linkId,\n      documentId,\n      dataroomId,\n      userId,\n      documentVersionId,\n      documentName,\n      hasPages,\n      ownerId,\n      linkType,\n      dataroomViewId,\n      viewType,\n      groupId,\n      startPage,\n      ...data\n    } = body as {\n      linkId: string;\n      documentId: string | undefined;\n      dataroomId: string;\n      userId: string | null;\n      documentVersionId: string | undefined;\n      documentName: string | undefined;\n      hasPages: boolean | undefined;\n      ownerId: string | null;\n      linkType: string;\n      dataroomViewId?: string;\n      viewType: \"DATAROOM_VIEW\" | \"DOCUMENT_VIEW\";\n      groupId?: string;\n      startPage?: number;\n    };\n\n    const { email, password, name, hasConfirmedAgreement } = data as {\n      email: string;\n      password: string;\n      name?: string;\n      hasConfirmedAgreement?: boolean;\n    };\n\n    // Add customFields to the data extraction\n    const { customFields } = data as {\n      customFields?: { [key: string]: string };\n    };\n\n    // INFO: for using the advanced excel viewer\n    let { useAdvancedExcelViewer } = data as {\n      useAdvancedExcelViewer: boolean;\n    };\n\n    // previewToken is used to determine if the view is a preview and therefore should not be recorded\n    const { previewToken } = data as {\n      previewToken?: string;\n    };\n\n    // Email Verification Data\n    const { code, token, verifiedEmail } = data as {\n      code?: string;\n      token?: string;\n      verifiedEmail?: string;\n    };\n\n    // Fetch the link to verify the settings\n    const link = await prisma.link.findUnique({\n      where: {\n        id: linkId,\n      },\n      select: {\n        id: true,\n        name: true,\n        documentId: true,\n        dataroomId: true,\n        emailProtected: true,\n        enableNotification: true,\n        emailAuthenticated: true,\n        password: true,\n        domainSlug: true,\n        isArchived: true,\n        deletedAt: true,\n        slug: true,\n        domainId: true,\n        allowList: true,\n        denyList: true,\n        enableAgreement: true,\n        agreementId: true,\n        enableWatermark: true,\n        watermarkConfig: true,\n        groupId: true,\n        permissionGroupId: true,\n        audienceType: true,\n        allowDownload: true,\n        enableConversation: true,\n        teamId: true,\n        team: {\n          select: {\n            plan: true,\n            globalBlockList: true,\n            agentsEnabled: true,\n            pauseStartsAt: true,\n          },\n        },\n        customFields: {\n          select: {\n            identifier: true,\n            label: true,\n          },\n        },\n        enableUpload: true,\n        dataroom: {\n          select: {\n            agentsEnabled: true,\n            name: true,\n          },\n        },\n        visitorGroups: {\n          select: {\n            visitorGroup: {\n              select: {\n                emails: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!link) {\n      return NextResponse.json({ message: \"Link not found.\" }, { status: 404 });\n    }\n\n    if (link.isArchived) {\n      return NextResponse.json(\n        { message: \"Link is no longer available.\" },\n        { status: 404 },\n      );\n    }\n\n    if (link.deletedAt) {\n      return NextResponse.json(\n        { message: \"Link has been deleted.\" },\n        { status: 404 },\n      );\n    }\n\n    let isEmailVerified: boolean = false;\n    let hashedVerificationToken: string | null = null;\n    // Check if the user is part of the team and therefore skip verification steps\n    let isTeamMember: boolean = false;\n    let isPreview: boolean = false;\n    if (userId && previewToken) {\n      const session = await getServerSession(authOptions);\n      if (!session) {\n        return NextResponse.json(\n          { message: \"You need to be logged in to preview the link.\" },\n          { status: 401 },\n        );\n      }\n\n      const sessionUserId = (session.user as CustomUser).id;\n      const teamMembership = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: sessionUserId,\n            teamId: link.teamId!,\n          },\n        },\n      });\n      if (teamMembership) {\n        isTeamMember = true;\n        isPreview = true;\n        isEmailVerified = true;\n      }\n    }\n\n    // Check if there's a valid preview session\n    let previewSession: PreviewSession | null = null;\n    if (!isPreview && previewToken) {\n      const session = await getServerSession(authOptions);\n      if (!session) {\n        return NextResponse.json(\n          { message: \"You need to be logged in to preview the link.\" },\n          { status: 401 },\n        );\n      }\n      previewSession = await verifyPreviewSession(\n        previewToken,\n        (session.user as CustomUser).id,\n        linkId,\n      );\n\n      if (!previewSession) {\n        return NextResponse.json(\n          {\n            message: \"Preview session expired or invalid. Request a new one.\",\n            resetPreview: true,\n          },\n          { status: 401 },\n        );\n      }\n      isPreview = true;\n    }\n\n    let dataroomSession: DataroomSession | null = null;\n    if (!isPreview) {\n      dataroomSession = await verifyDataroomSession(\n        request,\n        linkId,\n        link.dataroomId!,\n      );\n\n\n      // If we have a dataroom session, use its verified status\n      if (dataroomSession) {\n        isEmailVerified = dataroomSession.verified;\n      }\n    }\n\n    // If there is no session, then we need to check if the link is protected and enforce the checks\n    if (!dataroomSession && !isPreview) {\n      // Check if email is required for visiting the link\n      if (link.emailProtected) {\n        if (!email || email.trim() === \"\") {\n          return NextResponse.json(\n            { message: \"Email is required.\" },\n            { status: 400 },\n          );\n        }\n\n        // validate email\n        if (!validateEmail(email)) {\n          return NextResponse.json(\n            { message: \"Invalid email address.\" },\n            { status: 400 },\n          );\n        }\n      }\n\n      // Check if password is required for visiting the link\n      if (link.password) {\n        if (!password || password.trim() === \"\") {\n          return NextResponse.json(\n            { message: \"Password is required.\" },\n            { status: 400 },\n          );\n        }\n\n        let isPasswordValid: boolean = false;\n        const textParts: string[] = link.password.split(\":\");\n        if (!textParts || textParts.length !== 2) {\n          isPasswordValid = await checkPassword(password, link.password);\n        } else {\n          const decryptedPassword = decryptEncrpytedPassword(link.password);\n          isPasswordValid = decryptedPassword === password;\n        }\n\n        if (!isPasswordValid) {\n          return NextResponse.json(\n            { message: \"Invalid password.\" },\n            { status: 403 },\n          );\n        }\n      }\n\n      // Check if agreement is required for visiting the link\n      if (link.enableAgreement && !hasConfirmedAgreement) {\n        return NextResponse.json(\n          { message: \"Agreement to NDA is required.\" },\n          { status: 400 },\n        );\n      }\n\n      // Check global block list first - this overrides all other access controls\n      const globalBlockCheck = checkGlobalBlockList(\n        email,\n        link.team?.globalBlockList,\n      );\n      if (globalBlockCheck.error) {\n        return NextResponse.json(\n          { message: globalBlockCheck.error },\n          { status: 400 },\n        );\n      }\n      if (globalBlockCheck.isBlocked) {\n        waitUntil(reportDeniedAccessAttempt(link, email, \"global\"));\n\n        return NextResponse.json({ message: \"Access denied\" }, { status: 403 });\n      }\n\n      // Build combined allow list from individual emails + visitor groups\n      const visitorGroupEmails =\n        link.visitorGroups?.flatMap((vg) => vg.visitorGroup.emails) || [];\n      const combinedAllowList = [\n        ...(link.allowList || []),\n        ...visitorGroupEmails,\n      ];\n\n      // Check if email is allowed to visit the link\n      if (combinedAllowList.length > 0) {\n        // Determine if the email or its domain is allowed\n        const isAllowed = combinedAllowList.some((allowed) =>\n          isEmailMatched(email, allowed),\n        );\n\n        // Deny access if the email is not allowed\n        if (!isAllowed) {\n          waitUntil(reportDeniedAccessAttempt(link, email, \"allow\"));\n\n          return NextResponse.json(\n            { message: \"Unauthorized access\" },\n            { status: 403 },\n          );\n        }\n      }\n\n      // Check if email is denied to visit the link\n      if (link.denyList && link.denyList.length > 0) {\n        // Determine if the email or its domain is denied\n        const isDenied = link.denyList.some((denied) =>\n          isEmailMatched(email, denied),\n        );\n\n        // Deny access if the email is denied\n        if (isDenied) {\n          waitUntil(reportDeniedAccessAttempt(link, email, \"deny\"));\n\n          return NextResponse.json(\n            { message: \"Unauthorized access\" },\n            { status: 403 },\n          );\n        }\n      }\n\n      // Check if group is allowed to visit the link\n      if (link.audienceType === LinkAudienceType.GROUP && link.groupId) {\n        const group = await prisma.viewerGroup.findUnique({\n          where: { id: link.groupId },\n          select: {\n            members: { include: { viewer: { select: { email: true } } } },\n            domains: true,\n            allowAll: true,\n          },\n        });\n\n        if (!group) {\n          return NextResponse.json(\n            { message: \"Group not found.\" },\n            { status: 404 },\n          );\n        }\n\n        // Check if all emails are allowed\n        if (group.allowAll) {\n          // Allow access\n        } else {\n          // Check individual membership\n          const isMember = group.members.some(\n            (member) => member.viewer.email === email,\n          );\n\n          // Extract domain from email\n          const emailDomain = extractEmailDomain(email);\n          // Check domain access\n          const hasDomainAccess = emailDomain\n            ? group.domains.some((domain) => domain === emailDomain)\n            : false;\n\n          if (!isMember && !hasDomainAccess) {\n            waitUntil(reportDeniedAccessAttempt(link, email, \"allow\"));\n            return NextResponse.json(\n              { message: \"Unauthorized access\" },\n              { status: 403 },\n            );\n          }\n        }\n      }\n\n      // Request OTP Code for email verification if\n      // 1) email verification is required and\n      // 2) code is not provided or token not provided\n      if (link.emailAuthenticated && !code && !token) {\n        const ipAddressValue = ipAddress(request);\n\n        // Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding\n        const { success: emailLimitSuccess } = await ratelimit(1, \"30 s\").limit(\n          `send-otp:${linkId}:${email}`,\n        );\n        if (!emailLimitSuccess) {\n          return NextResponse.json(\n            {\n              message:\n                \"Please wait before requesting another code. Try again in 30 seconds.\",\n            },\n            { status: 429 },\n          );\n        }\n\n        // Additional IP-based rate limit (10 per minute) to prevent abuse across different emails\n        const { success } = await ratelimit(10, \"1 m\").limit(\n          `send-otp:${ipAddressValue}`,\n        );\n        if (!success) {\n          return NextResponse.json(\n            { message: \"Too many requests. Please try again later.\" },\n            { status: 429 },\n          );\n        }\n\n        await prisma.verificationToken.deleteMany({\n          where: {\n            identifier: `otp:${linkId}:${email}`,\n          },\n        });\n\n        const otpCode = generateOTP();\n        const expiresAt = new Date();\n        expiresAt.setMinutes(expiresAt.getMinutes() + 10); // token expires at 10 minutes\n\n        await prisma.verificationToken.create({\n          data: {\n            token: otpCode,\n            identifier: `otp:${linkId}:${email}`,\n            expires: expiresAt,\n          },\n        });\n\n        waitUntil(sendOtpVerificationEmail(email, otpCode, true, link.teamId!));\n        return NextResponse.json(\n          {\n            type: \"email-verification\",\n            message: \"Verification email sent.\",\n          },\n          { status: 200 },\n        );\n      }\n\n      if (link.emailAuthenticated && code) {\n        const ipAddressValue = ipAddress(request);\n        const { success } = await ratelimit(10, \"1 m\").limit(\n          `verify-otp:${ipAddressValue}`,\n        );\n        if (!success) {\n          return NextResponse.json(\n            { message: \"Too many requests. Please try again later.\" },\n            { status: 429 },\n          );\n        }\n\n        // Check if the OTP code is valid\n        const verification = await prisma.verificationToken.findUnique({\n          where: {\n            token: code,\n            identifier: `otp:${linkId}:${email}`,\n          },\n        });\n\n        if (!verification) {\n          return NextResponse.json(\n            {\n              message: \"Unauthorized access. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        // Check the OTP code's expiration date\n        if (Date.now() > verification.expires.getTime()) {\n          await prisma.verificationToken.delete({\n            where: {\n              token: code,\n            },\n          });\n          return NextResponse.json(\n            {\n              message: \"Access expired. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        // delete the OTP code after verification\n        await prisma.verificationToken.delete({\n          where: {\n            token: code,\n          },\n        });\n\n        // Create a email verification token for repeat access\n        const token = newId(\"email\");\n        hashedVerificationToken = hashToken(token);\n        const tokenExpiresAt = new Date();\n        tokenExpiresAt.setHours(tokenExpiresAt.getHours() + 23); // token expires at 23 hours\n        await prisma.verificationToken.create({\n          data: {\n            token: hashedVerificationToken,\n            identifier: `link-verification:${linkId}:${link.teamId}:${email}`,\n            expires: tokenExpiresAt,\n          },\n        });\n\n        isEmailVerified = true;\n      }\n\n      if (link.emailAuthenticated && token) {\n        const ipAddressValue = ipAddress(request);\n        const { success } = await ratelimit(10, \"1 m\").limit(\n          `verify-email:${ipAddressValue}`,\n        );\n        if (!success) {\n          return NextResponse.json(\n            { message: \"Too many requests. Please try again later.\" },\n            { status: 429 },\n          );\n        }\n\n        // Check if the long-term verification token is valid\n        const verification = await prisma.verificationToken.findUnique({\n          where: {\n            token: token,\n            identifier: `link-verification:${linkId}:${link.teamId}:${email}`,\n          },\n        });\n\n        if (!verification) {\n          return NextResponse.json(\n            {\n              message: \"Unauthorized access. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        // Check the long-term verification token's expiration date\n        if (Date.now() > verification.expires.getTime()) {\n          // delete the long-term verification token after verification\n          await prisma.verificationToken.delete({\n            where: {\n              token: token,\n            },\n          });\n          return NextResponse.json(\n            {\n              message: \"Access expired. Request new access.\",\n              resetVerification: true,\n            },\n            { status: 401 },\n          );\n        }\n\n        isEmailVerified = true;\n      }\n\n    }\n\n    let viewer: { id: string; email: string; verified: boolean } | null = null;\n    if (!isPreview) {\n      if (!dataroomSession) {\n        if (email) {\n          // find or create a viewer\n          console.time(\"find-viewer\");\n          viewer = await prisma.viewer.findUnique({\n            where: {\n              teamId_email: {\n                teamId: link.teamId!,\n                email: email,\n              },\n            },\n            select: { id: true, email: true, verified: true },\n          });\n          console.timeEnd(\"find-viewer\");\n\n          if (!viewer) {\n            console.time(\"create-viewer\");\n            viewer = await prisma.viewer.create({\n              data: {\n                email: email,\n                verified: isEmailVerified,\n                teamId: link.teamId!,\n              },\n              select: { id: true, email: true, verified: true },\n            });\n            console.timeEnd(\"create-viewer\");\n          }\n        }\n      } else {\n        if (dataroomSession.viewerId) {\n          viewer = await prisma.viewer.findUnique({\n            where: { id: dataroomSession.viewerId, teamId: link.teamId! },\n            select: { id: true, email: true, verified: true },\n          });\n        }\n      }\n\n      if (viewer && !viewer.verified && isEmailVerified) {\n        await prisma.viewer.update({\n          where: { id: viewer.id },\n          data: { verified: isEmailVerified },\n        });\n        // Update the viewer object to reflect the new verified status\n        viewer.verified = isEmailVerified;\n      }\n    }\n\n    // Common fields for the view object shared between DATAROOM_VIEW and DOCUMENT_VIEW\n    const viewFields = {\n      linkId: linkId,\n      viewerEmail: viewer?.email ?? email,\n      viewerName: name,\n      verified: isEmailVerified,\n      dataroomId: link.dataroomId,\n      viewerId: viewer?.id ?? undefined,\n      teamId: link.teamId,\n      ...(link.enableAgreement &&\n        link.agreementId &&\n        hasConfirmedAgreement && {\n          agreementResponse: {\n            create: {\n              agreementId: link.agreementId,\n            },\n          },\n        }),\n      ...(link.audienceType === LinkAudienceType.GROUP &&\n        link.groupId && {\n          groupId: link.groupId,\n        }),\n      ...(customFields &&\n        link.customFields.length > 0 && {\n          customFieldResponse: {\n            create: {\n              data: link.customFields.map((field) => ({\n                identifier: field.identifier,\n                label: field.label,\n                response: customFields[field.identifier] || \"\",\n              })),\n            },\n          },\n        }),\n    };\n\n    const isPaused =\n      link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()\n        ? true\n        : false;\n\n    // ** DATAROOM_VIEW **\n    if (viewType === \"DATAROOM_VIEW\") {\n      try {\n        let newDataroomView: { id: string } | null = null;\n        if (!isPreview) {\n          if (!dataroomSession) {\n            console.time(\"create-view\");\n            newDataroomView = await prisma.view.create({\n              data: { ...viewFields, viewType: \"DATAROOM_VIEW\" },\n              select: { id: true },\n            });\n            console.timeEnd(\"create-view\");\n          }\n        }\n\n        // Send events in the background to avoid blocking the response\n        if (newDataroomView) {\n          waitUntil(\n            // Record link view in Tinybird\n            recordLinkView({\n              req: request,\n              clickId: newId(\"linkView\"),\n              viewId: newDataroomView.id,\n              linkId,\n              dataroomId: link.dataroomId!,\n              teamId: link.teamId!,\n              enableNotification: link.enableNotification,\n              isPaused,\n            }),\n          );\n\n          if (link.teamId && !isPreview) {\n            waitUntil(\n              (async () => {\n                try {\n                  await notifyDataroomAccess({\n                    teamId: link.teamId!,\n                    dataroomId: link.dataroomId!,\n                    linkId,\n                    viewerEmail: verifiedEmail ?? email,\n                    viewerId: viewer?.id,\n                    teamIsPaused: isPaused,\n                  });\n                } catch (error) {\n                  console.error(\"Error sending Slack notification:\", error);\n                }\n              })(),\n            );\n          }\n        }\n\n        const dataroomViewId =\n          newDataroomView?.id ?? dataroomSession?.viewId ?? undefined;\n\n        const returnObject = {\n          message: \"Dataroom View recorded\",\n          viewId: dataroomViewId,\n          isPreview: isPreview ? true : undefined,\n          file: undefined,\n          pages: undefined,\n          notionData: undefined,\n          verificationToken: hashedVerificationToken,\n          viewerId: viewer?.id,\n          conversationsEnabled: link.enableConversation,\n          enableVisitorUpload: link.enableUpload,\n          agentsEnabled: link.dataroom?.agentsEnabled ?? false,\n          dataroomName: link.dataroom?.name,\n          ...(isTeamMember && { isTeamMember: true }),\n        };\n\n        const response = NextResponse.json(returnObject, { status: 200 });\n\n        // Create a dataroom session token if a dataroom session doesn't exist yet\n        if (!dataroomSession && !isPreview) {\n          const fingerprint = generateSessionFingerprint(\n            collectFingerprintHeaders(request.headers),\n          );\n          const newDataroomSession = await createDataroomSession(\n            link.dataroomId!,\n            linkId,\n            newDataroomView?.id!,\n            ipAddress(request) ?? LOCALHOST_IP,\n            isEmailVerified,\n            viewer?.id,\n            fingerprint,\n          );\n\n          let basePath = `/view/${linkId}`;\n          const cookieId = `pm_drs_${linkId}`;\n          let flagCookieId = `pm_drs_flag_${linkId}`;\n\n          if (link.domainId) {\n            basePath = `/${link.slug}`;\n            flagCookieId = `pm_drs_flag_${link.slug}`;\n          }\n\n          response.cookies.set(cookieId, newDataroomSession?.token, {\n            path: \"/\",\n            expires: new Date(newDataroomSession?.expiresAt),\n            httpOnly: true,\n            sameSite: \"strict\",\n          });\n          response.cookies.set(flagCookieId, \"true\", {\n            path: basePath,\n            expires: new Date(newDataroomSession?.expiresAt),\n            sameSite: \"strict\",\n          });\n        }\n\n        return response;\n      } catch (error) {\n        log({\n          message: `Failed to record view for dataroom link: ${linkId}. \\n\\n ${error}`,\n          type: \"error\",\n          mention: true,\n        });\n        return NextResponse.json(\n          { message: (error as Error).message },\n          { status: 500 },\n        );\n      }\n    }\n\n    // ** DOCUMENT_VIEW **\n    try {\n      let newView: { id: string } | null = null;\n      let dataroomView: { id: string } | null = null;\n      if (!isPreview) {\n        console.time(\"create-view\");\n\n        // if dataroomSession is not present, create a dataroom view first\n        if (!dataroomSession) {\n          dataroomView = await prisma.view.create({\n            data: { ...viewFields, viewType: \"DATAROOM_VIEW\" },\n            select: { id: true },\n          });\n\n          waitUntil(\n            // Record link view in Tinybird\n            recordLinkView({\n              req: request,\n              clickId: newId(\"linkView\"),\n              viewId: dataroomView.id,\n              linkId,\n              dataroomId: link.dataroomId!,\n              teamId: link.teamId!,\n              enableNotification: link.enableNotification,\n              isPaused,\n            }),\n          );\n        }\n\n        // create the document view\n        newView = await prisma.view.create({\n          data: {\n            ...viewFields,\n            documentId: documentId,\n            dataroomViewId:\n              dataroomSession?.viewId ?? dataroomView?.id ?? dataroomViewId,\n            viewType: \"DOCUMENT_VIEW\",\n          },\n          select: { id: true },\n        });\n        console.timeEnd(\"create-view\");\n        // Only send Slack notifications for non-preview views\n        if (link.teamId && !isPreview) {\n          waitUntil(\n            (async () => {\n              try {\n                await notifyDocumentView({\n                  teamId: link.teamId!,\n                  documentId,\n                  dataroomId: link.dataroomId!,\n                  linkId,\n                  viewerEmail: verifiedEmail ?? email,\n                  viewerId: viewer?.id,\n                  teamIsPaused: isPaused,\n                });\n              } catch (error) {\n                console.error(\"Error sending Slack notification:\", error);\n              }\n            })(),\n          );\n        }\n      }\n\n      // if document version has pages, then return pages\n      // otherwise, return file from document version\n      let documentPages, documentVersion;\n      let sheetData;\n      const INITIAL_PAGES_TO_LOAD = 10;\n\n      if (hasPages) {\n        // get pages from document version\n        console.time(\"get-pages\");\n        documentPages = await prisma.documentPage.findMany({\n          where: { versionId: documentVersionId },\n          orderBy: { pageNumber: \"asc\" },\n          select: {\n            file: true,\n            storageType: true,\n            pageNumber: true,\n            embeddedLinks: !link.team?.plan.includes(\"free\"),\n            pageLinks: !link.team?.plan.includes(\"free\"),\n            metadata: true,\n          },\n        });\n\n        // Sign URLs for pages around the requested start page (or page 1 by default).\n        // Remaining page URLs are fetched on-demand by the client via /api/views/pages.\n        const centerIndex = Math.max(0, (startPage ?? 1) - 1);\n        const halfWindow = Math.floor(INITIAL_PAGES_TO_LOAD / 2);\n        const signStart = Math.max(0, centerIndex - halfWindow);\n        const signEnd = Math.min(documentPages.length, signStart + INITIAL_PAGES_TO_LOAD);\n\n        documentPages = await Promise.all(\n          documentPages.map(async (page, index) => {\n            const { storageType, ...otherPage } = page;\n            return {\n              ...otherPage,\n              file:\n                index >= signStart && index < signEnd\n                  ? await getFile({ data: page.file, type: storageType })\n                  : null,\n            };\n          }),\n        );\n\n        console.timeEnd(\"get-pages\");\n      } else {\n        // get file from document version\n        console.time(\"get-file\");\n        documentVersion = await prisma.documentVersion.findUnique({\n          where: { id: documentVersionId },\n          select: {\n            file: true,\n            storageType: true,\n            type: true,\n          },\n        });\n\n        if (!documentVersion) {\n          return NextResponse.json(\n            { message: \"Document version not found.\" },\n            { status: 404 },\n          );\n        }\n\n        if (\n          documentVersion.type === \"pdf\" ||\n          documentVersion.type === \"image\" ||\n          documentVersion.type === \"video\"\n        ) {\n          documentVersion.file = await getFile({\n            data: documentVersion.file,\n            type: documentVersion.storageType,\n          });\n        }\n        // For link documents, the file is already a URL, no processing needed\n        if (documentVersion.type === \"sheet\") {\n          const document = await prisma.document.findUnique({\n            where: { id: documentId },\n            select: { advancedExcelEnabled: true },\n          });\n          useAdvancedExcelViewer = document?.advancedExcelEnabled ?? false;\n\n          if (useAdvancedExcelViewer) {\n            if (documentVersion.file.includes(\"https://\")) {\n              documentVersion.file = documentVersion.file;\n            } else {\n              // Get team-specific storage config for advanced distribution host\n              const storageConfig = await getTeamStorageConfigById(\n                link.teamId!,\n              );\n              documentVersion.file = `https://${storageConfig.advancedDistributionHost}/${documentVersion.file}`;\n            }\n          } else {\n            const fileUrl = await getFile({\n              data: documentVersion.file,\n              type: documentVersion.storageType,\n            });\n\n            const data = await parseSheet({ fileUrl });\n            sheetData = data;\n          }\n        }\n        console.timeEnd(\"get-file\");\n      }\n\n      // check if viewer can download the document based on group permissions\n      let canDownload: boolean = link.allowDownload ?? false;\n      const effectiveGroupId = link.groupId || link.permissionGroupId;\n\n      if (\n        link.allowDownload &&\n        (link.audienceType === LinkAudienceType.GROUP ||\n          link.permissionGroupId) &&\n        effectiveGroupId &&\n        documentId &&\n        link.dataroomId\n      ) {\n        const dataroomDocument = await prisma.dataroomDocument.findUnique({\n          where: {\n            dataroomId_documentId: {\n              dataroomId: link.dataroomId,\n              documentId: documentId,\n            },\n          },\n          select: { id: true },\n        });\n        if (!dataroomDocument) {\n          canDownload = false;\n        } else {\n          if (link.groupId) {\n            // This is a ViewerGroup (legacy behavior)\n            const groupDocumentPermission =\n              await prisma.viewerGroupAccessControls.findUnique({\n                where: {\n                  groupId_itemId: {\n                    groupId: link.groupId,\n                    itemId: dataroomDocument.id,\n                  },\n                  itemType: ItemType.DATAROOM_DOCUMENT,\n                },\n                select: { canDownload: true },\n              });\n            canDownload = groupDocumentPermission?.canDownload ?? false;\n          } else if (link.permissionGroupId) {\n            // This is a PermissionGroup (new behavior)\n            const permissionGroupDocumentPermission =\n              await prisma.permissionGroupAccessControls.findUnique({\n                where: {\n                  groupId_itemId: {\n                    groupId: link.permissionGroupId,\n                    itemId: dataroomDocument.id,\n                  },\n                  itemType: ItemType.DATAROOM_DOCUMENT,\n                },\n                select: { canDownload: true },\n              });\n            canDownload =\n              permissionGroupDocumentPermission?.canDownload ?? false;\n          }\n        }\n      }\n\n      const returnObject = {\n        message: \"View recorded\",\n        viewId: !isPreview && newView ? newView.id : undefined,\n        isPreview: isPreview ? true : undefined,\n        file:\n          (documentVersion &&\n            (documentVersion.type === \"pdf\" ||\n              documentVersion.type === \"image\" ||\n              documentVersion.type === \"zip\" ||\n              documentVersion.type === \"video\" ||\n              documentVersion.type === \"link\")) ||\n          (documentVersion && useAdvancedExcelViewer)\n            ? documentVersion.file\n            : undefined,\n        pages: documentPages ? documentPages : undefined,\n        notionData: undefined,\n        sheetData:\n          documentVersion &&\n          documentVersion.type === \"sheet\" &&\n          !useAdvancedExcelViewer\n            ? sheetData\n            : undefined,\n        fileType: documentVersion\n          ? documentVersion.type\n          : documentPages\n            ? \"pdf\"\n            : undefined,\n        watermarkConfig: link.enableWatermark\n          ? link.watermarkConfig\n          : undefined,\n        viewerEmail: viewer?.email ?? email ?? verifiedEmail ?? null,\n        ipAddress:\n          link.enableWatermark &&\n          link.watermarkConfig &&\n          WatermarkConfigSchema.parse(link.watermarkConfig).text.includes(\n            \"{{ipAddress}}\",\n          )\n            ? (ipAddress(request) ?? LOCALHOST_IP)\n            : undefined,\n        useAdvancedExcelViewer:\n          documentVersion &&\n          documentVersion.type === \"sheet\" &&\n          useAdvancedExcelViewer\n            ? useAdvancedExcelViewer\n            : undefined,\n        canDownload: canDownload,\n        viewerId: viewer?.id,\n        conversationsEnabled: link.enableConversation,\n        agentsEnabled: link.dataroom?.agentsEnabled ?? false,\n        dataroomName: link.dataroom?.name,\n        ...(isTeamMember && { isTeamMember: true }),\n      };\n\n      const response = NextResponse.json(returnObject, { status: 200 });\n\n      // Create a dataroom session token if a dataroom session doesn't exist yet\n      if (!dataroomSession && !isPreview) {\n        const fingerprint = generateSessionFingerprint(\n          collectFingerprintHeaders(request.headers),\n        );\n        const newDataroomSession = await createDataroomSession(\n          link.dataroomId!,\n          linkId,\n          dataroomView?.id!,\n          ipAddress(request) ?? LOCALHOST_IP,\n          isEmailVerified,\n          viewer?.id,\n          fingerprint,\n        );\n\n        let basePath = `/view/${linkId}`;\n        const cookieId = `pm_drs_${linkId}`;\n        let flagCookieId = `pm_drs_flag_${linkId}`;\n        if (link.domainId) {\n          basePath = `/${link.slug}`;\n          flagCookieId = `pm_drs_flag_${link.slug}`;\n        }\n\n        response.cookies.set(cookieId, newDataroomSession?.token, {\n          path: \"/\",\n          expires: new Date(newDataroomSession?.expiresAt),\n          httpOnly: true,\n          sameSite: \"strict\",\n        });\n        response.cookies.set(flagCookieId, \"true\", {\n          path: basePath,\n          expires: new Date(newDataroomSession?.expiresAt),\n          sameSite: \"strict\",\n        });\n      }\n\n      return response;\n    } catch (error) {\n      log({\n        message: `Failed to record view for dataroom document ${linkId}. \\n\\n ${error}`,\n        type: \"error\",\n        mention: true,\n      });\n      return NextResponse.json(\n        { message: (error as Error).message },\n        { status: 500 },\n      );\n    }\n  } catch (error) {\n    console.error(error);\n    return NextResponse.json(\n      { error: \"Internal Server Error\" },\n      { status: 500 },\n    );\n  }\n}\n"
  },
  {
    "path": "app/api/webhooks/callback/route.ts",
    "content": "import { z } from \"zod\";\n\nimport { verifyQstashSignature } from \"@/lib/cron/verify-qstash\";\nimport prisma from \"@/lib/prisma\";\nimport { recordWebhookEvent } from \"@/lib/tinybird/publish\";\nimport { getSearchParams } from \"@/lib/utils/get-search-params\";\nimport { WEBHOOK_TRIGGERS } from \"@/lib/webhook/constants\";\nimport {\n  webhookCallbackSchema,\n  webhookPayloadSchema,\n} from \"@/lib/zod/schemas/webhooks\";\n\nconst searchParamsSchema = z.object({\n  webhookId: z.string(),\n  eventId: z.string(),\n  event: z.enum(WEBHOOK_TRIGGERS),\n});\n\n// POST /api/webhooks/callback – listen to webhooks status from QStash\nexport const POST = async (req: Request) => {\n  const rawBody = await req.text();\n  await verifyQstashSignature({\n    req,\n    rawBody,\n  });\n\n  const { url, status, body, sourceBody, sourceMessageId } =\n    webhookCallbackSchema.parse(JSON.parse(rawBody));\n\n  const { webhookId, eventId, event } = searchParamsSchema.parse(\n    getSearchParams(req.url),\n  );\n\n  const webhook = await prisma.webhook.findUnique({\n    where: { pId: webhookId },\n  });\n\n  if (!webhook) {\n    console.error(\"Webhook not found\", { webhookId });\n    return new Response(\"Webhook not found\");\n  }\n\n  const request = Buffer.from(sourceBody, \"base64\").toString(\"utf-8\");\n  const response = Buffer.from(body, \"base64\").toString(\"utf-8\");\n  const isFailed = status >= 400 || status === -1;\n\n  await recordWebhookEvent({\n    url,\n    event,\n    event_id: eventId,\n    http_status: status === -1 ? 503 : status,\n    webhook_id: webhookId || \"\",\n    request_body: request,\n    response_body: response,\n    message_id: sourceMessageId,\n  });\n\n  return new Response(`Webhook ${webhookId} processed`);\n};\n"
  },
  {
    "path": "app/layout.tsx",
    "content": "import { Metadata } from \"next\";\nimport { Inter } from \"next/font/google\";\n\nimport \"@/styles/globals.css\";\n\nconst inter = Inter({ subsets: [\"latin\"] });\n\nconst data = {\n  description:\n    \"Papermark is an open-source document sharing infrastructure. Free alternative to Docsend with custom domain. Manage secure document sharing with real-time analytics.\",\n  title: \"Papermark | The Open Source DocSend Alternative\",\n  url: \"/\",\n};\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(\"https://www.papermark.com\"),\n  title: data.title,\n  description: data.description,\n  openGraph: {\n    title: data.title,\n    description: data.description,\n    url: data.url,\n    siteName: \"Papermark\",\n    images: [\n      {\n        url: \"/_static/meta-image.png\",\n        width: 800,\n        height: 600,\n      },\n    ],\n    locale: \"en_US\",\n    type: \"website\",\n  },\n  twitter: {\n    card: \"summary_large_image\",\n    title: data.title,\n    description: data.description,\n    creator: \"@papermarkio\",\n    images: [\"/_static/meta-image.png\"],\n  },\n};\n\nexport default function RootLayout({\n  children,\n}: {\n  children: React.ReactNode;\n}) {\n  return (\n    <html lang=\"en\">\n      <body className={inter.className}>{children}</body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "app/robots.txt",
    "content": "User-Agent: *\nDisallow: /register\nDisallow: /verify/\nDisallow: /auth/\nDisallow: /unsubscribe\n"
  },
  {
    "path": "components/EmailForm.tsx",
    "content": "export default function EmailForm({ onSubmitHandler, setEmail }: any) {\n  return (\n    <>\n      <div className=\"flex h-screen flex-1 flex-col  bg-black px-6 py-12 lg:px-8\">\n        <div className=\"sm:mx-auto sm:w-full sm:max-w-md\">\n          <h2 className=\"mt-10 text-2xl font-bold leading-9 tracking-tight text-white\">\n            Enter your email to view the document\n          </h2>\n        </div>\n\n        <div className=\"mt-10 sm:mx-auto sm:w-full sm:max-w-md\">\n          <form className=\"space-y-6\" onSubmit={onSubmitHandler}>\n            <div>\n              <label\n                htmlFor=\"email\"\n                className=\"block text-sm font-medium leading-6 text-white\"\n              >\n                Email address\n              </label>\n              <div className=\"mt-2\">\n                <input\n                  id=\"email\"\n                  name=\"email\"\n                  type=\"email\"\n                  autoComplete=\"email\"\n                  onChange={(e) => setEmail(e.target.value)}\n                  required\n                  className=\"block w-full rounded-md border-0 bg-white/5 py-1.5 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6\"\n                />\n              </div>\n            </div>\n\n            <div>\n              <button\n                type=\"submit\"\n                className=\"flex w-full justify-center rounded-md bg-indigo-500 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500\"\n              >\n                View Document\n              </button>\n            </div>\n          </form>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/Skeleton.tsx",
    "content": "import { classNames } from \"@/lib/utils\";\n\nfunction Skeleton({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={classNames(\"animate-pulse rounded-md bg-muted\", className!)}\n      {...props}\n    />\n  );\n}\n\nexport default Skeleton;\n"
  },
  {
    "path": "components/account/account-header.tsx",
    "content": "import { NavMenu } from \"../navigation-menu\";\n\nexport function AccountHeader() {\n  return (\n    <header>\n      <section className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n        <div className=\"space-y-1\">\n          <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n            User Account\n          </h3>\n          <p className=\"text-sm text-muted-foreground\">Manage your profile</p>\n        </div>\n      </section>\n\n      <NavMenu\n        navigation={[\n          {\n            label: \"General\",\n            href: `/account/general`,\n            segment: `general`,\n          },\n          {\n            label: \"Security\",\n            href: `/account/security`,\n            segment: \"security\",\n          },\n        ]}\n      />\n    </header>\n  );\n}\n"
  },
  {
    "path": "components/account/update-subscription.tsx",
    "content": "import { useOptimisticUpdate } from \"@/components/hooks/use-optimistic-update\";\nimport { Switch } from \"@/components/ui/switch\";\n\nexport const UpdateMailSubscribe = () => {\n  const { data, isLoading, update } = useOptimisticUpdate<{\n    subscribed: boolean;\n  }>(\"/api/user/subscribe\", {\n    loading: \"Updating email preferences...\",\n    success: \"Your email preferences have been updated!\",\n    error: \"Failed to update email preferences. Please try again.\",\n  });\n\n  const subscribe = async (checked: boolean) => {\n    const method = checked ? \"POST\" : \"DELETE\";\n    const res = await fetch(\"/api/user/subscribe\", {\n      method,\n    });\n    if (!res.ok) {\n      throw new Error(\"Failed to update email preferences\");\n    }\n    return { subscribed: checked };\n  };\n\n  return (\n    <div className=\"flex items-center gap-x-2\">\n      <Switch\n        checked={data?.subscribed ?? true}\n        disabled={isLoading}\n        onCheckedChange={(checked: boolean) => {\n          update(() => subscribe(checked), { subscribed: checked });\n        }}\n      />\n      <p className=\"text-sm text-muted-foreground transition-colors\">\n        Subscribed to product updates\n      </p>\n    </div>\n  );\n};\n\nexport default UpdateMailSubscribe;\n"
  },
  {
    "path": "components/account/upload-avatar.tsx",
    "content": "\"use client\";\n\nimport { ReactNode, useEffect, useState } from \"react\";\n\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport { convertDataUrlToFile, uploadImage } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { FileUpload } from \"@/components/ui/file-upload\";\n\ninterface UploadAvatarProps {\n  title: string;\n  description: string;\n  helpText?: string | ReactNode;\n  buttonText?: string;\n}\n\nconst UploadAvatar = ({\n  title,\n  description,\n  helpText,\n  buttonText,\n}: UploadAvatarProps) => {\n  const [uploading, setUploading] = useState(false);\n  const { data: session, update } = useSession();\n  const [image, setImage] = useState<string | null>(null);\n\n  useEffect(() => {\n    if (session?.user?.image) {\n      setImage(session.user.image);\n    }\n  }, [session]);\n\n  return (\n    <form\n      onSubmit={async (e) => {\n        setUploading(true);\n        e.preventDefault();\n        if (!image) {\n          return;\n        }\n        const blob = convertDataUrlToFile({ dataUrl: image });\n        const blobUrl = await uploadImage(blob);\n        fetch(\"/api/account\", {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ image: blobUrl }),\n        }).then(async (res) => {\n          setUploading(false);\n          if (!res.ok) {\n            const errorMessage = await res.text();\n            toast.error(errorMessage || \"Something went wrong\");\n            return;\n          }\n          await update();\n          toast.success(\"Successfully updated your profile picture!\");\n        });\n      }}\n      className=\"rounded-lg\"\n    >\n      <Card className=\"bg-transparent\">\n        <CardHeader>\n          <CardTitle>{title}</CardTitle>\n          <CardDescription>{description}</CardDescription>\n        </CardHeader>\n        <CardContent>\n          <FileUpload\n            accept=\"images\"\n            className=\"h-24 w-24 rounded-full border border-gray-300\"\n            iconClassName=\"w-5 h-5\"\n            variant=\"plain\"\n            imageSrc={image}\n            readFile\n            onChange={({ src, file }) => setImage(src)}\n            content={null}\n            maxFileSizeMB={2}\n            targetResolution={{ width: 160, height: 160, quality: 100 }}\n          />\n        </CardContent>\n        <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n          {typeof helpText === \"string\" ? (\n            <p\n              className=\"text-sm text-muted-foreground transition-colors\"\n              dangerouslySetInnerHTML={{ __html: helpText || \"\" }}\n            />\n          ) : (\n            helpText\n          )}\n          <div className=\"shrink-0\">\n            <Button\n              loading={uploading}\n              disabled={!image || session?.user?.image === image}\n            >\n              {buttonText || \"Save Changes\"}\n            </Button>\n          </div>\n        </CardFooter>\n      </Card>\n    </form>\n  );\n};\n\nexport default UploadAvatar;\n"
  },
  {
    "path": "components/agreements/agreement-card.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { DownloadIcon, FileTextIcon, MoreVertical, TrashIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { AgreementWithLinksCount } from \"@/lib/swr/use-agreements\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\ninterface AgreementCardProps {\n  agreement: AgreementWithLinksCount;\n  onDelete: (id: string) => void;\n}\n\nexport default function AgreementCard({\n  agreement,\n  onDelete,\n}: AgreementCardProps) {\n  const teamInfo = useTeam();\n  const [showDeleteDialog, setShowDeleteDialog] = useState(false);\n\n  const handleDelete = async () => {\n    toast.promise(\n      fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/agreements/${agreement.id}`,\n        {\n          method: \"PUT\",\n        },\n      ).then(async (response) => {\n        if (!response.ok) {\n          throw new Error(\"Failed to delete agreement\");\n        }\n        onDelete(agreement.id);\n      }),\n      {\n        loading: \"Deleting agreement...\",\n        success: \"Agreement deleted successfully\",\n        error: \"Failed to delete agreement\",\n      },\n    );\n  };\n\n  const handleDownload = async () => {\n    toast.promise(\n      fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/agreements/${agreement.id}/download`,\n        {\n          method: \"POST\",\n        },\n      ).then(async (response) => {\n        if (!response.ok) {\n          throw new Error(\"Failed to download agreement\");\n        }\n        \n        // Get the filename from the Content-Disposition header or use default\n        const contentDisposition = response.headers.get(\"Content-Disposition\");\n        const filenameMatch = contentDisposition?.match(/filename=\"([^\"]+)\"/);\n        const filename = filenameMatch ? filenameMatch[1] : `${agreement.name}.txt`;\n        \n        // Create a blob and download\n        const blob = await response.blob();\n        const url = window.URL.createObjectURL(blob);\n        const link = document.createElement(\"a\");\n        link.href = url;\n        link.download = filename;\n        document.body.appendChild(link);\n        link.click();\n        \n        // Cleanup\n        setTimeout(() => {\n          window.URL.revokeObjectURL(url);\n          document.body.removeChild(link);\n        }, 100);\n      }),\n      {\n        loading: \"Downloading agreement...\",\n        success: \"Agreement downloaded successfully\",\n        error: \"Failed to download agreement\",\n      },\n    );\n  };\n\n  return (\n    <>\n      <div className=\"flex items-center justify-between rounded-lg border p-4\">\n        <div className=\"flex items-center space-x-4\">\n          <div className=\"flex h-10 w-10 items-center justify-center rounded-lg bg-muted\">\n            <FileTextIcon className=\"h-5 w-5\" />\n          </div>\n          <div>\n            <h3 className=\"font-medium\">{agreement.name}</h3>\n            <p className=\"text-sm text-muted-foreground\">\n              Last updated {new Date(agreement.updatedAt).toLocaleDateString()}\n            </p>\n          </div>\n        </div>\n\n        <div className=\"flex items-center gap-x-2\">\n          <div className=\"text-sm text-muted-foreground\">\n            {agreement._count?.links || 0}{\" \"}\n            {agreement._count?.links === 1 ? \"link\" : \"links\"}\n          </div>\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button variant=\"ghost\" className=\"h-8 w-8 p-0\">\n                <span className=\"sr-only\">Open menu</span>\n                <MoreVertical className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuItem onClick={handleDownload}>\n                <DownloadIcon className=\"mr-2 h-4 w-4\" />\n                Download agreement\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                className=\"text-destructive focus:bg-destructive focus:text-destructive-foreground\"\n                onClick={() => setShowDeleteDialog(true)}\n              >\n                <TrashIcon className=\"mr-2 h-4 w-4\" />\n                Delete agreement\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      </div>\n\n      <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Are you sure?</AlertDialogTitle>\n            <AlertDialogDescription>\n              This will permanently delete the agreement &quot;\n              {agreement.name}&quot;. This action cannot be undone.\n              <br />\n              <br />\n              <span className=\"font-medium\">\n                Note: If this agreement is still referenced in any documents or\n                dataroom links, it will remain available there until those\n                references are removed.\n              </span>\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              onClick={handleDelete}\n              className=\"bg-destructive text-destructive-foreground\"\n            >\n              Delete\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/ai-elements/conversation.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { ArrowDownIcon } from \"lucide-react\";\nimport type { ComponentProps } from \"react\";\nimport { useCallback } from \"react\";\nimport { StickToBottom, useStickToBottomContext } from \"use-stick-to-bottom\";\n\nexport type ConversationProps = ComponentProps<typeof StickToBottom>;\n\nexport const Conversation = ({ className, ...props }: ConversationProps) => (\n  <StickToBottom\n    className={cn(\"relative flex-1 overflow-y-hidden\", className)}\n    initial=\"smooth\"\n    resize=\"smooth\"\n    role=\"log\"\n    {...props}\n  />\n);\n\nexport type ConversationContentProps = ComponentProps<\n  typeof StickToBottom.Content\n>;\n\nexport const ConversationContent = ({\n  className,\n  ...props\n}: ConversationContentProps) => (\n  <StickToBottom.Content\n    className={cn(\"flex flex-col gap-8 p-4\", className)}\n    {...props}\n  />\n);\n\nexport type ConversationEmptyStateProps = ComponentProps<\"div\"> & {\n  title?: string;\n  description?: string;\n  icon?: React.ReactNode;\n};\n\nexport const ConversationEmptyState = ({\n  className,\n  title = \"No messages yet\",\n  description = \"Start a conversation to see messages here\",\n  icon,\n  children,\n  ...props\n}: ConversationEmptyStateProps) => (\n  <div\n    className={cn(\n      \"flex size-full flex-col items-center justify-center gap-3 p-8 text-center\",\n      className\n    )}\n    {...props}\n  >\n    {children ?? (\n      <>\n        {icon && <div className=\"text-muted-foreground\">{icon}</div>}\n        <div className=\"space-y-1\">\n          <h3 className=\"font-medium text-sm\">{title}</h3>\n          {description && (\n            <p className=\"text-muted-foreground text-sm\">{description}</p>\n          )}\n        </div>\n      </>\n    )}\n  </div>\n);\n\nexport type ConversationScrollButtonProps = ComponentProps<typeof Button>;\n\nexport const ConversationScrollButton = ({\n  className,\n  ...props\n}: ConversationScrollButtonProps) => {\n  const { isAtBottom, scrollToBottom } = useStickToBottomContext();\n\n  const handleScrollToBottom = useCallback(() => {\n    scrollToBottom();\n  }, [scrollToBottom]);\n\n  return (\n    !isAtBottom && (\n      <Button\n        className={cn(\n          \"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full\",\n          className\n        )}\n        onClick={handleScrollToBottom}\n        size=\"icon\"\n        type=\"button\"\n        variant=\"outline\"\n        {...props}\n      >\n        <ArrowDownIcon className=\"size-4\" />\n      </Button>\n    )\n  );\n};\n"
  },
  {
    "path": "components/ai-elements/message.tsx",
    "content": "\"use client\";\n\nimport type { ComponentProps, HTMLAttributes, ReactElement } from \"react\";\nimport { createContext, memo, useContext, useEffect, useState } from \"react\";\n\nimport type { FileUIPart, UIMessage } from \"ai\";\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  PaperclipIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { Streamdown } from \"streamdown\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ButtonGroup, ButtonGroupText } from \"@/components/ui/button-group\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nexport type MessageProps = HTMLAttributes<HTMLDivElement> & {\n  from: UIMessage[\"role\"];\n};\n\nexport const Message = ({ className, from, ...props }: MessageProps) => (\n  <div\n    className={cn(\n      \"group flex w-full max-w-[80%] flex-col gap-2\",\n      from === \"user\" ? \"is-user ml-auto justify-end\" : \"is-assistant\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nexport type MessageContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const MessageContent = ({\n  children,\n  className,\n  ...props\n}: MessageContentProps) => (\n  <div\n    className={cn(\n      \"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm\",\n      \"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground\",\n      \"group-[.is-assistant]:text-foreground\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n  </div>\n);\n\nexport type MessageActionsProps = ComponentProps<\"div\">;\n\nexport const MessageActions = ({\n  className,\n  children,\n  ...props\n}: MessageActionsProps) => (\n  <div className={cn(\"flex items-center gap-1\", className)} {...props}>\n    {children}\n  </div>\n);\n\nexport type MessageActionProps = ComponentProps<typeof Button> & {\n  tooltip?: string;\n  label?: string;\n};\n\nexport const MessageAction = ({\n  tooltip,\n  children,\n  label,\n  variant = \"ghost\",\n  size = \"icon\",\n  ...props\n}: MessageActionProps) => {\n  const button = (\n    <Button size={size} type=\"button\" variant={variant} {...props}>\n      {children}\n      <span className=\"sr-only\">{label || tooltip}</span>\n    </Button>\n  );\n\n  if (tooltip) {\n    return (\n      <TooltipProvider delayDuration={100}>\n        <Tooltip>\n          <TooltipTrigger asChild>{button}</TooltipTrigger>\n          <TooltipContent side=\"bottom\" className=\"px-1.5 py-1 text-sm\">\n            <span>{tooltip}</span>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    );\n  }\n\n  return button;\n};\n\ntype MessageBranchContextType = {\n  currentBranch: number;\n  totalBranches: number;\n  goToPrevious: () => void;\n  goToNext: () => void;\n  branches: ReactElement[];\n  setBranches: (branches: ReactElement[]) => void;\n};\n\nconst MessageBranchContext = createContext<MessageBranchContextType | null>(\n  null,\n);\n\nconst useMessageBranch = () => {\n  const context = useContext(MessageBranchContext);\n\n  if (!context) {\n    throw new Error(\n      \"MessageBranch components must be used within MessageBranch\",\n    );\n  }\n\n  return context;\n};\n\nexport type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {\n  defaultBranch?: number;\n  onBranchChange?: (branchIndex: number) => void;\n};\n\nexport const MessageBranch = ({\n  defaultBranch = 0,\n  onBranchChange,\n  className,\n  ...props\n}: MessageBranchProps) => {\n  const [currentBranch, setCurrentBranch] = useState(defaultBranch);\n  const [branches, setBranches] = useState<ReactElement[]>([]);\n\n  const handleBranchChange = (newBranch: number) => {\n    setCurrentBranch(newBranch);\n    onBranchChange?.(newBranch);\n  };\n\n  const goToPrevious = () => {\n    const newBranch =\n      currentBranch > 0 ? currentBranch - 1 : branches.length - 1;\n    handleBranchChange(newBranch);\n  };\n\n  const goToNext = () => {\n    const newBranch =\n      currentBranch < branches.length - 1 ? currentBranch + 1 : 0;\n    handleBranchChange(newBranch);\n  };\n\n  const contextValue: MessageBranchContextType = {\n    currentBranch,\n    totalBranches: branches.length,\n    goToPrevious,\n    goToNext,\n    branches,\n    setBranches,\n  };\n\n  return (\n    <MessageBranchContext.Provider value={contextValue}>\n      <div\n        className={cn(\"grid w-full gap-2 [&>div]:pb-0\", className)}\n        {...props}\n      />\n    </MessageBranchContext.Provider>\n  );\n};\n\nexport type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;\n\nexport const MessageBranchContent = ({\n  children,\n  ...props\n}: MessageBranchContentProps) => {\n  const { currentBranch, setBranches, branches } = useMessageBranch();\n  const childrenArray = Array.isArray(children) ? children : [children];\n\n  // Use useEffect to update branches when they change\n  useEffect(() => {\n    if (branches.length !== childrenArray.length) {\n      setBranches(childrenArray);\n    }\n  }, [childrenArray, branches, setBranches]);\n\n  return childrenArray.map((branch, index) => (\n    <div\n      className={cn(\n        \"grid gap-2 overflow-hidden [&>div]:pb-0\",\n        index === currentBranch ? \"block\" : \"hidden\",\n      )}\n      key={branch.key}\n      {...props}\n    >\n      {branch}\n    </div>\n  ));\n};\n\nexport type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {\n  from: UIMessage[\"role\"];\n};\n\nexport const MessageBranchSelector = ({\n  className,\n  from,\n  ...props\n}: MessageBranchSelectorProps) => {\n  const { totalBranches } = useMessageBranch();\n\n  // Don't render if there's only one branch\n  if (totalBranches <= 1) {\n    return null;\n  }\n\n  return (\n    <ButtonGroup\n      className=\"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md\"\n      orientation=\"horizontal\"\n      {...props}\n    />\n  );\n};\n\nexport type MessageBranchPreviousProps = ComponentProps<typeof Button>;\n\nexport const MessageBranchPrevious = ({\n  children,\n  ...props\n}: MessageBranchPreviousProps) => {\n  const { goToPrevious, totalBranches } = useMessageBranch();\n\n  return (\n    <Button\n      aria-label=\"Previous branch\"\n      disabled={totalBranches <= 1}\n      onClick={goToPrevious}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <ChevronLeftIcon size={14} />}\n    </Button>\n  );\n};\n\nexport type MessageBranchNextProps = ComponentProps<typeof Button>;\n\nexport const MessageBranchNext = ({\n  children,\n  className,\n  ...props\n}: MessageBranchNextProps) => {\n  const { goToNext, totalBranches } = useMessageBranch();\n\n  return (\n    <Button\n      aria-label=\"Next branch\"\n      disabled={totalBranches <= 1}\n      onClick={goToNext}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...props}\n    >\n      {children ?? <ChevronRightIcon size={14} />}\n    </Button>\n  );\n};\n\nexport type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;\n\nexport const MessageBranchPage = ({\n  className,\n  ...props\n}: MessageBranchPageProps) => {\n  const { currentBranch, totalBranches } = useMessageBranch();\n\n  return (\n    <ButtonGroupText\n      className={cn(\n        \"border-none bg-transparent text-muted-foreground shadow-none\",\n        className,\n      )}\n      {...props}\n    >\n      {currentBranch + 1} of {totalBranches}\n    </ButtonGroupText>\n  );\n};\n\nexport type MessageResponseProps = ComponentProps<typeof Streamdown>;\n\nexport const MessageResponse = memo(\n  ({ className, ...props }: MessageResponseProps) => (\n    <Streamdown\n      className={cn(\n        \"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_li>p]:m-0 [&_li>p]:inline\",\n        className,\n      )}\n      {...props}\n    />\n  ),\n  (prevProps, nextProps) => prevProps.children === nextProps.children,\n);\n\nMessageResponse.displayName = \"MessageResponse\";\n\nexport type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {\n  data: FileUIPart;\n  className?: string;\n  onRemove?: () => void;\n};\n\nexport function MessageAttachment({\n  data,\n  className,\n  onRemove,\n  ...props\n}: MessageAttachmentProps) {\n  const filename = data.filename || \"\";\n  const mediaType =\n    data.mediaType?.startsWith(\"image/\") && data.url ? \"image\" : \"file\";\n  const isImage = mediaType === \"image\";\n  const attachmentLabel = filename || (isImage ? \"Image\" : \"Attachment\");\n\n  return (\n    <div\n      className={cn(\n        \"group relative size-24 overflow-hidden rounded-lg\",\n        className,\n      )}\n      {...props}\n    >\n      {isImage ? (\n        <>\n          <img\n            alt={filename || \"attachment\"}\n            className=\"size-full object-cover\"\n            height={100}\n            src={data.url}\n            width={100}\n          />\n          {onRemove && (\n            <Button\n              aria-label=\"Remove attachment\"\n              className=\"absolute right-2 top-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3\"\n              onClick={(e) => {\n                e.stopPropagation();\n                onRemove();\n              }}\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <XIcon />\n              <span className=\"sr-only\">Remove</span>\n            </Button>\n          )}\n        </>\n      ) : (\n        <>\n          <Tooltip>\n            <TooltipTrigger asChild>\n              <div className=\"flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\">\n                <PaperclipIcon className=\"size-4\" />\n              </div>\n            </TooltipTrigger>\n            <TooltipContent>\n              <p>{attachmentLabel}</p>\n            </TooltipContent>\n          </Tooltip>\n          {onRemove && (\n            <Button\n              aria-label=\"Remove attachment\"\n              className=\"size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3\"\n              onClick={(e) => {\n                e.stopPropagation();\n                onRemove();\n              }}\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <XIcon />\n              <span className=\"sr-only\">Remove</span>\n            </Button>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n\nexport type MessageAttachmentsProps = ComponentProps<\"div\">;\n\nexport function MessageAttachments({\n  children,\n  className,\n  ...props\n}: MessageAttachmentsProps) {\n  if (!children) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"ml-auto flex w-fit flex-wrap items-start gap-2\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport type MessageToolbarProps = ComponentProps<\"div\">;\n\nexport const MessageToolbar = ({\n  className,\n  children,\n  ...props\n}: MessageToolbarProps) => (\n  <div\n    className={cn(\n      \"mt-4 flex w-full items-center justify-between gap-4\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n  </div>\n);\n"
  },
  {
    "path": "components/ai-elements/prompt-input.tsx",
    "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n  CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/ui/hover-card\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\nimport type { ChatStatus, FileUIPart } from \"ai\";\nimport {\n  CornerDownLeftIcon,\n  ImageIcon,\n  Loader2Icon,\n  MicIcon,\n  PaperclipIcon,\n  PlusIcon,\n  SquareIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { nanoid } from \"nanoid\";\nimport {\n  type ChangeEvent,\n  type ChangeEventHandler,\n  Children,\n  type ClipboardEventHandler,\n  type ComponentProps,\n  createContext,\n  type FormEvent,\n  type FormEventHandler,\n  Fragment,\n  type HTMLAttributes,\n  type KeyboardEventHandler,\n  type PropsWithChildren,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\n// ============================================================================\n// Provider Context & Types\n// ============================================================================\n\nexport type AttachmentsContext = {\n  files: (FileUIPart & { id: string })[];\n  add: (files: File[] | FileList) => void;\n  remove: (id: string) => void;\n  clear: () => void;\n  openFileDialog: () => void;\n  fileInputRef: RefObject<HTMLInputElement | null>;\n};\n\nexport type TextInputContext = {\n  value: string;\n  setInput: (v: string) => void;\n  clear: () => void;\n};\n\nexport type PromptInputControllerProps = {\n  textInput: TextInputContext;\n  attachments: AttachmentsContext;\n  /** INTERNAL: Allows PromptInput to register its file textInput + \"open\" callback */\n  __registerFileInput: (\n    ref: RefObject<HTMLInputElement | null>,\n    open: () => void\n  ) => void;\n};\n\nconst PromptInputController = createContext<PromptInputControllerProps | null>(\n  null\n);\nconst ProviderAttachmentsContext = createContext<AttachmentsContext | null>(\n  null\n);\n\nexport const usePromptInputController = () => {\n  const ctx = useContext(PromptInputController);\n  if (!ctx) {\n    throw new Error(\n      \"Wrap your component inside <PromptInputProvider> to use usePromptInputController().\"\n    );\n  }\n  return ctx;\n};\n\n// Optional variants (do NOT throw). Useful for dual-mode components.\nconst useOptionalPromptInputController = () =>\n  useContext(PromptInputController);\n\nexport const useProviderAttachments = () => {\n  const ctx = useContext(ProviderAttachmentsContext);\n  if (!ctx) {\n    throw new Error(\n      \"Wrap your component inside <PromptInputProvider> to use useProviderAttachments().\"\n    );\n  }\n  return ctx;\n};\n\nconst useOptionalProviderAttachments = () =>\n  useContext(ProviderAttachmentsContext);\n\nexport type PromptInputProviderProps = PropsWithChildren<{\n  initialInput?: string;\n}>;\n\n/**\n * Optional global provider that lifts PromptInput state outside of PromptInput.\n * If you don't use it, PromptInput stays fully self-managed.\n */\nexport function PromptInputProvider({\n  initialInput: initialTextInput = \"\",\n  children,\n}: PromptInputProviderProps) {\n  // ----- textInput state\n  const [textInput, setTextInput] = useState(initialTextInput);\n  const clearInput = useCallback(() => setTextInput(\"\"), []);\n\n  // ----- attachments state (global when wrapped)\n  const [attachmentFiles, setAttachmentFiles] = useState<\n    (FileUIPart & { id: string })[]\n  >([]);\n  const fileInputRef = useRef<HTMLInputElement | null>(null);\n  const openRef = useRef<() => void>(() => {});\n\n  const add = useCallback((files: File[] | FileList) => {\n    const incoming = Array.from(files);\n    if (incoming.length === 0) {\n      return;\n    }\n\n    setAttachmentFiles((prev) =>\n      prev.concat(\n        incoming.map((file) => ({\n          id: nanoid(),\n          type: \"file\" as const,\n          url: URL.createObjectURL(file),\n          mediaType: file.type,\n          filename: file.name,\n        }))\n      )\n    );\n  }, []);\n\n  const remove = useCallback((id: string) => {\n    setAttachmentFiles((prev) => {\n      const found = prev.find((f) => f.id === id);\n      if (found?.url) {\n        URL.revokeObjectURL(found.url);\n      }\n      return prev.filter((f) => f.id !== id);\n    });\n  }, []);\n\n  const clear = useCallback(() => {\n    setAttachmentFiles((prev) => {\n      for (const f of prev) {\n        if (f.url) {\n          URL.revokeObjectURL(f.url);\n        }\n      }\n      return [];\n    });\n  }, []);\n\n  // Keep a ref to attachments for cleanup on unmount (avoids stale closure)\n  const attachmentsRef = useRef(attachmentFiles);\n  attachmentsRef.current = attachmentFiles;\n\n  // Cleanup blob URLs on unmount to prevent memory leaks\n  useEffect(() => {\n    return () => {\n      for (const f of attachmentsRef.current) {\n        if (f.url) {\n          URL.revokeObjectURL(f.url);\n        }\n      }\n    };\n  }, []);\n\n  const openFileDialog = useCallback(() => {\n    openRef.current?.();\n  }, []);\n\n  const attachments = useMemo<AttachmentsContext>(\n    () => ({\n      files: attachmentFiles,\n      add,\n      remove,\n      clear,\n      openFileDialog,\n      fileInputRef,\n    }),\n    [attachmentFiles, add, remove, clear, openFileDialog]\n  );\n\n  const __registerFileInput = useCallback(\n    (ref: RefObject<HTMLInputElement | null>, open: () => void) => {\n      fileInputRef.current = ref.current;\n      openRef.current = open;\n    },\n    []\n  );\n\n  const controller = useMemo<PromptInputControllerProps>(\n    () => ({\n      textInput: {\n        value: textInput,\n        setInput: setTextInput,\n        clear: clearInput,\n      },\n      attachments,\n      __registerFileInput,\n    }),\n    [textInput, clearInput, attachments, __registerFileInput]\n  );\n\n  return (\n    <PromptInputController.Provider value={controller}>\n      <ProviderAttachmentsContext.Provider value={attachments}>\n        {children}\n      </ProviderAttachmentsContext.Provider>\n    </PromptInputController.Provider>\n  );\n}\n\n// ============================================================================\n// Component Context & Hooks\n// ============================================================================\n\nconst LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);\n\nexport const usePromptInputAttachments = () => {\n  // Dual-mode: prefer provider if present, otherwise use local\n  const provider = useOptionalProviderAttachments();\n  const local = useContext(LocalAttachmentsContext);\n  const context = provider ?? local;\n  if (!context) {\n    throw new Error(\n      \"usePromptInputAttachments must be used within a PromptInput or PromptInputProvider\"\n    );\n  }\n  return context;\n};\n\nexport type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {\n  data: FileUIPart & { id: string };\n  className?: string;\n};\n\nexport function PromptInputAttachment({\n  data,\n  className,\n  ...props\n}: PromptInputAttachmentProps) {\n  const attachments = usePromptInputAttachments();\n\n  const filename = data.filename || \"\";\n\n  const mediaType =\n    data.mediaType?.startsWith(\"image/\") && data.url ? \"image\" : \"file\";\n  const isImage = mediaType === \"image\";\n\n  const attachmentLabel = filename || (isImage ? \"Image\" : \"Attachment\");\n\n  return (\n    <PromptInputHoverCard>\n      <HoverCardTrigger asChild>\n        <div\n          className={cn(\n            \"group relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n            className\n          )}\n          key={data.id}\n          {...props}\n        >\n          <div className=\"relative size-5 shrink-0\">\n            <div className=\"absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0\">\n              {isImage ? (\n                <img\n                  alt={filename || \"attachment\"}\n                  className=\"size-5 object-cover\"\n                  height={20}\n                  src={data.url}\n                  width={20}\n                />\n              ) : (\n                <div className=\"flex size-5 items-center justify-center text-muted-foreground\">\n                  <PaperclipIcon className=\"size-3\" />\n                </div>\n              )}\n            </div>\n            <Button\n              aria-label=\"Remove attachment\"\n              className=\"absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5\"\n              onClick={(e) => {\n                e.stopPropagation();\n                attachments.remove(data.id);\n              }}\n              type=\"button\"\n              variant=\"ghost\"\n            >\n              <XIcon />\n              <span className=\"sr-only\">Remove</span>\n            </Button>\n          </div>\n\n          <span className=\"flex-1 truncate\">{attachmentLabel}</span>\n        </div>\n      </HoverCardTrigger>\n      <PromptInputHoverCardContent className=\"w-auto p-2\">\n        <div className=\"w-auto space-y-3\">\n          {isImage && (\n            <div className=\"flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border\">\n              <img\n                alt={filename || \"attachment preview\"}\n                className=\"max-h-full max-w-full object-contain\"\n                height={384}\n                src={data.url}\n                width={448}\n              />\n            </div>\n          )}\n          <div className=\"flex items-center gap-2.5\">\n            <div className=\"min-w-0 flex-1 space-y-1 px-0.5\">\n              <h4 className=\"truncate font-semibold text-sm leading-none\">\n                {filename || (isImage ? \"Image\" : \"Attachment\")}\n              </h4>\n              {data.mediaType && (\n                <p className=\"truncate font-mono text-muted-foreground text-xs\">\n                  {data.mediaType}\n                </p>\n              )}\n            </div>\n          </div>\n        </div>\n      </PromptInputHoverCardContent>\n    </PromptInputHoverCard>\n  );\n}\n\nexport type PromptInputAttachmentsProps = Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> & {\n  children: (attachment: FileUIPart & { id: string }) => ReactNode;\n};\n\nexport function PromptInputAttachments({\n  children,\n  className,\n  ...props\n}: PromptInputAttachmentsProps) {\n  const attachments = usePromptInputAttachments();\n\n  if (!attachments.files.length) {\n    return null;\n  }\n\n  return (\n    <div\n      className={cn(\"flex flex-wrap items-center gap-2 p-3 w-full\", className)}\n      {...props}\n    >\n      {attachments.files.map((file) => (\n        <Fragment key={file.id}>{children(file)}</Fragment>\n      ))}\n    </div>\n  );\n}\n\nexport type PromptInputActionAddAttachmentsProps = ComponentProps<\n  typeof DropdownMenuItem\n> & {\n  label?: string;\n};\n\nexport const PromptInputActionAddAttachments = ({\n  label = \"Add photos or files\",\n  ...props\n}: PromptInputActionAddAttachmentsProps) => {\n  const attachments = usePromptInputAttachments();\n\n  return (\n    <DropdownMenuItem\n      {...props}\n      onSelect={(e) => {\n        e.preventDefault();\n        attachments.openFileDialog();\n      }}\n    >\n      <ImageIcon className=\"mr-2 size-4\" /> {label}\n    </DropdownMenuItem>\n  );\n};\n\nexport type PromptInputMessage = {\n  text: string;\n  files: FileUIPart[];\n};\n\nexport type PromptInputProps = Omit<\n  HTMLAttributes<HTMLFormElement>,\n  \"onSubmit\" | \"onError\"\n> & {\n  accept?: string; // e.g., \"image/*\" or leave undefined for any\n  multiple?: boolean;\n  // When true, accepts drops anywhere on document. Default false (opt-in).\n  globalDrop?: boolean;\n  // Render a hidden input with given name and keep it in sync for native form posts. Default false.\n  syncHiddenInput?: boolean;\n  // Minimal constraints\n  maxFiles?: number;\n  maxFileSize?: number; // bytes\n  onError?: (err: {\n    code: \"max_files\" | \"max_file_size\" | \"accept\";\n    message: string;\n  }) => void;\n  onSubmit: (\n    message: PromptInputMessage,\n    event: FormEvent<HTMLFormElement>\n  ) => void | Promise<void>;\n};\n\nexport const PromptInput = ({\n  className,\n  accept,\n  multiple,\n  globalDrop,\n  syncHiddenInput,\n  maxFiles,\n  maxFileSize,\n  onError,\n  onSubmit,\n  children,\n  ...props\n}: PromptInputProps) => {\n  // Try to use a provider controller if present\n  const controller = useOptionalPromptInputController();\n  const usingProvider = !!controller;\n\n  // Refs\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const formRef = useRef<HTMLFormElement | null>(null);\n\n  // ----- Local attachments (only used when no provider)\n  const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);\n  const files = usingProvider ? controller.attachments.files : items;\n\n  // Keep a ref to files for cleanup on unmount (avoids stale closure)\n  const filesRef = useRef(files);\n  filesRef.current = files;\n\n  const openFileDialogLocal = useCallback(() => {\n    inputRef.current?.click();\n  }, []);\n\n  const matchesAccept = useCallback(\n    (f: File) => {\n      if (!accept || accept.trim() === \"\") {\n        return true;\n      }\n      if (accept.includes(\"image/*\")) {\n        return f.type.startsWith(\"image/\");\n      }\n      // NOTE: keep simple; expand as needed\n      return true;\n    },\n    [accept]\n  );\n\n  const addLocal = useCallback(\n    (fileList: File[] | FileList) => {\n      const incoming = Array.from(fileList);\n      const accepted = incoming.filter((f) => matchesAccept(f));\n      if (incoming.length && accepted.length === 0) {\n        onError?.({\n          code: \"accept\",\n          message: \"No files match the accepted types.\",\n        });\n        return;\n      }\n      const withinSize = (f: File) =>\n        maxFileSize ? f.size <= maxFileSize : true;\n      const sized = accepted.filter(withinSize);\n      if (accepted.length > 0 && sized.length === 0) {\n        onError?.({\n          code: \"max_file_size\",\n          message: \"All files exceed the maximum size.\",\n        });\n        return;\n      }\n\n      setItems((prev) => {\n        const capacity =\n          typeof maxFiles === \"number\"\n            ? Math.max(0, maxFiles - prev.length)\n            : undefined;\n        const capped =\n          typeof capacity === \"number\" ? sized.slice(0, capacity) : sized;\n        if (typeof capacity === \"number\" && sized.length > capacity) {\n          onError?.({\n            code: \"max_files\",\n            message: \"Too many files. Some were not added.\",\n          });\n        }\n        const next: (FileUIPart & { id: string })[] = [];\n        for (const file of capped) {\n          next.push({\n            id: nanoid(),\n            type: \"file\",\n            url: URL.createObjectURL(file),\n            mediaType: file.type,\n            filename: file.name,\n          });\n        }\n        return prev.concat(next);\n      });\n    },\n    [matchesAccept, maxFiles, maxFileSize, onError]\n  );\n\n  const removeLocal = useCallback(\n    (id: string) =>\n      setItems((prev) => {\n        const found = prev.find((file) => file.id === id);\n        if (found?.url) {\n          URL.revokeObjectURL(found.url);\n        }\n        return prev.filter((file) => file.id !== id);\n      }),\n    []\n  );\n\n  const clearLocal = useCallback(\n    () =>\n      setItems((prev) => {\n        for (const file of prev) {\n          if (file.url) {\n            URL.revokeObjectURL(file.url);\n          }\n        }\n        return [];\n      }),\n    []\n  );\n\n  const add = usingProvider ? controller.attachments.add : addLocal;\n  const remove = usingProvider ? controller.attachments.remove : removeLocal;\n  const clear = usingProvider ? controller.attachments.clear : clearLocal;\n  const openFileDialog = usingProvider\n    ? controller.attachments.openFileDialog\n    : openFileDialogLocal;\n\n  // Let provider know about our hidden file input so external menus can call openFileDialog()\n  useEffect(() => {\n    if (!usingProvider) return;\n    controller.__registerFileInput(inputRef, () => inputRef.current?.click());\n  }, [usingProvider, controller]);\n\n  // Note: File input cannot be programmatically set for security reasons\n  // The syncHiddenInput prop is no longer functional\n  useEffect(() => {\n    if (syncHiddenInput && inputRef.current && files.length === 0) {\n      inputRef.current.value = \"\";\n    }\n  }, [files, syncHiddenInput]);\n\n  // Attach drop handlers on nearest form and document (opt-in)\n  useEffect(() => {\n    const form = formRef.current;\n    if (!form) return;\n\n    const onDragOver = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n    };\n    const onDrop = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {\n        add(e.dataTransfer.files);\n      }\n    };\n    form.addEventListener(\"dragover\", onDragOver);\n    form.addEventListener(\"drop\", onDrop);\n    return () => {\n      form.removeEventListener(\"dragover\", onDragOver);\n      form.removeEventListener(\"drop\", onDrop);\n    };\n  }, [add]);\n\n  useEffect(() => {\n    if (!globalDrop) return;\n\n    const onDragOver = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n    };\n    const onDrop = (e: DragEvent) => {\n      if (e.dataTransfer?.types?.includes(\"Files\")) {\n        e.preventDefault();\n      }\n      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {\n        add(e.dataTransfer.files);\n      }\n    };\n    document.addEventListener(\"dragover\", onDragOver);\n    document.addEventListener(\"drop\", onDrop);\n    return () => {\n      document.removeEventListener(\"dragover\", onDragOver);\n      document.removeEventListener(\"drop\", onDrop);\n    };\n  }, [add, globalDrop]);\n\n  useEffect(\n    () => () => {\n      if (!usingProvider) {\n        for (const f of filesRef.current) {\n          if (f.url) URL.revokeObjectURL(f.url);\n        }\n      }\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current\n    [usingProvider]\n  );\n\n  const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {\n    if (event.currentTarget.files) {\n      add(event.currentTarget.files);\n    }\n    // Reset input value to allow selecting files that were previously removed\n    event.currentTarget.value = \"\";\n  };\n\n  const convertBlobUrlToDataUrl = async (\n    url: string\n  ): Promise<string | null> => {\n    try {\n      const response = await fetch(url);\n      const blob = await response.blob();\n      return new Promise((resolve) => {\n        const reader = new FileReader();\n        reader.onloadend = () => resolve(reader.result as string);\n        reader.onerror = () => resolve(null);\n        reader.readAsDataURL(blob);\n      });\n    } catch {\n      return null;\n    }\n  };\n\n  const ctx = useMemo<AttachmentsContext>(\n    () => ({\n      files: files.map((item) => ({ ...item, id: item.id })),\n      add,\n      remove,\n      clear,\n      openFileDialog,\n      fileInputRef: inputRef,\n    }),\n    [files, add, remove, clear, openFileDialog]\n  );\n\n  const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {\n    event.preventDefault();\n\n    const form = event.currentTarget;\n    const text = usingProvider\n      ? controller.textInput.value\n      : (() => {\n          const formData = new FormData(form);\n          return (formData.get(\"message\") as string) || \"\";\n        })();\n\n    // Reset form immediately after capturing text to avoid race condition\n    // where user input during async blob conversion would be lost\n    if (!usingProvider) {\n      form.reset();\n    }\n\n    // Convert blob URLs to data URLs asynchronously\n    Promise.all(\n      files.map(async ({ id, ...item }) => {\n        if (item.url && item.url.startsWith(\"blob:\")) {\n          const dataUrl = await convertBlobUrlToDataUrl(item.url);\n          // If conversion failed, keep the original blob URL\n          return {\n            ...item,\n            url: dataUrl ?? item.url,\n          };\n        }\n        return item;\n      })\n    )\n      .then((convertedFiles: FileUIPart[]) => {\n        try {\n          const result = onSubmit({ text, files: convertedFiles }, event);\n\n          // Handle both sync and async onSubmit\n          if (result instanceof Promise) {\n            result\n              .then(() => {\n                clear();\n                if (usingProvider) {\n                  controller.textInput.clear();\n                }\n              })\n              .catch(() => {\n                // Don't clear on error - user may want to retry\n              });\n          } else {\n            // Sync function completed without throwing, clear attachments\n            clear();\n            if (usingProvider) {\n              controller.textInput.clear();\n            }\n          }\n        } catch {\n          // Don't clear on error - user may want to retry\n        }\n      })\n      .catch(() => {\n        // Don't clear on error - user may want to retry\n      });\n  };\n\n  // Render with or without local provider\n  const inner = (\n    <>\n      <input\n        accept={accept}\n        aria-label=\"Upload files\"\n        className=\"hidden\"\n        multiple={multiple}\n        onChange={handleChange}\n        ref={inputRef}\n        title=\"Upload files\"\n        type=\"file\"\n      />\n      <form\n        className={cn(\"w-full\", className)}\n        onSubmit={handleSubmit}\n        ref={formRef}\n        {...props}\n      >\n        <InputGroup className=\"overflow-hidden\">{children}</InputGroup>\n      </form>\n    </>\n  );\n\n  return usingProvider ? (\n    inner\n  ) : (\n    <LocalAttachmentsContext.Provider value={ctx}>\n      {inner}\n    </LocalAttachmentsContext.Provider>\n  );\n};\n\nexport type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputBody = ({\n  className,\n  ...props\n}: PromptInputBodyProps) => (\n  <div className={cn(\"contents\", className)} {...props} />\n);\n\nexport type PromptInputTextareaProps = ComponentProps<\n  typeof InputGroupTextarea\n>;\n\nexport const PromptInputTextarea = ({\n  onChange,\n  className,\n  placeholder = \"What would you like to know?\",\n  ...props\n}: PromptInputTextareaProps) => {\n  const controller = useOptionalPromptInputController();\n  const attachments = usePromptInputAttachments();\n  const [isComposing, setIsComposing] = useState(false);\n\n  const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {\n    if (e.key === \"Enter\") {\n      if (isComposing || e.nativeEvent.isComposing) {\n        return;\n      }\n      if (e.shiftKey) {\n        return;\n      }\n      e.preventDefault();\n\n      // Check if the submit button is disabled before submitting\n      const form = e.currentTarget.form;\n      const submitButton = form?.querySelector(\n        'button[type=\"submit\"]'\n      ) as HTMLButtonElement | null;\n      if (submitButton?.disabled) {\n        return;\n      }\n\n      form?.requestSubmit();\n    }\n\n    // Remove last attachment when Backspace is pressed and textarea is empty\n    if (\n      e.key === \"Backspace\" &&\n      e.currentTarget.value === \"\" &&\n      attachments.files.length > 0\n    ) {\n      e.preventDefault();\n      const lastAttachment = attachments.files.at(-1);\n      if (lastAttachment) {\n        attachments.remove(lastAttachment.id);\n      }\n    }\n  };\n\n  const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {\n    const items = event.clipboardData?.items;\n\n    if (!items) {\n      return;\n    }\n\n    const files: File[] = [];\n\n    for (const item of items) {\n      if (item.kind === \"file\") {\n        const file = item.getAsFile();\n        if (file) {\n          files.push(file);\n        }\n      }\n    }\n\n    if (files.length > 0) {\n      event.preventDefault();\n      attachments.add(files);\n    }\n  };\n\n  const controlledProps = controller\n    ? {\n        value: controller.textInput.value,\n        onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {\n          controller.textInput.setInput(e.currentTarget.value);\n          onChange?.(e);\n        },\n      }\n    : {\n        onChange,\n      };\n\n  return (\n    <InputGroupTextarea\n      className={cn(\"field-sizing-content max-h-48 min-h-16\", className)}\n      name=\"message\"\n      onCompositionEnd={() => setIsComposing(false)}\n      onCompositionStart={() => setIsComposing(true)}\n      onKeyDown={handleKeyDown}\n      onPaste={handlePaste}\n      placeholder={placeholder}\n      {...props}\n      {...controlledProps}\n    />\n  );\n};\n\nexport type PromptInputHeaderProps = Omit<\n  ComponentProps<typeof InputGroupAddon>,\n  \"align\"\n>;\n\nexport const PromptInputHeader = ({\n  className,\n  ...props\n}: PromptInputHeaderProps) => (\n  <InputGroupAddon\n    align=\"block-end\"\n    className={cn(\"order-first flex-wrap gap-1\", className)}\n    {...props}\n  />\n);\n\nexport type PromptInputFooterProps = Omit<\n  ComponentProps<typeof InputGroupAddon>,\n  \"align\"\n>;\n\nexport const PromptInputFooter = ({\n  className,\n  ...props\n}: PromptInputFooterProps) => (\n  <InputGroupAddon\n    align=\"block-end\"\n    className={cn(\"justify-between gap-1\", className)}\n    {...props}\n  />\n);\n\nexport type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTools = ({\n  className,\n  ...props\n}: PromptInputToolsProps) => (\n  <div className={cn(\"flex items-center gap-1\", className)} {...props} />\n);\n\nexport type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;\n\nexport const PromptInputButton = ({\n  variant = \"ghost\",\n  className,\n  size,\n  ...props\n}: PromptInputButtonProps) => {\n  const newSize =\n    size ?? (Children.count(props.children) > 1 ? \"sm\" : \"icon-sm\");\n\n  return (\n    <InputGroupButton\n      className={cn(className)}\n      size={newSize}\n      type=\"button\"\n      variant={variant}\n      {...props}\n    />\n  );\n};\n\nexport type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;\nexport const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (\n  <DropdownMenu {...props} />\n);\n\nexport type PromptInputActionMenuTriggerProps = PromptInputButtonProps;\n\nexport const PromptInputActionMenuTrigger = ({\n  className,\n  children,\n  ...props\n}: PromptInputActionMenuTriggerProps) => (\n  <DropdownMenuTrigger asChild>\n    <PromptInputButton className={className} {...props}>\n      {children ?? <PlusIcon className=\"size-4\" />}\n    </PromptInputButton>\n  </DropdownMenuTrigger>\n);\n\nexport type PromptInputActionMenuContentProps = ComponentProps<\n  typeof DropdownMenuContent\n>;\nexport const PromptInputActionMenuContent = ({\n  className,\n  ...props\n}: PromptInputActionMenuContentProps) => (\n  <DropdownMenuContent align=\"start\" className={cn(className)} {...props} />\n);\n\nexport type PromptInputActionMenuItemProps = ComponentProps<\n  typeof DropdownMenuItem\n>;\nexport const PromptInputActionMenuItem = ({\n  className,\n  ...props\n}: PromptInputActionMenuItemProps) => (\n  <DropdownMenuItem className={cn(className)} {...props} />\n);\n\n// Note: Actions that perform side-effects (like opening a file dialog)\n// are provided in opt-in modules (e.g., prompt-input-attachments).\n\nexport type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {\n  status?: ChatStatus;\n};\n\nexport const PromptInputSubmit = ({\n  className,\n  variant = \"default\",\n  size = \"icon-sm\",\n  status,\n  children,\n  ...props\n}: PromptInputSubmitProps) => {\n  let Icon = <CornerDownLeftIcon className=\"size-4\" />;\n\n  if (status === \"submitted\") {\n    Icon = <Loader2Icon className=\"size-4 animate-spin\" />;\n  } else if (status === \"streaming\") {\n    Icon = <SquareIcon className=\"size-4\" />;\n  } else if (status === \"error\") {\n    Icon = <XIcon className=\"size-4\" />;\n  }\n\n  return (\n    <InputGroupButton\n      aria-label=\"Submit\"\n      className={cn(className)}\n      size={size}\n      type=\"submit\"\n      variant={variant}\n      {...props}\n    >\n      {children ?? Icon}\n    </InputGroupButton>\n  );\n};\n\ninterface SpeechRecognition extends EventTarget {\n  continuous: boolean;\n  interimResults: boolean;\n  lang: string;\n  start(): void;\n  stop(): void;\n  onstart: ((this: SpeechRecognition, ev: Event) => any) | null;\n  onend: ((this: SpeechRecognition, ev: Event) => any) | null;\n  onresult:\n    | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)\n    | null;\n  onerror:\n    | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)\n    | null;\n}\n\ninterface SpeechRecognitionEvent extends Event {\n  results: SpeechRecognitionResultList;\n  resultIndex: number;\n}\n\ntype SpeechRecognitionResultList = {\n  readonly length: number;\n  item(index: number): SpeechRecognitionResult;\n  [index: number]: SpeechRecognitionResult;\n};\n\ntype SpeechRecognitionResult = {\n  readonly length: number;\n  item(index: number): SpeechRecognitionAlternative;\n  [index: number]: SpeechRecognitionAlternative;\n  isFinal: boolean;\n};\n\ntype SpeechRecognitionAlternative = {\n  transcript: string;\n  confidence: number;\n};\n\ninterface SpeechRecognitionErrorEvent extends Event {\n  error: string;\n}\n\ndeclare global {\n  interface Window {\n    SpeechRecognition: {\n      new (): SpeechRecognition;\n    };\n    webkitSpeechRecognition: {\n      new (): SpeechRecognition;\n    };\n  }\n}\n\nexport type PromptInputSpeechButtonProps = ComponentProps<\n  typeof PromptInputButton\n> & {\n  textareaRef?: RefObject<HTMLTextAreaElement | null>;\n  onTranscriptionChange?: (text: string) => void;\n};\n\nexport const PromptInputSpeechButton = ({\n  className,\n  textareaRef,\n  onTranscriptionChange,\n  ...props\n}: PromptInputSpeechButtonProps) => {\n  const [isListening, setIsListening] = useState(false);\n  const [recognition, setRecognition] = useState<SpeechRecognition | null>(\n    null\n  );\n  const recognitionRef = useRef<SpeechRecognition | null>(null);\n\n  useEffect(() => {\n    if (\n      typeof window !== \"undefined\" &&\n      (\"SpeechRecognition\" in window || \"webkitSpeechRecognition\" in window)\n    ) {\n      const SpeechRecognition =\n        window.SpeechRecognition || window.webkitSpeechRecognition;\n      const speechRecognition = new SpeechRecognition();\n\n      speechRecognition.continuous = true;\n      speechRecognition.interimResults = true;\n      speechRecognition.lang = \"en-US\";\n\n      speechRecognition.onstart = () => {\n        setIsListening(true);\n      };\n\n      speechRecognition.onend = () => {\n        setIsListening(false);\n      };\n\n      speechRecognition.onresult = (event) => {\n        let finalTranscript = \"\";\n\n        for (let i = event.resultIndex; i < event.results.length; i++) {\n          const result = event.results[i];\n          if (result.isFinal) {\n            finalTranscript += result[0]?.transcript ?? \"\";\n          }\n        }\n\n        if (finalTranscript && textareaRef?.current) {\n          const textarea = textareaRef.current;\n          const currentValue = textarea.value;\n          const newValue =\n            currentValue + (currentValue ? \" \" : \"\") + finalTranscript;\n\n          textarea.value = newValue;\n          textarea.dispatchEvent(new Event(\"input\", { bubbles: true }));\n          onTranscriptionChange?.(newValue);\n        }\n      };\n\n      speechRecognition.onerror = (event) => {\n        console.error(\"Speech recognition error:\", event.error);\n        setIsListening(false);\n      };\n\n      recognitionRef.current = speechRecognition;\n      setRecognition(speechRecognition);\n    }\n\n    return () => {\n      if (recognitionRef.current) {\n        recognitionRef.current.stop();\n      }\n    };\n  }, [textareaRef, onTranscriptionChange]);\n\n  const toggleListening = useCallback(() => {\n    if (!recognition) {\n      return;\n    }\n\n    if (isListening) {\n      recognition.stop();\n    } else {\n      recognition.start();\n    }\n  }, [recognition, isListening]);\n\n  return (\n    <PromptInputButton\n      className={cn(\n        \"relative transition-all duration-200\",\n        isListening && \"animate-pulse bg-accent text-accent-foreground\",\n        className\n      )}\n      disabled={!recognition}\n      onClick={toggleListening}\n      {...props}\n    >\n      <MicIcon className=\"size-4\" />\n    </PromptInputButton>\n  );\n};\n\nexport type PromptInputSelectProps = ComponentProps<typeof Select>;\n\nexport const PromptInputSelect = (props: PromptInputSelectProps) => (\n  <Select {...props} />\n);\n\nexport type PromptInputSelectTriggerProps = ComponentProps<\n  typeof SelectTrigger\n>;\n\nexport const PromptInputSelectTrigger = ({\n  className,\n  ...props\n}: PromptInputSelectTriggerProps) => (\n  <SelectTrigger\n    className={cn(\n      \"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors\",\n      \"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground\",\n      className\n    )}\n    {...props}\n  />\n);\n\nexport type PromptInputSelectContentProps = ComponentProps<\n  typeof SelectContent\n>;\n\nexport const PromptInputSelectContent = ({\n  className,\n  ...props\n}: PromptInputSelectContentProps) => (\n  <SelectContent className={cn(className)} {...props} />\n);\n\nexport type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;\n\nexport const PromptInputSelectItem = ({\n  className,\n  ...props\n}: PromptInputSelectItemProps) => (\n  <SelectItem className={cn(className)} {...props} />\n);\n\nexport type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;\n\nexport const PromptInputSelectValue = ({\n  className,\n  ...props\n}: PromptInputSelectValueProps) => (\n  <SelectValue className={cn(className)} {...props} />\n);\n\nexport type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;\n\nexport const PromptInputHoverCard = ({\n  openDelay = 0,\n  closeDelay = 0,\n  ...props\n}: PromptInputHoverCardProps) => (\n  <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />\n);\n\nexport type PromptInputHoverCardTriggerProps = ComponentProps<\n  typeof HoverCardTrigger\n>;\n\nexport const PromptInputHoverCardTrigger = (\n  props: PromptInputHoverCardTriggerProps\n) => <HoverCardTrigger {...props} />;\n\nexport type PromptInputHoverCardContentProps = ComponentProps<\n  typeof HoverCardContent\n>;\n\nexport const PromptInputHoverCardContent = ({\n  align = \"start\",\n  ...props\n}: PromptInputHoverCardContentProps) => (\n  <HoverCardContent align={align} {...props} />\n);\n\nexport type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTabsList = ({\n  className,\n  ...props\n}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;\n\nexport type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTab = ({\n  className,\n  ...props\n}: PromptInputTabProps) => <div className={cn(className)} {...props} />;\n\nexport type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;\n\nexport const PromptInputTabLabel = ({\n  className,\n  ...props\n}: PromptInputTabLabelProps) => (\n  <h3\n    className={cn(\n      \"mb-2 px-3 font-medium text-muted-foreground text-xs\",\n      className\n    )}\n    {...props}\n  />\n);\n\nexport type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTabBody = ({\n  className,\n  ...props\n}: PromptInputTabBodyProps) => (\n  <div className={cn(\"space-y-1\", className)} {...props} />\n);\n\nexport type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;\n\nexport const PromptInputTabItem = ({\n  className,\n  ...props\n}: PromptInputTabItemProps) => (\n  <div\n    className={cn(\n      \"flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent\",\n      className\n    )}\n    {...props}\n  />\n);\n\nexport type PromptInputCommandProps = ComponentProps<typeof Command>;\n\nexport const PromptInputCommand = ({\n  className,\n  ...props\n}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;\n\nexport type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;\n\nexport const PromptInputCommandInput = ({\n  className,\n  ...props\n}: PromptInputCommandInputProps) => (\n  <CommandInput className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandListProps = ComponentProps<typeof CommandList>;\n\nexport const PromptInputCommandList = ({\n  className,\n  ...props\n}: PromptInputCommandListProps) => (\n  <CommandList className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;\n\nexport const PromptInputCommandEmpty = ({\n  className,\n  ...props\n}: PromptInputCommandEmptyProps) => (\n  <CommandEmpty className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;\n\nexport const PromptInputCommandGroup = ({\n  className,\n  ...props\n}: PromptInputCommandGroupProps) => (\n  <CommandGroup className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;\n\nexport const PromptInputCommandItem = ({\n  className,\n  ...props\n}: PromptInputCommandItemProps) => (\n  <CommandItem className={cn(className)} {...props} />\n);\n\nexport type PromptInputCommandSeparatorProps = ComponentProps<\n  typeof CommandSeparator\n>;\n\nexport const PromptInputCommandSeparator = ({\n  className,\n  ...props\n}: PromptInputCommandSeparatorProps) => (\n  <CommandSeparator className={cn(className)} {...props} />\n);\n"
  },
  {
    "path": "components/ai-elements/shimmer.tsx",
    "content": "\"use client\";\n\nimport {\n  type CSSProperties,\n  type ElementType,\n  type JSX,\n  memo,\n  useMemo,\n} from \"react\";\n\nimport { motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type TextShimmerProps = {\n  children: string;\n  as?: ElementType;\n  className?: string;\n  duration?: number;\n  spread?: number;\n  hoverOnly?: boolean;\n};\n\nconst ShimmerComponent = ({\n  children,\n  as: Component = \"p\",\n  className,\n  duration = 2,\n  spread = 2,\n  hoverOnly = false,\n}: TextShimmerProps) => {\n  const MotionComponent = motion.create(\n    Component as keyof JSX.IntrinsicElements,\n  );\n\n  const dynamicSpread = useMemo(\n    () => (children?.length ?? 0) * spread,\n    [children, spread],\n  );\n\n  const animationProps = hoverOnly\n    ? {\n        initial: { backgroundPosition: \"100% center\" },\n        whileHover: { backgroundPosition: \"0% center\" },\n      }\n    : {\n        initial: { backgroundPosition: \"100% center\" },\n        animate: { backgroundPosition: \"0% center\" },\n      };\n\n  const transitionProps = hoverOnly\n    ? {\n        duration,\n        ease: \"linear\" as const,\n      }\n    : {\n        repeat: Number.POSITIVE_INFINITY,\n        duration,\n        ease: \"linear\" as const,\n      };\n\n  return (\n    <MotionComponent\n      {...animationProps}\n      className={cn(\n        \"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent\",\n        \"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]\",\n        className,\n      )}\n      style={\n        {\n          \"--spread\": `${dynamicSpread}px`,\n          backgroundImage:\n            \"var(--bg), linear-gradient(var(--muted-foreground), var(--muted-foreground))\",\n        } as CSSProperties\n      }\n      transition={transitionProps}\n    >\n      {children}\n    </MotionComponent>\n  );\n};\n\nexport const Shimmer = memo(ShimmerComponent);\n"
  },
  {
    "path": "components/analytics/analytics-card.tsx",
    "content": "import { ReactNode } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface AnalyticsCardProps {\n  title: string;\n  icon?: ReactNode;\n  columnHeaders?: {\n    label: string;\n    width?: string;\n  }[];\n  children: ReactNode;\n  className?: string;\n  contentClassName?: string;\n}\n\nexport function AnalyticsCard({\n  title,\n  icon,\n  columnHeaders,\n  children,\n  className,\n  contentClassName,\n}: AnalyticsCardProps) {\n  return (\n    <div\n      className={cn(\n        \"relative z-0 overflow-hidden border border-border bg-card sm:rounded-xl\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center justify-between border-b border-border py-4 pl-5 pr-4\">\n        <h3 className=\"text-sm font-medium\">{title}</h3>\n        {columnHeaders ? (\n          <div className=\"flex items-center justify-end space-x-1 text-sm text-muted-foreground\">\n            {columnHeaders.map((header, index) => (\n              <div key={index} className={cn(\"text-xs\", header.width)}>\n                {header.label}\n              </div>\n            ))}\n          </div>\n        ) : icon ? (\n          <div className=\"flex items-center gap-1 text-muted-foreground\">\n            {icon}\n          </div>\n        ) : null}\n      </div>\n      <div className={cn(\"py-4\", contentClassName)}>{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/analytics/dashboard-views-chart.tsx",
    "content": "import { useMemo } from \"react\";\n\nimport { format } from \"date-fns\";\nimport {\n  Bar,\n  BarChart,\n  CartesianGrid,\n  ResponsiveContainer,\n  Tooltip,\n  XAxis,\n  YAxis,\n} from \"recharts\";\n\nimport { TimeRange } from \"./time-range-select\";\n\ninterface DashboardViewsChartProps {\n  timeRange: TimeRange;\n  data?: { date: string; views: number }[];\n  startDate?: Date;\n  endDate?: Date;\n}\n\nexport default function DashboardViewsChart({\n  timeRange,\n  data = [],\n  startDate,\n  endDate,\n}: DashboardViewsChartProps) {\n  const totalDays =\n    startDate && endDate\n      ? Math.ceil(\n          (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),\n        )\n      : 0;\n  // Format the data for display\n  const formattedData = useMemo(() => {\n    // Generate all possible time slots\n    const now = new Date();\n    const slots: { date: Date; views: number }[] = [];\n    if (timeRange === \"custom\" && startDate && endDate) {\n      if (totalDays > 365) {\n        // More than a year: Group by months\n        let current = new Date(\n          startDate.getFullYear(),\n          startDate.getMonth(),\n          1,\n        );\n\n        while (current <= endDate) {\n          slots.push({ date: new Date(current), views: 0 });\n          current.setMonth(current.getMonth() + 1);\n        }\n      } else if (totalDays > 30) {\n        // More than a month but less than a year: Group by weeks\n        for (let i = 0; i <= totalDays; i += 7) {\n          const date = new Date(startDate);\n          date.setDate(date.getDate() + i);\n          date.setHours(0, 0, 0, 0);\n          slots.push({ date, views: 0 });\n        }\n      } else {\n        // Less than a month: Show daily data\n        for (let i = 0; i <= totalDays; i++) {\n          const date = new Date(startDate);\n          date.setDate(date.getDate() + i);\n          date.setHours(0, 0, 0, 0);\n          slots.push({ date, views: 0 });\n        }\n      }\n    } else if (timeRange === \"24h\") {\n      // Generate 24 hourly slots\n      for (let i = 23; i >= 0; i--) {\n        const date = new Date(now);\n        date.setHours(date.getHours() - i);\n        date.setMinutes(0, 0, 0); // Reset minutes, seconds, milliseconds\n        slots.push({ date, views: 0 });\n      }\n    } else {\n      // Generate daily slots for 7d or 30d\n      const days = timeRange === \"7d\" ? 7 : 30;\n      for (let i = days - 1; i >= 0; i--) {\n        const date = new Date(now);\n        date.setDate(date.getDate() - i);\n        date.setHours(0, 0, 0, 0); // Reset hours, minutes, seconds, milliseconds\n        slots.push({ date, views: 0 });\n      }\n    }\n\n    // Fill in actual data points\n    if (data) {\n      data.forEach((point) => {\n        const pointDate = new Date(point.date);\n\n        let slotIndex = -1;\n\n        if (timeRange === \"24h\") {\n          slotIndex = slots.findIndex(\n            (slot) => slot.date.getHours() === pointDate.getHours(),\n          );\n        } else if (timeRange === \"custom\") {\n          if (totalDays > 365) {\n            // If range is more than a year, match by month\n            slotIndex = slots.findIndex(\n              (slot) =>\n                slot.date.getFullYear() === pointDate.getFullYear() &&\n                slot.date.getMonth() === pointDate.getMonth(),\n            );\n          } else if (totalDays > 30) {\n            // If range is more than a month but less than a year, match by week\n            slotIndex = slots.findIndex(\n              (slot) =>\n                pointDate >= slot.date &&\n                pointDate <\n                  new Date(slot.date.getTime() + 7 * 24 * 60 * 60 * 1000), // Within the week\n            );\n          } else {\n            // If range is less than a month, match by exact day\n            slotIndex = slots.findIndex(\n              (slot) => slot.date.toDateString() === pointDate.toDateString(),\n            );\n          }\n        } else {\n          // Default case: match by exact day for '7d' and '30d'\n          slotIndex = slots.findIndex(\n            (slot) => slot.date.toDateString() === pointDate.toDateString(),\n          );\n        }\n\n        if (slotIndex !== -1) {\n          slots[slotIndex].views += point.views;\n        }\n      });\n    }\n\n    // Format for display\n    return slots.map((slot) => ({\n      date: slot.date,\n      name: format(\n        slot.date,\n        timeRange === \"24h\"\n          ? \"h:mm aa\"\n          : totalDays > 365\n            ? \"MMM yyyy\"\n            : totalDays > 30\n              ? \"MMM d\"\n              : \"EEE, MMM d\",\n      ),\n      views: slot.views,\n    }));\n  }, [data, timeRange, startDate, endDate, totalDays]);\n\n  // Calculate tick values based on time range\n  const ticks = useMemo(() => {\n    if (!formattedData.length) return [];\n\n    if (timeRange === \"24h\") {\n      // Show current hour and every 5th hour working backwards\n      const tickIndices = [];\n      for (let i = formattedData.length - 1; i >= 0; i -= 5) {\n        tickIndices.unshift(i);\n      }\n      return tickIndices.map((i) => formattedData[i].name);\n    } else if (timeRange === \"7d\") {\n      // Show all days\n      return formattedData.map((d) => d.name);\n    } else if (timeRange === \"30d\") {\n      // Show current day and every 5th day working backwards\n      const tickIndices = [];\n      for (let i = formattedData.length - 1; i >= 0; i -= 5) {\n        tickIndices.unshift(i);\n      }\n      return tickIndices.map((i) => formattedData[i].name);\n    } else if (timeRange === \"custom\") {\n      if (totalDays > 365) {\n        // Show every 2rd month\n        return formattedData.filter((_, i) => i % 2 === 0).map((d) => d.name);\n      }\n\n      if (totalDays > 30) {\n        // Show every 2nd week\n        return formattedData.filter((_, i) => i % 2 === 0).map((d) => d.name);\n      }\n      return formattedData.map((d) => d.name);\n    }\n    return formattedData.map((d) => d.name);\n  }, [timeRange, formattedData, totalDays]);\n\n  const barSize = useMemo(() => {\n    if (timeRange === \"24h\") return 8;\n    if (timeRange === \"7d\") return 24;\n    if (timeRange === \"30d\") return 12;\n\n    if (startDate && endDate) {\n      if (totalDays > 365) return 24;\n      if (totalDays > 30) return 16;\n    }\n\n    return 12;\n  }, [timeRange, startDate, endDate, totalDays]);\n\n  return (\n    <div className=\"h-[300px] w-full\">\n      <ResponsiveContainer width=\"100%\" height=\"100%\">\n        <BarChart\n          data={formattedData}\n          margin={{ top: 10, right: 30, left: 0, bottom: 0 }}\n          barSize={barSize}\n        >\n          <XAxis\n            dataKey=\"name\"\n            stroke=\"#888888\"\n            fontSize={12}\n            tickLine={false}\n            axisLine={false}\n            ticks={ticks}\n            interval=\"preserveStartEnd\"\n          />\n          <YAxis\n            stroke=\"#888888\"\n            fontSize={12}\n            tickLine={false}\n            axisLine={false}\n            tickFormatter={(value) => `${value}`}\n          />\n          <CartesianGrid vertical={false} strokeDasharray=\"3 3\" />\n          <Tooltip\n            content={({ active, payload }) => {\n              if (active && payload && payload.length) {\n                const data = payload[0].payload;\n                return (\n                  <div className=\"rounded-lg border bg-background p-2 shadow-sm\">\n                    <div className=\"grid grid-cols-2 gap-2\">\n                      <div className=\"flex flex-col\">\n                        <span className=\"text-[0.70rem] uppercase text-muted-foreground\">\n                          Time\n                        </span>\n                        <span className=\"font-bold text-muted-foreground\">\n                          {format(\n                            data.date,\n                            timeRange === \"24h\"\n                              ? \"h:mm aa\"\n                              : totalDays > 365\n                                ? \"MMM yyyy\"\n                                : totalDays > 30\n                                  ? \"'Week of' MMM d\"\n                                  : \"MMM d, yyyy\",\n                          )}\n                        </span>\n                      </div>\n                      <div className=\"flex flex-col\">\n                        <span className=\"text-[0.70rem] uppercase text-muted-foreground\">\n                          Views\n                        </span>\n                        <span className=\"font-bold\">{data.views}</span>\n                      </div>\n                    </div>\n                  </div>\n                );\n              }\n              return null;\n            }}\n          />\n          <Bar\n            dataKey=\"views\"\n            fill=\"rgb(16 185 129)\"\n            stroke=\"rgb(16 185 129)\"\n            strokeWidth={1}\n            radius={[2, 2, 0, 0]}\n          />\n        </BarChart>\n      </ResponsiveContainer>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/analytics/documents-table.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  ColumnDef,\n  SortingState,\n  flexRender,\n  getCoreRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport {\n  ChevronDownIcon,\n  ChevronUpIcon,\n  ChevronsUpDownIcon,\n  Download,\n  FileIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { DataTablePagination } from \"@/components/visitors/data-table-pagination\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { fetcher, timeAgo } from \"@/lib/utils\";\nimport { downloadCSV } from \"@/lib/utils/csv\";\nimport { UpgradeButton } from \"../ui/upgrade-button\";\n\n\ninterface Document {\n  id: string;\n  name: string;\n  views: number;\n  avgDuration: string;\n  lastViewed: Date | null;\n}\n\nconst columns: ColumnDef<Document>[] = [\n  {\n    accessorKey: \"name\",\n    header: \"Documents\",\n    cell: ({ row }) => (\n      <div className=\"flex items-center overflow-visible sm:space-x-3\">\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"focus:outline-none\">\n            <Link\n              href={`/documents/${row.original.id}`}\n              className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 hover:text-gray-600 dark:text-gray-200 dark:hover:text-gray-400\"\n            >\n              {row.original.name}\n            </Link>\n          </div>\n        </div>\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"views\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\"\n          }\n        >\n          Views\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">{row.original.views}</div>\n    ),\n  },\n  {\n    accessorKey: \"avgDuration\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\"\n          }\n        >\n          Avg Duration\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">\n        {row.original.avgDuration}\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"lastViewed\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\"\n          }\n        >\n          Last Viewed\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) =>\n      row.original.lastViewed ? (\n        <TimestampTooltip\n          timestamp={row.original.lastViewed}\n          side=\"right\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <div className=\"select-none text-sm text-muted-foreground\">\n            {timeAgo(row.original.lastViewed)}\n          </div>\n        </TimestampTooltip>\n      ) : (\n        <div className=\"text-sm text-muted-foreground\">-</div>\n      ),\n  },\n];\n\nexport default function DocumentsTable({\n  startDate,\n  endDate,\n}: {\n  startDate: Date;\n  endDate: Date;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { isTrial, isFree } = usePlan();\n  const [sorting, setSorting] = useState<SortingState>([\n    { id: \"lastViewed\", desc: true },\n  ]);\n\n  const interval = router.query.interval || \"24h\";\n  const { data: documents, isLoading } = useSWR<Document[]>(\n    teamInfo?.currentTeam?.id\n      ? `/api/analytics?type=documents&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === \"custom\" ? `&startDate=${format(startDate, \"MM-dd-yyyy\")}&endDate=${format(endDate, \"MM-dd-yyyy\")}` : \"\"}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  const table = useReactTable({\n    data: documents || [],\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getPaginationRowModel: getPaginationRowModel(),\n    onSortingChange: setSorting,\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n      sorting,\n    },\n  });\n\n  const handleExport = () => {\n    if (isFree && !isTrial) {\n      toast.error(\"Please upgrade to export data\");\n      return;\n    }\n\n    if (!documents?.length) {\n      toast.error(\"No data to export\");\n      return;\n    }\n\n    const exportData = documents.map((doc) => ({\n      \"Document Name\": doc.name,\n      Views: doc.views,\n      \"Average Duration\": doc.avgDuration,\n      \"Last Viewed\": doc.lastViewed\n        ? new Date(doc.lastViewed).toISOString()\n        : \"Never\",\n    }));\n\n    downloadCSV(exportData, \"documents\");\n  };\n\n  const UpgradeOrExportButton = () => {\n    if (isFree && !isTrial) {\n      return (\n        <UpgradeButton\n          text=\"Export\"\n          clickedPlan={PlanEnum.Pro}\n          trigger=\"dashboard_documents_export\"\n          variant=\"outline\"\n          size=\"sm\"\n        />\n      );\n    } else {\n      return (\n        <Button variant=\"outline\" size=\"sm\" onClick={handleExport}>\n          <Download className=\"!size-4\" />\n          Export\n        </Button>\n      );\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex justify-end\">\n        <UpgradeOrExportButton />\n      </div>\n      <div className=\"overflow-x-auto rounded-xl border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id} className=\"px-0 first:px-4\">\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext(),\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {isLoading ? (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  Loading...\n                </TableCell>\n              </TableRow>\n            ) : table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow key={row.id}>\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext(),\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  <div className=\"flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4\">\n                    <div className=\"hidden rounded-full sm:block\">\n                      <div className=\"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3\">\n                        <FileIcon className=\"size-6\" />\n                      </div>\n                    </div>\n                    <p>No visited documents in the last {interval}</p>\n                  </div>\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n      <DataTablePagination table={table} name=\"document\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/analytics/links-table.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  ColumnDef,\n  SortingState,\n  flexRender,\n  getCoreRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport {\n  Check,\n  ChevronDownIcon,\n  ChevronUpIcon,\n  ChevronsUpDownIcon,\n  Copy,\n  Download,\n  Link2Icon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { cn, timeAgo } from \"@/lib/utils\";\nimport { fetcher } from \"@/lib/utils\";\nimport { downloadCSV } from \"@/lib/utils/csv\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { DataTablePagination } from \"@/components/visitors/data-table-pagination\";\n\nimport { UpgradeButton } from \"../ui/upgrade-button\";\n\ninterface Link {\n  id: string;\n  name: string;\n  url: string;\n  documentName: string;\n  documentId: string;\n  views: number;\n  avgDuration: string;\n  lastViewed: Date | null;\n}\n\nfunction CopyButton({ url }: { url: string }) {\n  const [copied, setCopied] = useState(false);\n\n  useEffect(() => {\n    if (copied) {\n      toast.success(\"Link copied to clipboard\");\n      const timeout = setTimeout(() => setCopied(false), 2000);\n      return () => clearTimeout(timeout);\n    }\n  }, [copied]);\n\n  return (\n    <Button\n      variant=\"ghost\"\n      size=\"icon\"\n      className=\"size-6 hover:bg-transparent\"\n      onClick={() => {\n        navigator.clipboard.writeText(url);\n        setCopied(true);\n      }}\n    >\n      {copied ? (\n        <Check className=\"!size-4 text-emerald-500\" />\n      ) : (\n        <Copy className=\"!size-4 text-muted-foreground\" />\n      )}\n    </Button>\n  );\n}\n\nconst columns: ColumnDef<Link>[] = [\n  {\n    accessorKey: \"name\",\n    header: \"Links\",\n    cell: ({ row }) => (\n      <div className=\"flex items-center overflow-visible sm:space-x-3\">\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"focus:outline-none\">\n            <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n              {row.original.name}\n            </p>\n            <div className=\"flex items-center gap-x-1\">\n              <p className=\"text-sm text-muted-foreground\">\n                {row.original.url}\n              </p>\n              <CopyButton url={row.original.url} />\n            </div>\n          </div>\n        </div>\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"documentName\",\n    header: \"Document\",\n    cell: ({ row }) => (\n      <div className=\"text-sm\">\n        {row.original.documentId ? (\n          <Link\n            href={`/documents/${row.original.documentId}`}\n            className=\"flex items-center gap-x-2 overflow-visible text-sm text-gray-800 hover:text-gray-600 dark:text-gray-200 dark:hover:text-gray-400\"\n          >\n            {row.original.documentName}\n          </Link>\n        ) : (\n          <span className=\"text-muted-foreground\">\n            {row.original.documentName}\n          </span>\n        )}\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"views\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={cn(\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\",\n            \"px-0\",\n          )}\n        >\n          Views\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">{row.original.views}</div>\n    ),\n  },\n  {\n    accessorKey: \"avgDuration\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={cn(\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\",\n            \"px-0\",\n          )}\n        >\n          Avg Duration\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">\n        {row.original.avgDuration}\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"lastViewed\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={cn(\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\",\n            \"px-0\",\n          )}\n        >\n          Last Viewed\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) =>\n      row.original.lastViewed ? (\n        <TimestampTooltip\n          timestamp={row.original.lastViewed}\n          side=\"right\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <div className=\"select-none text-sm text-muted-foreground\">\n            {timeAgo(row.original.lastViewed)}\n          </div>\n        </TimestampTooltip>\n      ) : (\n        <div className=\"text-sm text-muted-foreground\">-</div>\n      ),\n  },\n];\n\nexport default function LinksTable({\n  startDate,\n  endDate,\n}: {\n  startDate: Date;\n  endDate: Date;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { isTrial, isFree } = usePlan();\n  const [sorting, setSorting] = useState<SortingState>([\n    { id: \"lastViewed\", desc: true },\n  ]);\n\n  const interval = router.query.interval || \"7d\";\n  const { data: links, isLoading } = useSWR<Link[]>(\n    teamInfo?.currentTeam?.id\n      ? `/api/analytics?type=links&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === \"custom\" ? `&startDate=${format(startDate, \"MM-dd-yyyy\")}&endDate=${format(endDate, \"MM-dd-yyyy\")}` : \"\"}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  const table = useReactTable({\n    data: links || [],\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getPaginationRowModel: getPaginationRowModel(),\n    onSortingChange: setSorting,\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n      sorting,\n    },\n  });\n\n  const handleExport = () => {\n    if (isFree && !isTrial) {\n      toast.error(\"Please upgrade to export data\");\n      return;\n    }\n\n    if (!links?.length) {\n      toast.error(\"No data to export\");\n      return;\n    }\n\n    const exportData = links.map((link) => ({\n      \"Link Name\": link.name,\n      URL: link.url,\n      Document: link.documentName,\n      Views: link.views,\n      \"Average Duration\": link.avgDuration,\n      \"Last Viewed\": link.lastViewed\n        ? new Date(link.lastViewed).toISOString()\n        : \"Never\",\n    }));\n\n    downloadCSV(exportData, \"links\");\n  };\n\n  const UpgradeOrExportButton = () => {\n    if (isFree && !isTrial) {\n      return (\n        <UpgradeButton\n          text=\"Export\"\n          clickedPlan={PlanEnum.Pro}\n          trigger=\"dashboard_links_export\"\n          variant=\"outline\"\n          size=\"sm\"\n        />\n      );\n    } else {\n      return (\n        <Button variant=\"outline\" size=\"sm\" onClick={handleExport}>\n          <Download className=\"!size-4\" />\n          Export\n        </Button>\n      );\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex justify-end\">\n        <UpgradeOrExportButton />\n      </div>\n      <div className=\"overflow-x-auto rounded-xl border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id} className=\"px-4\">\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext(),\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {isLoading ? (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  Loading...\n                </TableCell>\n              </TableRow>\n            ) : table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow key={row.id}>\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext(),\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  <div className=\"flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4\">\n                    <div className=\"hidden rounded-full sm:block\">\n                      <div className=\"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3\">\n                        <Link2Icon className=\"size-6\" />\n                      </div>\n                    </div>\n                    <p>\n                      No visited links in the last{\" \"}\n                      {interval === \"custom\"\n                        ? `From ${format(startDate, \"PP\")} to ${format(endDate, \"PP\")}`\n                        : interval}\n                    </p>\n                  </div>\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n      <DataTablePagination table={table} name=\"link\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/analytics/time-range-select.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { differenceInDays, format, startOfDay, subDays } from \"date-fns\";\nimport { CalendarIcon, ChevronDown, CrownIcon } from \"lucide-react\";\nimport { DateRange } from \"react-day-picker\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\n\nconst TIME_RANGES = [\n  { value: \"24h\", label: \"Last 24 hours\", shortcut: \"D\" },\n  { value: \"7d\", label: \"Last 7 days\", shortcut: \"W\" },\n  { value: \"30d\", label: \"Last 30 days\", shortcut: \"M\" },\n  { value: \"custom\", label: \"Custom Date\", shortcut: \"C\" },\n] as const;\n\nexport type TimeRange = (typeof TIME_RANGES)[number][\"value\"];\ninterface CustomRange {\n  start: Date;\n  end: Date;\n}\ninterface TimeRangeSelectProps {\n  value: TimeRange;\n  onChange: (value: TimeRange) => void;\n  customRange: CustomRange;\n  setCustomRange: (range: CustomRange) => void;\n  onCustomRangeComplete?: (range: CustomRange) => void;\n  slug: React.MutableRefObject<boolean>;\n  isPremium?: boolean;\n}\n\nexport function TimeRangeSelect({\n  value,\n  onChange,\n  customRange,\n  setCustomRange,\n  onCustomRangeComplete,\n  slug,\n  isPremium = false,\n}: TimeRangeSelectProps) {\n  const selectedRange = TIME_RANGES.find((range) => range.value === value);\n  const [date, setDate] = useState<DateRange | undefined>({\n    from: customRange.start,\n    to: customRange.end,\n  });\n  const [open, setOpen] = useState(false);\n\n  // Calculate the minimum allowed date (30 days ago for non-premium)\n  const minDate = isPremium ? undefined : subDays(new Date(), 30);\n\n  useEffect(() => {\n    setDate({ from: customRange.start, to: customRange.end });\n  }, [customRange]);\n\n  const handleSelectOption = (value: TimeRange) => {\n    // Prevent selecting custom range for non-premium users\n    if (value === \"custom\" && !isPremium) {\n      toast.error(\"Upgrade to view data beyond 30 days\");\n      return;\n    }\n\n    onChange(value);\n\n    // Update date range based on selected preset\n    const now = new Date();\n    const end = startOfDay(now);\n    let start = startOfDay(now);\n\n    switch (value) {\n      case \"24h\":\n        start = subDays(end, 1);\n        break;\n      case \"7d\":\n        start = subDays(end, 7);\n        break;\n      case \"30d\":\n        start = subDays(end, 30);\n        break;\n      case \"custom\":\n        // Reset the date range when switching to custom\n        setDate(undefined);\n        return;\n      default:\n        return;\n    }\n\n    setCustomRange({ start, end });\n    setDate({ from: start, to: end });\n    slug.current = false;\n    setOpen(false);\n  };\n\n  const handleRangeChange = (range: DateRange | undefined) => {\n    setDate(range);\n    if (range?.from && range?.to) {\n      // Check if the selected range is within limits for non-premium users\n      if (\n        !isPremium &&\n        (differenceInDays(new Date(), range.from) > 30 ||\n          differenceInDays(new Date(), range.to) > 30)\n      ) {\n        toast.error(\"Upgrade to view data beyond 30 days\");\n        return;\n      }\n\n      const newRange = { start: range.from, end: range.to };\n      setCustomRange(newRange);\n      onChange(\"custom\");\n      slug.current = false;\n      setOpen(false);\n      onCustomRangeComplete?.(newRange);\n    } else if (range?.from) {\n      setCustomRange({ start: range.from, end: range.from });\n      onChange(\"custom\");\n      slug.current = false;\n    }\n  };\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          variant=\"outline\"\n          className={cn(\n            \"w-[300px] justify-between text-left font-normal\",\n            !date && \"text-muted-foreground\",\n          )}\n        >\n          <div className=\"flex items-center gap-2\">\n            <CalendarIcon className=\"h-4 w-4\" />\n            <span>\n              {value === \"custom\" && date?.from ? (\n                <>\n                  {format(date.from, \"MMM d\")} -{\" \"}\n                  {format(date.to || date.from, \"MMM d, yyyy\")}\n                </>\n              ) : (\n                selectedRange?.label\n              )}\n            </span>\n          </div>\n          <ChevronDown className=\"h-4 w-4 opacity-50\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-auto p-2\" align=\"end\">\n        <div className=\"flex gap-2\">\n          <div className=\"rounded-md border\">\n            <Calendar\n              mode=\"range\"\n              defaultMonth={date?.from}\n              selected={date}\n              onSelect={handleRangeChange}\n              numberOfMonths={2}\n              disabled={\n                !isPremium\n                  ? (date) => {\n                      if (!date) return false;\n                      return differenceInDays(new Date(), date) > 30;\n                    }\n                  : undefined\n              }\n              fromDate={minDate}\n            />\n          </div>\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"grid gap-1\">\n              {TIME_RANGES.map((range) => {\n                if (isPremium || range.value !== \"custom\") {\n                  return (\n                    <Button\n                      key={range.value}\n                      variant={range.value === value ? \"secondary\" : \"ghost\"}\n                      className=\"justify-between\"\n                      onClick={() => handleSelectOption(range.value)}\n                    >\n                      <span>{range.label}</span>\n                      {/* <kbd className=\"pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100\">\n                        {range.shortcut}\n                      </kbd> */}\n                    </Button>\n                  );\n                } else {\n                  return <UpgradeButton key={range.value} />;\n                }\n              })}\n            </div>\n          </div>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nconst UpgradeButton = () => {\n  const [open, setOpen] = useState(false);\n\n  return (\n    <>\n      <Button\n        variant=\"ghost\"\n        className=\"justify-between text-muted-foreground\"\n        onClick={() => setOpen(true)}\n        title=\"Upgrade to view data beyond 30 days\"\n      >\n        Custom Date <CrownIcon className=\"!size-4\" />\n      </Button>\n      <UpgradePlanModal\n        clickedPlan={PlanEnum.Pro}\n        trigger=\"dashboard_time_range_custom_select\"\n        open={open}\n        setOpen={setOpen}\n      />\n    </>\n  );\n};\n"
  },
  {
    "path": "components/analytics/views-table.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  ColumnDef,\n  SortingState,\n  flexRender,\n  getCoreRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport {\n  AlertTriangleIcon,\n  BadgeCheckIcon,\n  BadgeInfoIcon,\n  ChevronDownIcon,\n  ChevronUpIcon,\n  ChevronsUpDownIcon,\n  Download,\n  DownloadCloudIcon,\n  FileBadgeIcon,\n  ServerIcon,\n  ThumbsDownIcon,\n  ThumbsUpIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { cn, durationFormat, fetcher, timeAgo } from \"@/lib/utils\";\nimport { downloadCSV } from \"@/lib/utils/csv\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Gauge } from \"@/components/ui/gauge\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\nimport { DataTablePagination } from \"@/components/visitors/data-table-pagination\";\nimport { VisitorAvatar } from \"@/components/visitors/visitor-avatar\";\n\nimport { UpgradeButton } from \"../ui/upgrade-button\";\n\ninterface View {\n  id: string;\n  viewerEmail: string | null;\n  documentName: string;\n  linkName: string;\n  viewedAt: Date;\n  totalDuration: number;\n  completionRate: number;\n  verified?: boolean;\n  internal?: boolean;\n  agreementResponse?: any;\n  downloadedAt?: Date;\n  dataroomId?: string;\n  feedbackResponse?: any;\n  versionNumber?: number;\n  versionNumPages?: number;\n  documentId?: string;\n  teamId?: string;\n}\n\nconst columns: ColumnDef<View>[] = [\n  {\n    accessorKey: \"viewerEmail\",\n    header: \"Recent Views\",\n    cell: ({ row }) => (\n      <div className=\"flex items-center overflow-visible sm:space-x-3\">\n        <VisitorAvatar viewerEmail={row.original.viewerEmail} />\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"focus:outline-none\">\n            <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n              {row.original.viewerEmail ? (\n                <>\n                  {row.original.viewerEmail}{\" \"}\n                  {row.original.verified && (\n                    <BadgeTooltip content=\"Verified visitor\" key=\"verified\">\n                      <BadgeCheckIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                    </BadgeTooltip>\n                  )}\n                  {row.original.internal && (\n                    <BadgeTooltip content=\"Internal visitor\" key=\"internal\">\n                      <BadgeInfoIcon className=\"h-4 w-4 text-blue-500 hover:text-blue-600\" />\n                    </BadgeTooltip>\n                  )}\n                  {row.original.agreementResponse && (\n                    <BadgeTooltip\n                      content={`Agreed to ${row.original.agreementResponse.agreement.name}`}\n                      key=\"agreement\"\n                    >\n                      <FileBadgeIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                    </BadgeTooltip>\n                  )}\n                  {row.original.downloadedAt && (\n                    <BadgeTooltip\n                      content={`Downloaded ${timeAgo(row.original.downloadedAt)}`}\n                      key=\"download\"\n                    >\n                      <DownloadCloudIcon className=\"h-4 w-4 text-cyan-500 hover:text-cyan-600\" />\n                    </BadgeTooltip>\n                  )}\n                  {row.original.dataroomId && (\n                    <BadgeTooltip content={`Dataroom Visitor`} key=\"download\">\n                      <ServerIcon className=\"h-4 w-4 text-[#fb7a00] hover:text-[#fb7a00]/90\" />\n                    </BadgeTooltip>\n                  )}\n                  {row.original.feedbackResponse && (\n                    <BadgeTooltip\n                      content={`${row.original.feedbackResponse.data.question}: ${row.original.feedbackResponse.data.answer}`}\n                      key=\"feedback\"\n                    >\n                      {row.original.feedbackResponse.data.answer === \"yes\" ? (\n                        <ThumbsUpIcon className=\"h-4 w-4 text-gray-500 hover:text-gray-600\" />\n                      ) : (\n                        <ThumbsDownIcon className=\"h-4 w-4 text-gray-500 hover:text-gray-600\" />\n                      )}\n                    </BadgeTooltip>\n                  )}\n                </>\n              ) : (\n                \"Anonymous\"\n              )}\n            </p>\n          </div>\n        </div>\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"documentName\",\n    header: \"Document\",\n    cell: ({ row }) => (\n      <div className=\"text-sm\">\n        {row.original.documentId ? (\n          <Link\n            href={`/documents/${row.original.documentId}`}\n            className=\"flex items-center gap-x-2 overflow-visible text-sm text-gray-800 hover:text-gray-600 dark:text-gray-200 dark:hover:text-gray-400\"\n            onClick={(e) => e.stopPropagation()}\n          >\n            {row.original.documentName}\n          </Link>\n        ) : (\n          <span className=\"text-muted-foreground\">\n            {row.original.documentName}\n          </span>\n        )}\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"linkName\",\n    header: \"Link\",\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">\n        {row.original.linkName}\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"totalDuration\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={cn(\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\",\n            \"px-0\",\n          )}\n        >\n          Time Spent\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">\n        {durationFormat(row.original.totalDuration)}\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"completionRate\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={cn(\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\",\n            \"px-0\",\n          )}\n        >\n          Completion\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"flex justify-start text-sm text-muted-foreground\">\n        <Gauge\n          value={row.original.completionRate}\n          size={\"small\"}\n          showValue={true}\n        />\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"viewedAt\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={cn(\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\",\n            \"px-0\",\n          )}\n        >\n          Last Viewed\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <TimestampTooltip\n        timestamp={row.original.viewedAt}\n        side=\"right\"\n        rows={[\"local\", \"utc\", \"unix\"]}\n      >\n        <div className=\"select-none text-sm text-muted-foreground\">\n          {timeAgo(row.original.viewedAt)}\n        </div>\n      </TimestampTooltip>\n    ),\n  },\n];\n\nexport default function ViewsTable({\n  startDate,\n  endDate,\n}: {\n  startDate: Date;\n  endDate: Date;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { isTrial, isFree, isPaused } = usePlan();\n  const { interval = \"7d\" } = router.query;\n  const [sorting, setSorting] = useState<SortingState>([\n    { id: \"viewedAt\", desc: true },\n  ]);\n\n  const { data } = useSWR<{ views: View[]; hiddenFromPause: number }>(\n    teamInfo?.currentTeam?.id\n      ? `/api/analytics?type=views&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === \"custom\" ? `&startDate=${format(startDate, \"MM-dd-yyyy\")}&endDate=${format(endDate, \"MM-dd-yyyy\")}` : \"\"}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  const views = data?.views;\n  const hiddenFromPause = data?.hiddenFromPause ?? 0;\n\n  const table = useReactTable({\n    data: views || [],\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getPaginationRowModel: getPaginationRowModel(),\n    onSortingChange: setSorting,\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n      sorting,\n    },\n  });\n\n  const handleExport = () => {\n    if (isFree && !isTrial) {\n      toast.error(\"Please upgrade to export data\");\n      return;\n    }\n\n    if (!views?.length) {\n      toast.error(\"No data to export\");\n      return;\n    }\n\n    const exportData = views.map((view) => ({\n      \"Visitor Email\": view.viewerEmail || \"Anonymous\",\n      Document: view.documentName,\n      Link: view.linkName,\n      \"Time Spent\": durationFormat(view.totalDuration),\n      \"Completion Rate\": `${view.completionRate}%`,\n      \"Viewed At\": new Date(view.viewedAt).toISOString(),\n      Verified: view.verified ? \"Yes\" : \"No\",\n    }));\n\n    downloadCSV(exportData, \"views\");\n  };\n\n  const UpgradeOrExportButton = () => {\n    if (isFree && !isTrial) {\n      return (\n        <UpgradeButton\n          text=\"Export\"\n          clickedPlan={PlanEnum.Pro}\n          trigger=\"dashboard_views_export\"\n          variant=\"outline\"\n          size=\"sm\"\n        />\n      );\n    } else {\n      return (\n        <Button variant=\"outline\" size=\"sm\" onClick={handleExport}>\n          <Download className=\"!size-4\" />\n          Export\n        </Button>\n      );\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {isPaused && hiddenFromPause > 0 && (\n        <div className=\"flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center\">\n          <span className=\"flex items-center gap-x-1 text-sm\">\n            <AlertTriangleIcon className=\"inline-block h-4 w-4 text-orange-500\" />\n            {hiddenFromPause} view{hiddenFromPause !== 1 ? \"s\" : \"\"} occurred\n            after your team was paused and{\" \"}\n            {hiddenFromPause !== 1 ? \"are\" : \"is\"} hidden.{\" \"}\n          </span>\n          <Link\n            href=\"/settings/billing\"\n            className=\"text-sm font-medium text-orange-600 underline hover:text-orange-700\"\n          >\n            Unpause subscription to see all views\n          </Link>\n        </div>\n      )}\n      <div className=\"flex justify-end\">\n        <UpgradeOrExportButton />\n      </div>\n      <div className=\"overflow-x-auto rounded-xl border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id} className=\"px-4\">\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext(),\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                // <Collapsible key={row.id} asChild>\n                <>\n                  {/* <CollapsibleTrigger asChild> */}\n                  <TableRow>\n                    {row.getVisibleCells().map((cell) => (\n                      <TableCell key={cell.id}>\n                        {flexRender(\n                          cell.column.columnDef.cell,\n                          cell.getContext(),\n                        )}\n                      </TableCell>\n                    ))}\n                  </TableRow>\n                  {/* </CollapsibleTrigger> */}\n                  {/* <CollapsibleContent asChild>\n                      <TableRow className=\"hover:bg-transparent\">\n                        <TableCell colSpan={columns.length}>\n                          <div className=\"pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n                            <div className=\"flex items-center gap-x-1 px-1\">\n                              <FileDigitIcon className=\"size-4\" /> Document\n                              Version {row.original.versionNumber}\n                            </div>\n                          </div>\n                          <VisitorChart\n                            documentId={row.original.documentId!}\n                            viewId={row.original.id}\n                            totalPages={row.original.versionNumPages}\n                            versionNumber={row.original.versionNumber}\n                          />\n                          <VisitorClicks\n                            teamId={row.original.teamId!}\n                            documentId={row.original.documentId!}\n                            viewId={row.original.id}\n                          />\n                        </TableCell>\n                      </TableRow>\n                    </CollapsibleContent> */}\n                </>\n                // </Collapsible>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  <p>No views in the last {interval}</p>\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n      <DataTablePagination table={table} name=\"view\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/analytics/visitors-table.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  ColumnDef,\n  SortingState,\n  flexRender,\n  getCoreRowModel,\n  getPaginationRowModel,\n  getSortedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport { format } from \"date-fns\";\nimport {\n  AlertTriangleIcon,\n  BadgeCheckIcon,\n  ChevronDownIcon,\n  ChevronUpIcon,\n  ChevronsUpDownIcon,\n  Download,\n  UserIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { durationFormat, fetcher, timeAgo } from \"@/lib/utils\";\nimport { downloadCSV } from \"@/lib/utils/csv\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\nimport { DataTablePagination } from \"@/components/visitors/data-table-pagination\";\nimport { VisitorAvatar } from \"@/components/visitors/visitor-avatar\";\n\nimport { UpgradeButton } from \"../ui/upgrade-button\";\n\ninterface Visitor {\n  email: string;\n  viewerId: string | null;\n  totalViews: number;\n  lastActive: Date;\n  uniqueDocuments: number;\n  verified: boolean;\n  totalDuration: number;\n  viewerName?: string | null;\n}\n\nconst columns: ColumnDef<Visitor>[] = [\n  {\n    accessorKey: \"email\",\n    header: \"Visitor\",\n    cell: ({ row }) => (\n      <div className=\"flex items-center overflow-visible sm:space-x-3\">\n        <VisitorAvatar viewerEmail={row.original.email} />\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"focus:outline-none\">\n            <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n              {row.original.viewerName || row.original.email}{\" \"}\n              {row.original.verified && (\n                <BadgeTooltip content=\"Verified visitor\">\n                  <BadgeCheckIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                </BadgeTooltip>\n              )}\n            </p>\n            {row.original.viewerName && row.original.email && (\n              <p className=\"text-xs text-muted-foreground/60\">\n                {row.original.email}\n              </p>\n            )}\n            <p className=\"text-sm text-muted-foreground\">\n              {row.original.uniqueDocuments} document\n              {row.original.uniqueDocuments !== 1 ? \"s\" : \"\"} viewed\n            </p>\n          </div>\n        </div>\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"totalViews\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\"\n          }\n        >\n          Total Views\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">\n        {row.original.totalViews}\n      </div>\n    ),\n  },\n  {\n    accessorKey: \"totalDuration\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\"\n          }\n        >\n          Total Time Spent\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">\n        {durationFormat(row.original.totalDuration)}\n      </div>\n    ),\n  },\n  // {\n  //   accessorKey: \"avgCompletionRate\",\n  //   header: ({ column }) => {\n  //     return (\n  //       <Button\n  //         variant=\"ghost\"\n  //         onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n  //         className={\n  //           column.getIsSorted()\n  //             ? \"text-nowrap font-medium\"\n  //             : \"text-nowrap font-normal\"\n  //         }\n  //       >\n  //         Avg. Completion\n  //         {column.getIsSorted() === \"asc\" ? (\n  //           <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n  //         ) : column.getIsSorted() === \"desc\" ? (\n  //           <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n  //         ) : (\n  //           <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n  //         )}\n  //       </Button>\n  //     );\n  //   },\n  //   cell: ({ row }) => (\n  //     <div className=\"flex justify-start text-sm text-muted-foreground\">\n  //       <Gauge\n  //         value={row.original.avgCompletionRate}\n  //         size=\"small\"\n  //         showValue={true}\n  //       />\n  //     </div>\n  //   ),\n  // },\n  {\n    accessorKey: \"lastActive\",\n    header: ({ column }) => {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => column.toggleSorting(column.getIsSorted() === \"asc\")}\n          className={\n            column.getIsSorted()\n              ? \"text-nowrap font-medium\"\n              : \"text-nowrap font-normal\"\n          }\n        >\n          Last Active\n          {column.getIsSorted() === \"asc\" ? (\n            <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n          ) : column.getIsSorted() === \"desc\" ? (\n            <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n          ) : (\n            <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />\n          )}\n        </Button>\n      );\n    },\n    cell: ({ row }) => (\n      <div className=\"text-sm text-muted-foreground\">\n        {timeAgo(row.original.lastActive)}\n      </div>\n    ),\n  },\n];\n\nexport default function VisitorsTable({\n  startDate,\n  endDate,\n}: {\n  startDate: Date;\n  endDate: Date;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { isTrial, isFree, isPaused } = usePlan();\n  const { interval = \"7d\" } = router.query;\n  const [sorting, setSorting] = useState<SortingState>([\n    { id: \"lastActive\", desc: true },\n  ]);\n\n  const { data } = useSWR<{ visitors: Visitor[]; hiddenFromPause: number }>(\n    teamInfo?.currentTeam?.id\n      ? `/api/analytics?type=visitors&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === \"custom\" ? `&startDate=${format(startDate, \"MM-dd-yyyy\")}&endDate=${format(endDate, \"MM-dd-yyyy\")}` : \"\"}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  const visitors = data?.visitors;\n  const hiddenFromPause = data?.hiddenFromPause ?? 0;\n\n  const table = useReactTable({\n    data: visitors || [],\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getPaginationRowModel: getPaginationRowModel(),\n    onSortingChange: setSorting,\n    getSortedRowModel: getSortedRowModel(),\n    state: {\n      sorting,\n    },\n  });\n\n  const handleExport = () => {\n    if (isFree && !isTrial) {\n      toast.error(\"Please upgrade to export data\");\n      return;\n    }\n\n    if (!visitors?.length) {\n      toast.error(\"No data to export\");\n      return;\n    }\n\n    const exportData = visitors.map((visitor) => ({\n      Email: visitor.email,\n      \"Total Views\": visitor.totalViews,\n      \"Unique Documents\": visitor.uniqueDocuments,\n      \"Total Duration\": durationFormat(visitor.totalDuration),\n      \"Last Active\": new Date(visitor.lastActive).toISOString(),\n      Verified: visitor.verified ? \"Yes\" : \"No\",\n    }));\n\n    downloadCSV(exportData, \"visitors\");\n  };\n\n  const UpgradeOrExportButton = () => {\n    if (isFree && !isTrial) {\n      return (\n        <UpgradeButton\n          text=\"Export\"\n          clickedPlan={PlanEnum.Pro}\n          trigger=\"dashboard_visitors_export\"\n          variant=\"outline\"\n          size=\"sm\"\n        />\n      );\n    } else {\n      return (\n        <Button variant=\"outline\" size=\"sm\" onClick={handleExport}>\n          <Download className=\"!size-4\" />\n          Export\n        </Button>\n      );\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      {isPaused && hiddenFromPause > 0 && (\n        <div className=\"flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center\">\n          <span className=\"flex items-center gap-x-1 text-sm\">\n            <AlertTriangleIcon className=\"inline-block h-4 w-4 text-orange-500\" />\n            {hiddenFromPause} view{hiddenFromPause !== 1 ? \"s\" : \"\"} occurred\n            after your team was paused and{\" \"}\n            {hiddenFromPause !== 1 ? \"are\" : \"is\"} hidden.{\" \"}\n          </span>\n          <Link\n            href=\"/settings/billing\"\n            className=\"text-sm font-medium text-orange-600 underline hover:text-orange-700\"\n          >\n            Unpause subscription to see all views\n          </Link>\n        </div>\n      )}\n      <div className=\"flex justify-end\">\n        <UpgradeOrExportButton />\n      </div>\n      <div className=\"overflow-x-auto rounded-xl border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id} className=\"px-0 first:px-4\">\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext(),\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  key={row.id}\n                  data-state={row.getIsSelected() && \"selected\"}\n                >\n                  {row.getVisibleCells().map((cell) => (\n                    <TableCell key={cell.id}>\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext(),\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  <div className=\"flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4\">\n                    <div className=\"hidden rounded-full sm:block\">\n                      <div className=\"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3\">\n                        <UserIcon className=\"size-6\" />\n                      </div>\n                    </div>\n                    <p>No visitors in the last {interval}</p>\n                  </div>\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n      <DataTablePagination table={table} name=\"visitor\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/billing/add-seat-modal.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { getPriceIdFromPlan } from \"@/ee/stripe/functions/get-price-id-from-plan\";\nimport { getQuantityFromPriceId } from \"@/ee/stripe/functions/get-quantity-from-plan\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nexport function AddSeatModal({\n  open,\n  setOpen,\n  children,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  children?: React.ReactNode;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const analytics = useAnalytics();\n  const { plan: userPlan, planName, isAnnualPlan, isOldAccount } = usePlan();\n  const { limits } = useLimits();\n\n  const [quantity, setQuantity] = useState<number>(1);\n  const [loading, setLoading] = useState<boolean>(false);\n\n  // Get the minimum quantity for the current plan\n  const priceId = getPriceIdFromPlan({\n    planSlug: userPlan,\n    isOld: isOldAccount,\n    period: isAnnualPlan ? \"yearly\" : \"monthly\",\n  });\n  const minQuantity = getQuantityFromPriceId(priceId);\n\n  // Set initial quantity to 1 (adding one seat)\n  useEffect(() => {\n    if (open) {\n      setQuantity(1);\n    }\n  }, [open]);\n\n  // Calculate the total number of seats after the update\n  const totalSeatsAfterUpdate = limits ? limits.users! + quantity : quantity;\n\n  const handleDecrement = () => {\n    if (quantity > 1) {\n      setQuantity(quantity - 1);\n    }\n  };\n\n  const handleIncrement = () => {\n    setQuantity(quantity + 1);\n  };\n\n  const handleSubmit = async () => {\n    setLoading(true);\n\n    try {\n      const response = await fetch(`/api/teams/${teamId}/billing/manage`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          priceId,\n          quantity: totalSeatsAfterUpdate,\n          addSeat: true,\n          // return_url: `${process.env.NEXTAUTH_URL}/settings/people?success=true`,\n        }),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        toast.error(\"Unable to add seats. Please contact support.\");\n        setLoading(false);\n        return;\n      }\n\n      const url = await response.json();\n\n      analytics.capture(\"Add Seat Clicked\", {\n        quantity: quantity,\n        totalSeats: totalSeatsAfterUpdate,\n        teamId,\n        plan: userPlan,\n      });\n\n      router.push(url);\n    } catch (error) {\n      toast.error(\"An error occurred while processing your request.\");\n      setLoading(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Add more seats</DialogTitle>\n          <DialogDescription>{planName}</DialogDescription>\n        </DialogHeader>\n        <div className=\"py-6\">\n          <div className=\"flex items-center justify-center space-x-4\">\n            <Button\n              variant=\"outline\"\n              size=\"icon\"\n              onClick={handleDecrement}\n              disabled={quantity <= 1}\n              className=\"h-10 w-10 rounded-full\"\n            >\n              <span className=\"text-xl\">−</span>\n            </Button>\n\n            <div className=\"w-24\">\n              <Input\n                type=\"number\"\n                value={quantity}\n                onChange={(e) => {\n                  const value = parseInt(e.target.value);\n                  if (!isNaN(value) && value >= 1) {\n                    setQuantity(value);\n                  }\n                }}\n                className=\"text-center text-lg\"\n                min={1}\n              />\n            </div>\n\n            <Button\n              variant=\"outline\"\n              size=\"icon\"\n              onClick={handleIncrement}\n              className=\"h-10 w-10 rounded-full\"\n            >\n              <span className=\"text-xl\">+</span>\n            </Button>\n          </div>\n\n          {limits && (\n            <p className=\"mt-4 text-center text-sm text-muted-foreground\">\n              Current limit: {limits.users}{\" \"}\n              {limits.users === 1 ? \"user\" : \"users\"}\n            </p>\n          )}\n\n          <p className=\"mt-2 text-center text-sm\">\n            Adding <span className=\"font-semibold\">{quantity}</span>{\" \"}\n            {quantity === 1 ? \"seat\" : \"seats\"}\n          </p>\n\n          <p className=\"mt-2 text-center text-sm font-medium\">\n            Total after update: {totalSeatsAfterUpdate}{\" \"}\n            {totalSeatsAfterUpdate === 1 ? \"user\" : \"users\"}\n          </p>\n\n          {minQuantity > 1 && (\n            <p className=\"mt-2 text-center text-sm text-muted-foreground\">\n              Minimum quantity for {planName}: {minQuantity} users\n            </p>\n          )}\n        </div>\n\n        <DialogFooter className=\"flex flex-col gap-2 sm:flex-col sm:justify-center\">\n          <Button onClick={handleSubmit} className=\"w-full\" disabled={loading}>\n            {loading ? \"Redirecting...\" : \"Proceed to checkout\"}\n          </Button>\n          <Link\n            href=\"/settings/upgrade\"\n            className=\"block w-full text-center text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground\"\n            onClick={() => setOpen(false)}\n          >\n            or upgrade to higher plan\n          </Link>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/billing/plan-badge.tsx",
    "content": "import { CrownIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport default function PlanBadge({\n  plan,\n  className,\n}: {\n  plan: string;\n  className?: string;\n}) {\n  return (\n    <span\n      className={cn(\n        \"ml-1 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-normal uppercase tracking-normal text-gray-700 ring-1 ring-gray-400 hover:bg-gray-200 dark:text-foreground dark:ring-gray-500 hover:dark:bg-gray-700\",\n        className,\n      )}\n    >\n      <CrownIcon className=\"h-3 w-3\" /> {plan}\n    </span>\n  );\n}\n"
  },
  {
    "path": "components/billing/pro-annual-banner.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { Dispatch, SetStateAction, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { getPriceIdFromPlan } from \"@/ee/stripe/functions/get-price-id-from-plan\";\nimport Cookies from \"js-cookie\";\nimport { toast } from \"sonner\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport X from \"@/components/shared/icons/x\";\nimport { Button } from \"@/components/ui/button\";\n\nexport default function ProAnnualBanner({\n  setShowProAnnualBanner,\n}: {\n  setShowProAnnualBanner: Dispatch<SetStateAction<boolean | null>>;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { plan: teamPlan, trial, isCustomer, isOldAccount } = usePlan();\n\n  const [isLoading, setIsLoading] = useState(false);\n\n  const handleHideBanner = () => {\n    setShowProAnnualBanner(false);\n    Cookies.set(\"hideProAnnualBanner\", \"pro-annual-banner\", {\n      expires: 7,\n    });\n  };\n\n  return (\n    <aside className=\"relative mb-2 flex w-full flex-col justify-center rounded-lg border border-gray-700 bg-background p-4 text-foreground\">\n      <button\n        type=\"button\"\n        onClick={handleHideBanner}\n        className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\"\n      >\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </button>\n      <div className=\"flex space-x-2\">\n        <span className=\"text-sm font-bold\">Papermark Pro Annual ✨</span>\n      </div>\n      <p className=\"my-4 text-sm\">\n        Lock in a better price and get 2 months free.\n      </p>\n      <div className=\"flex\">\n        <Button\n          type=\"button\"\n          className=\"grow\"\n          loading={isLoading}\n          onClick={() => {\n            if (isCustomer && teamPlan === \"pro\") {\n              setIsLoading(true);\n              fetch(`/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`, {\n                method: \"POST\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify({\n                  priceId: getPriceIdFromPlan({\n                    planSlug: \"pro\",\n                    isOld: isOldAccount,\n                    period: \"yearly\",\n                  }),\n                  upgradePlan: true,\n                  proAnnualBanner: true,\n                }),\n              })\n                .then(async (res) => {\n                  const url = await res.json();\n                  router.push(url);\n                })\n                .catch((err) => {\n                  alert(err);\n                  toast.error(\"Something went wrong\");\n                })\n                .finally(() => {\n                  setIsLoading(false);\n                });\n            }\n          }}\n        >\n          {isLoading ? \"Redirecting...\" : \"Upgrade\"}\n        </Button>\n      </div>\n    </aside>\n  );\n}\n"
  },
  {
    "path": "components/billing/pro-banner.tsx",
    "content": "import { Dispatch, SetStateAction } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport Cookies from \"js-cookie\";\n\nimport X from \"@/components/shared/icons/x\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { UpgradePlanModal } from \"./upgrade-plan-modal\";\n\nexport default function ProBanner({\n  setShowProBanner,\n}: {\n  setShowProBanner: Dispatch<SetStateAction<boolean | null>>;\n}) {\n  const handleHideBanner = () => {\n    setShowProBanner(false);\n    Cookies.set(\"hideProBanner\", \"pro-banner\", {\n      expires: 7,\n    });\n  };\n\n  return (\n    <aside className=\"relative mb-2 flex w-full flex-col justify-center rounded-lg border border-gray-700 bg-background p-4 text-foreground\">\n      <button\n        type=\"button\"\n        onClick={handleHideBanner}\n        className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\"\n      >\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </button>\n      <div className=\"flex space-x-2\">\n        <span className=\"text-sm font-bold\">✨ Papermark Business ✨</span>\n      </div>\n      <p className=\"my-4 text-sm\">\n        Upgrade to unlock custom branding, team members, domains and data rooms.\n      </p>\n      <div className=\"flex\">\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.Business}\n          trigger={\"pro_banner\"}\n        >\n          <Button type=\"button\" className=\"grow\">\n            Upgrade\n          </Button>\n        </UpgradePlanModal>\n      </div>\n    </aside>\n  );\n}\n"
  },
  {
    "path": "components/billing/upgrade-plan-container.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { CancellationModal } from \"@/ee/features/billing/cancellation/components\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  BanIcon,\n  CirclePauseIcon,\n  CreditCardIcon,\n  MoreVertical,\n  ReceiptTextIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { UpgradeButton } from \"@/components/ui/upgrade-button\";\n\nexport default function UpgradePlanContainer() {\n  const router = useRouter();\n  const [loading, setLoading] = useState<boolean>(false);\n  const [unpauseLoading, setUnpauseLoading] = useState<boolean>(false);\n  const [cancellationModalOpen, setCancellationModalOpen] =\n    useState<boolean>(false);\n  const { currentTeamId } = useTeam();\n  const {\n    plan,\n    isFree,\n    isDataroomsPlus,\n    isDataroomsPremium,\n    isPaused,\n    isCancelled,\n    isCustomer,\n    startsAt,\n    endsAt,\n    pauseStartsAt,\n    pauseEndsAt,\n    discount,\n    mutate: mutatePlan,\n  } = usePlan({ withDiscount: true });\n  const analytics = useAnalytics();\n\n  const manageSubscription = async ({\n    type,\n  }: {\n    type:\n      | \"manage\"\n      | \"invoices\"\n      | \"subscription_update\"\n      | \"payment_method_update\"\n      | \"cancellation\";\n  }) => {\n    if (!currentTeamId) return;\n\n    setLoading(true);\n\n    try {\n      fetch(`/api/teams/${currentTeamId}/billing/manage`, {\n        method: \"POST\",\n        body: JSON.stringify({ type }),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      })\n        .then(async (res) => {\n          const url = await res.json();\n          router.push(url);\n        })\n        .catch((err) => {\n          throw err;\n        })\n        .finally(() => {\n          setLoading(false);\n        });\n    } catch (error) {\n      console.error(error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleUnpauseSubscription = async () => {\n    if (!currentTeamId) return;\n\n    setUnpauseLoading(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${currentTeamId}/billing/unpause`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to unpause subscription\");\n      }\n\n      // Track the unpause event for analytics\n      analytics.capture(\"Subscription Unpaused\", {\n        teamId: currentTeamId,\n        plan: plan,\n      });\n\n      toast.success(\"Subscription unpaused successfully!\");\n      mutate(`/api/teams/${currentTeamId}/billing/plan`);\n      mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);\n    } catch (error) {\n      console.error(error);\n    } finally {\n      setUnpauseLoading(false);\n    }\n  };\n\n  const handleReactivateSubscription = async () => {\n    if (!currentTeamId) return;\n    setUnpauseLoading(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${currentTeamId}/billing/reactivate`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to reactivate subscription\");\n      }\n\n      // Track the reactivation event for analytics\n      analytics.capture(\"Subscription Reactivated\", {\n        teamId: currentTeamId,\n        plan: plan,\n      });\n\n      toast.success(\"Subscription reactivated successfully!\");\n      mutate(`/api/teams/${currentTeamId}/billing/plan`);\n      mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);\n    } catch (error) {\n      console.error(error);\n    } finally {\n      setUnpauseLoading(false);\n    }\n  };\n\n  const isBillingCycleCurrent = () => {\n    if (!startsAt || !endsAt) return false;\n    const currentDate = new Date();\n    return currentDate >= new Date(startsAt) && currentDate <= new Date(endsAt);\n  };\n\n  const getDiscountText = () => {\n    if (!discount || !discount.valid) return null;\n\n    let discountText = \"\";\n    if (discount.percentOff) {\n      discountText = `${discount.percentOff}% off`;\n    } else if (discount.amountOff) {\n      discountText = `$${(discount.amountOff / 100).toFixed(2)} off`;\n    }\n\n    if (discount.duration === \"repeating\" && discount.durationInMonths) {\n      discountText += ` for ${discount.durationInMonths} month${discount.durationInMonths > 1 ? \"s\" : \"\"}`;\n    } else if (discount.duration === \"once\") {\n      discountText += \" (one-time)\";\n    }\n\n    return discountText;\n  };\n\n  const ButtonList = () => {\n    if (isFree) {\n      return (\n        <div className=\"flex items-center gap-3\">\n          <UpgradeButton\n            text=\"\"\n            customText=\"Upgrade\"\n            clickedPlan={PlanEnum.Business}\n            trigger=\"upgrade_plan\"\n            useModal={false}\n            onClick={() => router.push(\"/settings/upgrade\")}\n          />\n        </div>\n      );\n    } else if (isCancelled) {\n      return (\n        <Button onClick={handleReactivateSubscription} loading={unpauseLoading}>\n          Reactivate subscription\n        </Button>\n      );\n    } else {\n      return (\n        <div className=\"flex items-center gap-3\">\n          {isPaused ? (\n            <>\n              <Button\n                onClick={handleUnpauseSubscription}\n                loading={unpauseLoading}\n              >\n                Unpause subscription\n              </Button>\n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <Button variant=\"outline\" size=\"sm\" className=\"h-9 w-9 p-0\">\n                    <MoreVertical className=\"h-4 w-4\" />\n                    <span className=\"sr-only\">More options</span>\n                  </Button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\">\n                  <DropdownMenuItem\n                    onClick={() => manageSubscription({ type: \"cancellation\" })}\n                    className=\"text-red-500\"\n                  >\n                    <BanIcon className=\"h-4 w-4\" />\n                    Cancel subscription\n                  </DropdownMenuItem>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </>\n          ) : (\n            <>\n              <Button\n                variant=\"outline\"\n                onClick={() => setCancellationModalOpen(true)}\n              >\n                Cancel subscription\n              </Button>\n              <Button\n                variant=\"outline\"\n                onClick={() =>\n                  manageSubscription({ type: \"subscription_update\" })\n                }\n                loading={loading}\n              >\n                Change plan\n              </Button>\n\n              <DropdownMenu>\n                <DropdownMenuTrigger asChild>\n                  <Button variant=\"outline\" size=\"sm\" className=\"h-9 w-9 p-0\">\n                    <MoreVertical className=\"h-4 w-4\" />\n                    <span className=\"sr-only\">More options</span>\n                  </Button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"end\">\n                  <DropdownMenuItem\n                    onClick={() => manageSubscription({ type: \"manage\" })}\n                  >\n                    <CreditCardIcon className=\"h-4 w-4\" />\n                    Change billing information\n                  </DropdownMenuItem>\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </>\n          )}\n        </div>\n      );\n    }\n  };\n\n  return (\n    <>\n      <div className=\"rounded-lg\">\n        <Card className=\"bg-transparent\">\n          <CardHeader>\n            <CardTitle>\n              {isDataroomsPremium\n                ? \"Premium\"\n                : isDataroomsPlus\n                  ? \"Datarooms+\"\n                  : plan.charAt(0).toUpperCase() + plan.slice(1)}{\" \"}\n              Plan\n            </CardTitle>\n            {!isCancelled && startsAt && endsAt && isBillingCycleCurrent() && (\n              <CardDescription>\n                <span className=\"font-medium text-foreground\">\n                  Current billing cycle:{\" \"}\n                </span>\n                <span className=\"text-foreground\">\n                  {new Date(startsAt).toLocaleDateString(\"en-US\", {\n                    month: \"short\",\n                    day: \"numeric\",\n                    year: \"numeric\",\n                  })}\n                  {\" - \"}\n                  {new Date(endsAt).toLocaleDateString(\"en-US\", {\n                    month: \"short\",\n                    day: \"numeric\",\n                    year: \"numeric\",\n                  })}\n                </span>\n              </CardDescription>\n            )}\n            {isPaused && pauseStartsAt && (\n              <CardDescription>\n                <span className=\"font-medium text-foreground\">\n                  Subscription{\" \"}\n                  {new Date(pauseStartsAt) > new Date()\n                    ? \"will pause on\"\n                    : \"paused on\"}\n                  :{\" \"}\n                </span>\n                <span className=\"text-foreground\">\n                  {new Date(pauseStartsAt).toLocaleDateString(\"en-US\", {\n                    month: \"short\",\n                    day: \"numeric\",\n                    year: \"numeric\",\n                  })}\n                </span>\n              </CardDescription>\n            )}\n            {isCancelled && endsAt && (\n              <CardDescription>\n                <span className=\"font-medium text-foreground\">\n                  Subscription cancels on:{\" \"}\n                </span>\n                <span className=\"text-foreground\">\n                  {new Date(endsAt).toLocaleDateString(\"en-US\", {\n                    month: \"short\",\n                    day: \"numeric\",\n                    year: \"numeric\",\n                  })}\n                </span>\n              </CardDescription>\n            )}\n            {discount && discount.valid && getDiscountText() && (\n              <CardDescription>\n                <div className=\"inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30\">\n                  🎉 {getDiscountText()} applied\n                </div>\n              </CardDescription>\n            )}\n          </CardHeader>\n          <CardContent></CardContent>\n          <CardFooter className=\"flex items-center justify-end rounded-b-lg border-t px-6 py-3\">\n            <div className=\"shrink-0\">{ButtonList()}</div>\n          </CardFooter>\n        </Card>\n      </div>\n\n      <CancellationModal\n        open={cancellationModalOpen}\n        onOpenChange={setCancellationModalOpen}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/billing/upgrade-plan-modal-old.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { getStripe } from \"@/ee/stripe/client\";\nimport { PLANS } from \"@/ee/stripe/utils\";\nimport { CheckIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent, DialogTrigger } from \"@/components/ui/dialog\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { capitalize, cn } from \"@/lib/utils\";\n\nimport { DataroomTrialModal } from \"../datarooms/dataroom-trial-modal\";\nimport X from \"../shared/icons/x\";\nimport { Badge } from \"../ui/badge\";\nimport { Switch } from \"../ui/switch\";\n\nexport function UpgradePlanModal({\n  clickedPlan,\n  trigger,\n  open,\n  setOpen,\n  children,\n}: {\n  clickedPlan: \"Data Rooms\" | \"Business\" | \"Pro\";\n  trigger?: string;\n  open?: boolean;\n  setOpen?: React.Dispatch<React.SetStateAction<boolean>>;\n  children?: React.ReactNode;\n}) {\n  const router = useRouter();\n  const [plan, setPlan] = useState<\"Pro\" | \"Business\" | \"Data Rooms\">(\n    clickedPlan,\n  );\n  const [period, setPeriod] = useState<\"yearly\" | \"monthly\">(\"yearly\");\n  const [clicked, setClicked] = useState<boolean>(false);\n  const teamInfo = useTeam();\n  const { plan: teamPlan, trial, isCustomer, isOldAccount } = usePlan();\n  const analytics = useAnalytics();\n\n  const isTrial = !!trial;\n\n  const features = useMemo(() => {\n    if (plan === \"Pro\") {\n      return [\n        \"2 users included\",\n        \"Custom branding\",\n        \"Folder organization\",\n        \"Require email verification\",\n        \"Papermark branding removed\",\n        \"1-year analytics retention\",\n      ];\n    }\n\n    if (plan === \"Business\") {\n      return [\n        \"3 users included\",\n        \"1 dataroom\",\n        \"Multi-file sharing\",\n        <span key=\"custom-domain\">\n          Custom domain <b>for documents</b>\n        </span>,\n        \"Advanced link controls\",\n        \"Allow/Block list\",\n        \"Unlimited documents\",\n        \"Unlimited subfolder levels\",\n        \"Large file uploads\",\n        \"48h priority support\",\n      ];\n    }\n    if (plan === \"Data Rooms\") {\n      return [\n        \"3 users included\",\n        \"Unlimited data rooms\",\n        <span key=\"custom-dataroom\">\n          Custom domain <b>for data rooms</b>\n        </span>,\n\n        \"NDA agreements\",\n        \"Dynamic watermark\",\n        \"Granular user/group permisssions\",\n        \"Advanced data rooms analytics\",\n        \"24h priority support\",\n        \"Custom onboarding\",\n      ];\n    }\n\n    return [\n      \"2 users\",\n      \"Custom branding\",\n      \"1-year analytics retention\",\n      \"Folders\",\n    ];\n  }, [plan]);\n\n  // Track analytics event when modal is opened\n  useEffect(() => {\n    if (open) {\n      analytics.capture(\"Upgrade Button Clicked\", {\n        trigger: trigger,\n        teamId: teamInfo?.currentTeam?.id,\n      });\n    }\n  }, [open, trigger]);\n\n  // Track analytics event when child button is present\n  const handleUpgradeClick = () => {\n    analytics.capture(\"Upgrade Button Clicked\", {\n      trigger: trigger,\n      teamId: teamInfo?.currentTeam?.id,\n    });\n  };\n\n  // If button is present, clone it and add onClick handler\n  const buttonChild = React.isValidElement<{\n    onClick?: React.MouseEventHandler<HTMLButtonElement>;\n  }>(children)\n    ? React.cloneElement(children, { onClick: handleUpgradeClick })\n    : children;\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{buttonChild}</DialogTrigger>\n      <DialogContent className=\"bg-background text-foreground sm:max-w-[600px]\">\n        <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border py-6 sm:px-10\">\n          <h2 className=\"text-2xl font-bold\">\n            Select the best plan for your business\n          </h2>\n          <Button\n            variant=\"ghost\"\n            className=\"absolute right-4 top-4\"\n            onClick={() => setOpen?.(false)}\n          >\n            <X className=\"h-5 w-5\" />\n          </Button>\n        </div>\n\n        <div className=\"flex items-center justify-center space-x-2 py-4\">\n          <span className={cn(\"mr-2\", period === \"yearly\" && \"text-gray-400\")}>\n            Monthly\n          </span>\n          <Switch\n            checked={period === \"yearly\"}\n            onCheckedChange={(checked) =>\n              setPeriod(checked ? \"yearly\" : \"monthly\")\n            }\n            className=\"h-5 w-10\"\n          />\n          <span className={cn(\"ml-2\", period === \"monthly\" && \"text-gray-400\")}>\n            Annually{\" \"}\n            <span className=\"ml-1 text-sm font-normal text-[#fb7a00]\">\n              (Save up to 25%)\n            </span>\n          </span>\n        </div>\n\n        <div className=\"space-y-4 px-6 py-4\">\n          <Tabs\n            value={plan}\n            onValueChange={(value) => setPlan(value as typeof plan)}\n          >\n            <TabsList className=\"grid w-full grid-cols-3\">\n              <TabsTrigger value=\"Pro\">Pro</TabsTrigger>\n              <TabsTrigger value=\"Business\">Business</TabsTrigger>\n              <TabsTrigger value=\"Data Rooms\">Data Rooms</TabsTrigger>\n            </TabsList>\n          </Tabs>\n\n          <div className=\"rounded-lg border border-border p-4\">\n            <div className=\"mb-4 flex items-center justify-between\">\n              <div className=\"flex items-center space-x-2\">\n                <h4 className=\"font-medium text-foreground\">\n                  {plan} {capitalize(period)}\n                </h4>\n                <Badge\n                  variant=\"outline\"\n                  className=\"text-sm font-normal normal-case\"\n                >\n                  {`€${PLANS.find((p) => p.name === plan)!.price[period].amount}/month`}\n                  {period === \"yearly\" && (\n                    <span className=\"ml-1 text-xs\">(billed yearly)</span>\n                  )}\n                </Badge>\n              </div>\n              <button\n                onClick={() =>\n                  setPeriod(period === \"monthly\" ? \"yearly\" : \"monthly\")\n                }\n                className=\"text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-gray-800 hover:dark:text-muted-foreground/80\"\n              >\n                {period === \"monthly\"\n                  ? plan === \"Business\"\n                    ? \"Want 43% off?\"\n                    : plan === \"Data Rooms\"\n                      ? \"Want 50% off?\"\n                      : \"Want 35% off?\"\n                  : \"Switch to monthly\"}\n              </button>\n            </div>\n            <ul className=\"space-y-2\">\n              {features.map((feature, i) => (\n                <li\n                  key={i}\n                  className=\"flex items-center gap-x-3 text-sm text-muted-foreground\"\n                >\n                  <CheckIcon\n                    className=\"h-5 w-5 flex-none text-[#fb7a00]\"\n                    aria-hidden=\"true\"\n                  />\n                  <span>{feature}</span>\n                </li>\n              ))}\n            </ul>\n          </div>\n\n          <Button\n            className=\"w-full\"\n            loading={clicked}\n            onClick={() => {\n              setClicked(true);\n              // @ts-ignore\n              // prettier-ignore\n\n              if (isCustomer && teamPlan !== \"free\") {\n                fetch(\n                  `/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`,\n                  {\n                    method: \"POST\",\n                  },\n                )\n                  .then(async (res) => {\n                    const url = await res.json();\n                    router.push(url);\n                  })\n                  .catch((err) => {\n                    alert(err);\n                    setClicked(false);\n                  });\n              } else {\n\n              fetch(\n                `/api/teams/${\n                  teamInfo?.currentTeam?.id\n                }/billing/upgrade?priceId=${\n                  PLANS.find((p) => p.name === plan)!.price[period].priceIds[\n                    process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n                      ? \"production\"\n                      : \"test\"\n                  ]\n                }`,\n                {\n                  method: \"POST\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                  },\n                },\n              )\n                .then(async (res) => {\n                  const data = await res.json();\n                  const { id: sessionId } = data;\n                  const stripe = await getStripe(isOldAccount);\n                  stripe?.redirectToCheckout({ sessionId });\n                })\n                .catch((err) => {\n                  alert(err);\n                  setClicked(false);\n                });\n              }\n            }}\n          >{`Upgrade to ${plan} ${capitalize(period)}`}</Button>\n\n          <div className=\"flex items-center justify-center space-x-2 text-center text-xs text-muted-foreground\">\n            {plan === \"Business\" && !isTrial ? (\n              <DataroomTrialModal>\n                <button\n                  className=\"underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80\"\n                  onClick={() => analytics.capture(\"Dataroom Trial Clicked\")}\n                >\n                  Looking for a trial?\n                </button>\n              </DataroomTrialModal>\n            ) : (\n              <a\n                href=\"https://cal.com/marcseitz/papermark\"\n                target=\"_blank\"\n                className=\"underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80\"\n              >\n                Looking for Papermark Enterprise?\n              </a>\n            )}\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/billing/upgrade-plan-modal-with-discount.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { getStripe } from \"@/ee/stripe/client\";\nimport { Feature, PlanEnum, getPlanFeatures } from \"@/ee/stripe/constants\";\nimport { getPriceIdFromPlan } from \"@/ee/stripe/functions/get-price-id-from-plan\";\nimport { PLANS } from \"@/ee/stripe/utils\";\nimport {\n  CheckIcon,\n  CircleHelpIcon,\n  Sparkles,\n  Users2Icon,\n  XIcon,\n} from \"lucide-react\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { capitalize, cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent, DialogTrigger } from \"@/components/ui/dialog\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  BadgeTooltip,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\n// Start Data Room Trial Button Component\nconst StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {\n  const router = useRouter();\n\n  const handleStartTrial = () => {\n    router.push(\"/welcome?type=dataroom-trial\");\n  };\n\n  return (\n    <span\n      onClick={handleStartTrial}\n      className=\"cursor-pointer underline underline-offset-4 hover:text-foreground\"\n    >\n      Start free data room trial\n    </span>\n  );\n};\n\n// Feature rendering component\nconst FeatureItem = ({ feature }: { feature: Feature }) => {\n  const baseClasses = `flex items-center ${feature.isHighlighted ? \"bg-orange-50 -mx-6 px-6 py-2 -my-1 font-bold rounded-md dark:bg-orange-900/20\" : \"\"}`;\n\n  if (feature.isUsers) {\n    return (\n      <div className={cn(\"justify-between gap-x-8\", baseClasses)}>\n        <div className=\"flex items-center gap-x-3\">\n          {feature.isNotIncluded ? (\n            <XIcon className=\"h-5 w-5 flex-shrink-0 text-gray-500\" />\n          ) : (\n            <CheckIcon className=\"h-5 w-5 flex-shrink-0 text-[#fb7a00]\" />\n          )}\n          <span>{feature.text}</span>\n        </div>\n        {feature.tooltip && (\n          <TooltipProvider>\n            <Tooltip delayDuration={0}>\n              <TooltipTrigger asChild>\n                <div className=\"cursor-help\">\n                  <Users2Icon className=\"h-4 w-4 text-gray-500\" />\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{feature.tooltip}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"text-sm\", baseClasses)}>\n      {feature.isNotIncluded ? (\n        <XIcon className=\"mr-3 h-5 w-5 flex-shrink-0 text-gray-500\" />\n      ) : (\n        <CheckIcon className=\"mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]\" />\n      )}\n      <div className=\"flex items-center gap-2\">\n        <span>{feature.text}</span>\n        {feature.tooltip && (\n          <BadgeTooltip content={feature.tooltip}>\n            <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n          </BadgeTooltip>\n        )}\n      </div>\n    </div>\n  );\n};\n\n// Segmented control component for Base/Plus/Premium selection\nconst PlanSelector = ({\n  value,\n  onChange,\n}: {\n  value: \"base\" | \"plus\" | \"premium\";\n  onChange: (value: \"base\" | \"plus\" | \"premium\") => void;\n}) => {\n  return (\n    <div className=\"mt-1 flex w-full rounded-lg border border-gray-200 p-1\">\n      <button\n        className={cn(\n          \"flex-1 rounded-md px-3 py-1 text-sm transition-colors\",\n          value === \"base\"\n            ? \"bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\",\n        )}\n        onClick={() => onChange(\"base\")}\n      >\n        Base\n      </button>\n      <button\n        className={cn(\n          \"flex-1 rounded-md px-3 py-1 text-sm transition-colors\",\n          value === \"plus\"\n            ? \"bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\",\n        )}\n        onClick={() => onChange(\"plus\")}\n      >\n        Plus\n      </button>\n      <button\n        className={cn(\n          \"flex-1 rounded-md px-3 py-1 text-sm transition-colors\",\n          value === \"premium\"\n            ? \"bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\",\n        )}\n        onClick={() => onChange(\"premium\")}\n      >\n        Premium\n      </button>\n    </div>\n  );\n};\n\nexport function UpgradePlanModalWithDiscount({\n  clickedPlan,\n  trigger,\n  open,\n  setOpen,\n  highlightItem,\n  children,\n}: {\n  clickedPlan: PlanEnum;\n  trigger?: string;\n  open?: boolean;\n  setOpen?: React.Dispatch<React.SetStateAction<boolean>>;\n  highlightItem?: string[];\n  children?: React.ReactNode;\n}) {\n  const router = useRouter();\n  const [period, setPeriod] = useState<\"yearly\" | \"monthly\">(\"yearly\");\n  const [selectedPlan, setSelectedPlan] = useState<string | null>(null);\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const {\n    plan: teamPlan,\n    isCustomer,\n    isOldAccount,\n    isTrial,\n    isFree,\n  } = usePlan();\n  const analytics = useAnalytics();\n  const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<\n    \"base\" | \"plus\" | \"premium\"\n  >(\"base\");\n\n  // Get next higher plan for current user\n  const getNextHigherPlan = () => {\n    if (isFree) return PlanEnum.Pro; // Free users see Pro as next\n    if (teamPlan === \"pro\") return PlanEnum.Business;\n    if (teamPlan === \"business\") return PlanEnum.DataRooms;\n    if (teamPlan === \"datarooms\") return PlanEnum.DataRoomsPlus;\n    if (teamPlan === \"datarooms-plus\") return PlanEnum.DataRoomsPremium;\n    return null;\n  };\n\n  const nextHigherPlan = getNextHigherPlan();\n\n  const plansToShow = useMemo(() => {\n    switch (clickedPlan) {\n      case PlanEnum.Pro:\n        return [PlanEnum.Pro, PlanEnum.Business];\n      case PlanEnum.Business:\n        return [PlanEnum.Business, PlanEnum.DataRooms];\n      case PlanEnum.DataRooms:\n        return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];\n      case PlanEnum.DataRoomsPlus:\n        return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];\n      case PlanEnum.DataRoomsPremium:\n        return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];\n      default:\n        return [PlanEnum.Pro, PlanEnum.Business];\n    }\n  }, [clickedPlan]);\n\n  // Track analytics event when modal is opened\n  useEffect(() => {\n    if (open) {\n      analytics.capture(\"Upgrade Button Clicked\", {\n        trigger: trigger,\n        teamId,\n      });\n    } else {\n      setDataRoomsPlanSelection(\"base\");\n    }\n  }, [open, trigger]);\n\n  const handleUpgradeClick = () => {\n    analytics.capture(\"Upgrade Button Clicked\", {\n      trigger: trigger,\n      teamId,\n    });\n  };\n\n  // If button is present, clone it and add onClick handler\n  const buttonChild = React.isValidElement<{\n    onClick?: React.MouseEventHandler<HTMLButtonElement>;\n  }>(children)\n    ? React.cloneElement(children, { onClick: handleUpgradeClick })\n    : children;\n\n  // Calculate 30% discounted yearly price\n  const getDiscountedYearlyPrice = (yearlyPrice: number) => {\n    return Math.round(yearlyPrice * 0.7); // Round to whole number\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{buttonChild}</DialogTrigger>\n      <DialogContent\n        className=\"max-h-[90vh] min-h-fit overflow-y-auto bg-white p-0 text-foreground dark:bg-gray-900\"\n        style={{\n          width: \"95vw\",\n          maxWidth: \"1200px\",\n        }}\n      >\n        <div className=\"flex flex-col lg:flex-row\">\n          {/* Left Sidebar - Discount Information */}\n          <div className=\"flex h-full w-full flex-col border-b border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 lg:w-64 lg:border-b-0 lg:border-r\">\n            <div className=\"mb-2\">\n              <h2 className=\"text-sm font-bold uppercase text-[#fb7a00]\">\n                SWITCH TO YEARLY\n              </h2>\n            </div>\n            <p className=\"mb-6 text-2xl font-semibold text-gray-900 dark:text-white\">\n              Save <span className=\"font-bold text-[#fb7a00]\">30%</span> on\n              yearly plans\n            </p>\n\n            {/* Decorative elements - Holiday section - Centered vertically */}\n            <div className=\"relative flex flex-1 flex-col items-center justify-center\">\n              <div className=\"mb-3 h-16 w-16 text-[#fb7a00] opacity-30\">\n                <Sparkles className=\"h-full w-full\" />\n              </div>\n              <div className=\"flex w-full items-center gap-2\">\n                <div className=\"h-px flex-1 bg-[#fb7a00]\"></div>\n                <p className=\"text-sm font-semibold text-[#fb7a00]\">NEW YEAR&apos;S</p>\n                <div className=\"h-px flex-1 bg-[#fb7a00]\"></div>\n              </div>\n              <p className=\"mt-2 text-xs text-gray-600 dark:text-gray-400\">\n                New Year&apos;s offer\n              </p>\n              {/* <div className=\"absolute bottom-0 h-12 w-12 text-[#fb7a00] opacity-20\">\n                <Sparkles className=\"h-full w-full\" />\n              </div> */}\n            </div>\n\n            <p className=\"mt-6 text-center text-xs text-gray-500 dark:text-gray-400\">\n              Ends Jan 15 2026\n            </p>\n          </div>\n\n          {/* Right Content - Plans */}\n          <div className=\"flex-1 p-6\">\n            {/* Monthly/Yearly Toggle */}\n            <div className=\"mb-6 flex items-center justify-center\">\n              <span className=\"mr-2 text-sm\">\n                Monthly{\" \"}\n                {period === \"monthly\" && (\n                  <span className=\"text-[#fb7a00]\"></span>\n                )}\n              </span>\n              <Switch\n                checked={period === \"yearly\"}\n                onCheckedChange={() =>\n                  setPeriod(period === \"monthly\" ? \"yearly\" : \"monthly\")\n                }\n              />\n              <span className=\"ml-2 text-sm\">Annually</span>\n            </div>\n\n            <div className=\"isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2\">\n              {plansToShow.map((planOption) => {\n                const isDataRoomsUpgrade = plansToShow.includes(\n                  PlanEnum.DataRooms,\n                );\n\n                // Determine which plan to show based on selection for Data Rooms\n                let effectivePlan = planOption;\n                let displayPlanName = planOption;\n\n                if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {\n                  if (dataRoomsPlanSelection === \"plus\") {\n                    effectivePlan = PlanEnum.DataRoomsPlus;\n                    displayPlanName = PlanEnum.DataRoomsPlus;\n                  } else if (dataRoomsPlanSelection === \"premium\") {\n                    effectivePlan = PlanEnum.DataRoomsPremium;\n                    displayPlanName = PlanEnum.DataRoomsPremium;\n                  }\n                }\n\n                const planFeatures = getPlanFeatures(effectivePlan, {\n                  period,\n                });\n\n                const planData = PLANS.find((p) => p.name === displayPlanName);\n                const monthlyPrice = planData?.price.monthly.amount || 0;\n                const yearlyPrice = planData?.price.yearly.amount || 0;\n                const discountedYearlyPrice =\n                  getDiscountedYearlyPrice(yearlyPrice);\n\n                // Determine if this plan should have orange styling\n                const isOrangePlan = isFree\n                  ? displayPlanName === PlanEnum.Business ||\n                    displayPlanName === PlanEnum.DataRoomsPlus\n                  : displayPlanName === nextHigherPlan;\n\n                return (\n                  <div\n                    key={displayPlanName}\n                    className={`relative flex flex-col rounded-lg border ${\n                      isOrangePlan\n                        ? \"border-[#fb7a00]\"\n                        : displayPlanName === PlanEnum.DataRoomsPremium\n                          ? \"border-gray-900\"\n                          : \"border-gray-200\"\n                    } bg-white p-6 shadow-sm dark:bg-gray-900`}\n                  >\n                    <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                      <div className=\"flex items-center justify-between\">\n                        <h3 className=\"text-balance text-xl font-medium text-gray-900 dark:text-white\">\n                          {displayPlanName}\n                        </h3>\n                      </div>\n                      {period === \"yearly\" &&\n                        (() => {\n                          const monthlyForYear = monthlyPrice * 12;\n                          const yearlyWithDiscount = Math.round(\n                            yearlyPrice * 12 * 0.7,\n                          );\n                          const savings = monthlyForYear - yearlyWithDiscount;\n                          return savings > 0 ? (\n                            <span className=\"absolute right-2 top-2 rounded bg-[#fb7a00]/10 px-2 py-1 text-xs font-medium text-[#fb7a00]\">\n                              Save €{savings}/year\n                            </span>\n                          ) : null;\n                        })()}\n                      {period === \"monthly\" && isOrangePlan && (\n                        <span className=\"absolute right-2 top-2 rounded bg-[#fb7a00] px-2 py-1 text-xs text-white\">\n                          {displayPlanName === PlanEnum.Business &&\n                            \"Most popular\"}\n                          {displayPlanName === PlanEnum.DataRoomsPlus &&\n                            \"Best deal\"}\n                        </span>\n                      )}\n                    </div>\n\n                    <div className=\"mb-2\">\n                      {period === \"yearly\" ? (\n                        // Yearly: Show discounted price with crossed out original\n                        <>\n                          <div className=\"flex items-baseline gap-2\">\n                            <span className=\"text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white\">\n                              €{discountedYearlyPrice}\n                            </span>\n                            <span className=\"text-xl text-gray-500 line-through\">\n                              €{yearlyPrice}\n                            </span>\n                          </div>\n                          <span className=\"text-gray-500 dark:text-white/75\">\n                            /month, billed annually\n                          </span>\n                        </>\n                      ) : (\n                        // Monthly: Show regular monthly price (no discount)\n                        <>\n                          <div className=\"flex items-baseline gap-2\">\n                            <span className=\"text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white\">\n                              €{monthlyPrice}\n                            </span>\n                          </div>\n                          <span className=\"text-gray-500 dark:text-white/75\">\n                            /month\n                          </span>\n                        </>\n                      )}\n                    </div>\n\n                    {planOption === PlanEnum.DataRooms &&\n                      isDataRoomsUpgrade &&\n                      !plansToShow.includes(PlanEnum.DataRoomsPlus) && (\n                        <PlanSelector\n                          value={dataRoomsPlanSelection}\n                          onChange={setDataRoomsPlanSelection}\n                        />\n                      )}\n\n                    <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                      {planFeatures.featureIntro}\n                    </p>\n\n                    <ul className=\"mb-6 mt-2 space-y-2 text-sm leading-6 text-gray-600 dark:text-muted-foreground\">\n                      {planFeatures.features.map((feature, i) => (\n                        <li key={i}>\n                          <FeatureItem\n                            feature={{\n                              ...feature,\n                              isHighlighted: highlightItem?.includes(\n                                feature.id,\n                              ),\n                            }}\n                          />\n                        </li>\n                      ))}\n                    </ul>\n\n                    <div className=\"mt-auto\">\n                      <Button\n                        variant={isOrangePlan ? \"default\" : \"outline\"}\n                        className={`w-full py-2 text-sm ${\n                          isOrangePlan\n                            ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                            : \"bg-gray-800 text-white hover:bg-gray-900 hover:text-white dark:hover:bg-gray-700/80\"\n                        }`}\n                        loading={selectedPlan === planOption}\n                        disabled={selectedPlan !== null}\n                        onClick={() => {\n                          const priceId = getPriceIdFromPlan({\n                            planName: displayPlanName,\n                            period,\n                            isOld: isOldAccount,\n                          });\n\n                          setSelectedPlan(planOption);\n                          // Apply 30% coupon only for yearly plans\n                          // Uses same logic as retention flow: getCouponFromPlan() in API routes\n                          const applyDiscount = period === \"yearly\";\n                          if (isCustomer && teamPlan !== \"free\") {\n                            // For existing customers: Apply coupon directly to subscription (same as retention flow)\n                            fetch(`/api/teams/${teamId}/billing/manage`, {\n                              method: \"POST\",\n                              headers: {\n                                \"Content-Type\": \"application/json\",\n                              },\n                              body: JSON.stringify({\n                                priceId,\n                                upgradePlan: true,\n                                applyYearlyDiscount: applyDiscount, // Only true for yearly plans\n                              }),\n                            })\n                              .then(async (res) => {\n                                if (!res.ok) {\n                                  const errorData = await res\n                                    .json()\n                                    .catch(() => null);\n                                  const errorMessage =\n                                    errorData?.error ||\n                                    errorData?.message ||\n                                    res.statusText ||\n                                    \"Failed to upgrade plan\";\n                                  throw new Error(errorMessage);\n                                }\n                                const url = await res.json();\n                                router.push(url);\n                              })\n                              .catch((err) => {\n                                alert(err.message || err);\n                                setSelectedPlan(null);\n                              });\n                          } else {\n                            // For new customers: Add coupon to Stripe checkout session\n                            fetch(\n                              `/api/teams/${teamId}/billing/upgrade?priceId=${priceId}${applyDiscount ? \"&applyYearlyDiscount=true\" : \"\"}`,\n                              {\n                                method: \"POST\",\n                                headers: {\n                                  \"Content-Type\": \"application/json\",\n                                },\n                              },\n                            )\n                              .then(async (res) => {\n                                if (!res.ok) {\n                                  const errorData = await res\n                                    .json()\n                                    .catch(() => null);\n                                  const errorMessage =\n                                    errorData?.error ||\n                                    errorData?.message ||\n                                    res.statusText ||\n                                    \"Failed to start checkout\";\n                                  throw new Error(errorMessage);\n                                }\n                                const data = await res.json();\n                                const { id: sessionId } = data;\n                                const stripe = await getStripe(isOldAccount);\n                                stripe?.redirectToCheckout({ sessionId });\n                              })\n                              .catch((err) => {\n                                alert(err.message || err);\n                                setSelectedPlan(null);\n                              });\n                          }\n                        }}\n                      >\n                        {selectedPlan === planOption\n                          ? \"Redirecting to Stripe...\"\n                          : `Upgrade to ${displayPlanName} ${capitalize(period)}`}\n                      </Button>\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n            <div className=\"mt-6 flex flex-col items-center text-center text-sm text-muted-foreground\">\n              All plans include unlimited visitors and page by page document\n              analytics.\n              <div className=\"flex items-center gap-2\">\n                <Link\n                  href=\"/settings/upgrade-holiday-offer\"\n                  className=\"underline underline-offset-4 hover:text-foreground\"\n                >\n                  See all plans\n                </Link>\n                {((teamPlan === \"free\" && !isTrial) ||\n                  (teamPlan === \"pro\" && !isTrial)) && (\n                  <>\n                    <span>|</span>\n                    <StartDataRoomTrialButton teamId={teamId} />\n                  </>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/billing/upgrade-plan-modal.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { getStripe } from \"@/ee/stripe/client\";\nimport { Feature, PlanEnum, getPlanFeatures } from \"@/ee/stripe/constants\";\nimport { getPriceIdFromPlan } from \"@/ee/stripe/functions/get-price-id-from-plan\";\nimport { PLANS } from \"@/ee/stripe/utils\";\nimport { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from \"lucide-react\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { capitalize, cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent, DialogTrigger } from \"@/components/ui/dialog\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  BadgeTooltip,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\n// Start Data Room Trial Button Component\nconst StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {\n  const router = useRouter();\n\n  const handleStartTrial = () => {\n    router.push(\"/welcome?type=dataroom-trial\");\n  };\n\n  return (\n    <span\n      onClick={handleStartTrial}\n      className=\"cursor-pointer underline underline-offset-4 hover:text-foreground\"\n    >\n      Start free data room trial\n    </span>\n  );\n};\n\n// Feature rendering component\nconst FeatureItem = ({ feature }: { feature: Feature }) => {\n  const baseClasses = `flex items-center ${feature.isHighlighted ? \"bg-orange-50 -mx-6 px-6 py-2 -my-1 font-bold rounded-md dark:bg-orange-900/20\" : \"\"}`;\n\n  if (feature.isUsers) {\n    return (\n      <div className={cn(\"justify-between gap-x-8\", baseClasses)}>\n        <div className=\"flex items-center gap-x-3\">\n          {feature.isNotIncluded ? (\n            <XIcon className=\"h-5 w-5 flex-shrink-0 text-gray-500\" />\n          ) : (\n            <CheckIcon className=\"h-5 w-5 flex-shrink-0 text-[#fb7a00]\" />\n          )}\n          <span>{feature.text}</span>\n        </div>\n        {feature.tooltip && (\n          <TooltipProvider>\n            <Tooltip delayDuration={0}>\n              <TooltipTrigger asChild>\n                <div className=\"cursor-help\">\n                  <Users2Icon className=\"h-4 w-4 text-gray-500\" />\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{feature.tooltip}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"text-sm\", baseClasses)}>\n      {feature.isNotIncluded ? (\n        <XIcon className=\"mr-3 h-5 w-5 flex-shrink-0 text-gray-500\" />\n      ) : (\n        <CheckIcon className=\"mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]\" />\n      )}\n      <div className=\"flex items-center gap-2\">\n        <span>{feature.text}</span>\n        {feature.tooltip && (\n          <BadgeTooltip content={feature.tooltip}>\n            <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n          </BadgeTooltip>\n        )}\n      </div>\n    </div>\n  );\n};\n\n// Segmented control component for Base/Plus/Premium selection\nconst PlanSelector = ({\n  value,\n  onChange,\n}: {\n  value: \"base\" | \"plus\" | \"premium\";\n  onChange: (value: \"base\" | \"plus\" | \"premium\") => void;\n}) => {\n  return (\n    <div className=\"mt-1 flex w-full rounded-lg border border-gray-200 p-1\">\n      <button\n        className={cn(\n          \"flex-1 rounded-md px-3 py-1 text-sm transition-colors\",\n          value === \"base\"\n            ? \"bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\",\n        )}\n        onClick={() => onChange(\"base\")}\n      >\n        Base\n      </button>\n      <button\n        className={cn(\n          \"flex-1 rounded-md px-3 py-1 text-sm transition-colors\",\n          value === \"plus\"\n            ? \"bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\",\n        )}\n        onClick={() => onChange(\"plus\")}\n      >\n        Plus\n      </button>\n      <button\n        className={cn(\n          \"flex-1 rounded-md px-3 py-1 text-sm transition-colors\",\n          value === \"premium\"\n            ? \"bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\",\n        )}\n        onClick={() => onChange(\"premium\")}\n      >\n        Premium\n      </button>\n    </div>\n  );\n};\n\nexport function UpgradePlanModal({\n  clickedPlan,\n  trigger,\n  open,\n  setOpen,\n  highlightItem,\n  children,\n}: {\n  clickedPlan: PlanEnum;\n  trigger?: string;\n  open?: boolean;\n  setOpen?: React.Dispatch<React.SetStateAction<boolean>>;\n  highlightItem?: string[];\n  children?: React.ReactNode;\n}) {\n  const router = useRouter();\n  const [period, setPeriod] = useState<\"yearly\" | \"monthly\">(\"yearly\");\n  const [selectedPlan, setSelectedPlan] = useState<string | null>(null);\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { plan: teamPlan, isCustomer, isOldAccount, isTrial } = usePlan();\n  const analytics = useAnalytics();\n  const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<\n    \"base\" | \"plus\" | \"premium\"\n  >(\"base\");\n\n  const plansToShow = useMemo(() => {\n    switch (clickedPlan) {\n      case PlanEnum.Pro:\n        return [PlanEnum.Pro, PlanEnum.Business];\n      case PlanEnum.Business:\n        return [PlanEnum.Business, PlanEnum.DataRooms];\n      case PlanEnum.DataRooms:\n        return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];\n      case PlanEnum.DataRoomsPlus:\n        return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];\n      case PlanEnum.DataRoomsPremium:\n        return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];\n      default:\n        return [PlanEnum.Pro, PlanEnum.Business];\n    }\n  }, [clickedPlan]);\n\n  // Track analytics event when modal is opened\n  useEffect(() => {\n    if (open) {\n      analytics.capture(\"Upgrade Button Clicked\", {\n        trigger: trigger,\n        teamId,\n      });\n    } else {\n      setDataRoomsPlanSelection(\"base\");\n    }\n  }, [open, trigger]);\n\n  const handleUpgradeClick = () => {\n    analytics.capture(\"Upgrade Button Clicked\", {\n      trigger: trigger,\n      teamId,\n    });\n  };\n\n  // If button is present, clone it and add onClick handler\n  const buttonChild = React.isValidElement<{\n    onClick?: React.MouseEventHandler<HTMLButtonElement>;\n  }>(children)\n    ? React.cloneElement(children, { onClick: handleUpgradeClick })\n    : children;\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{buttonChild}</DialogTrigger>\n      <DialogContent\n        className=\"max-h-[90vh] min-h-fit overflow-y-auto bg-gray-50 text-foreground dark:bg-gray-900\"\n        style={{\n          width: \"90vw\",\n          maxWidth: \"900px\",\n        }}\n      >\n        <div className=\"flex items-center justify-center\">\n          <span className=\"mr-2 text-sm\">Monthly</span>\n          <Switch\n            checked={period === \"yearly\"}\n            onCheckedChange={() =>\n              setPeriod(period === \"monthly\" ? \"yearly\" : \"monthly\")\n            }\n          />\n          <span className=\"ml-2 text-sm\">\n            Annually <span className=\"text-[#fb7a00]\">(Save up to 35%)</span>\n          </span>\n        </div>\n\n        <div className=\"isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2\">\n          {plansToShow.map((planOption) => {\n            const isDataRoomsUpgrade = plansToShow.includes(PlanEnum.DataRooms);\n\n            // Determine which plan to show based on selection for Data Rooms\n            let effectivePlan = planOption;\n            let displayPlanName = planOption;\n\n            if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {\n              if (dataRoomsPlanSelection === \"plus\") {\n                effectivePlan = PlanEnum.DataRoomsPlus;\n                displayPlanName = PlanEnum.DataRoomsPlus;\n              } else if (dataRoomsPlanSelection === \"premium\") {\n                effectivePlan = PlanEnum.DataRoomsPremium;\n                displayPlanName = PlanEnum.DataRoomsPremium;\n              }\n            }\n\n            const planFeatures = getPlanFeatures(effectivePlan, {\n              period,\n            });\n\n            return (\n              <div\n                key={displayPlanName}\n                className={`relative flex flex-col rounded-lg border ${\n                  planOption === PlanEnum.Business\n                    ? \"border-[#fb7a00]\"\n                    : planOption === PlanEnum.DataRoomsPlus &&\n                        isDataRoomsUpgrade\n                      ? \"border-gray-900\"\n                      : \"border-gray-200\"\n                } bg-white p-6 shadow-sm dark:bg-gray-900`}\n              >\n                <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                  <div className=\"flex items-center justify-between\">\n                    <h3 className=\"text-balance text-xl font-medium text-gray-900 dark:text-white\">\n                      {displayPlanName}\n                    </h3>\n                  </div>\n                  <span\n                    className={cn(\n                      \"absolute right-2 top-2 rounded px-2 py-1 text-xs text-white\",\n                      planOption === PlanEnum.Business && \"bg-[#fb7a00]\",\n                      displayPlanName === PlanEnum.DataRoomsPlus &&\n                        \"bg-gray-800 dark:bg-gray-200 dark:text-gray-900\",\n                    )}\n                  >\n                    {planOption === PlanEnum.Business && \"Most popular\"}\n                    {displayPlanName === PlanEnum.DataRoomsPlus && \"Best deal\"}\n                  </span>\n                </div>\n\n                <div className=\"mb-2\">\n                  <span className=\"text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white\">\n                    €\n                    {\n                      PLANS.find((p) => p.name === displayPlanName)?.price[\n                        period\n                      ].amount\n                    }\n                  </span>\n                  <span className=\"text-gray-500 dark:text-white/75\">\n                    /month{period === \"yearly\" && \", billed annually\"}\n                  </span>\n                </div>\n\n                {planOption === PlanEnum.DataRooms &&\n                  isDataRoomsUpgrade &&\n                  !plansToShow.includes(PlanEnum.DataRoomsPlus) && (\n                    <PlanSelector\n                      value={dataRoomsPlanSelection}\n                      onChange={setDataRoomsPlanSelection}\n                    />\n                  )}\n\n                <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                  {planFeatures.featureIntro}\n                </p>\n\n                <ul className=\"mb-6 mt-2 space-y-2 text-sm leading-6 text-gray-600 dark:text-muted-foreground\">\n                  {planFeatures.features.map((feature, i) => (\n                    <li key={i}>\n                      <FeatureItem\n                        feature={{\n                          ...feature,\n                          isHighlighted: highlightItem?.includes(feature.id),\n                        }}\n                      />\n                    </li>\n                  ))}\n                </ul>\n\n                <div className=\"mt-auto\">\n                  <Button\n                    variant={\n                      planOption === PlanEnum.Business ? \"default\" : \"outline\"\n                    }\n                    className={`w-full py-2 text-sm ${\n                      planOption === PlanEnum.Business\n                        ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                        : \"bg-gray-800 text-white hover:bg-gray-900 hover:text-white dark:hover:bg-gray-700/80\"\n                    }`}\n                    loading={selectedPlan === planOption}\n                    disabled={selectedPlan !== null}\n                    onClick={() => {\n                      const priceId = getPriceIdFromPlan({\n                        planName: displayPlanName,\n                        period,\n                        isOld: isOldAccount,\n                      });\n\n                      setSelectedPlan(planOption);\n                      if (isCustomer && teamPlan !== \"free\") {\n                        fetch(`/api/teams/${teamId}/billing/manage`, {\n                          method: \"POST\",\n                          headers: {\n                            \"Content-Type\": \"application/json\",\n                          },\n                          body: JSON.stringify({\n                            priceId,\n                            upgradePlan: true,\n                          }),\n                        })\n                          .then(async (res) => {\n                            const url = await res.json();\n                            router.push(url);\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      } else {\n                        fetch(\n                          `/api/teams/${teamId}/billing/upgrade?priceId=${\n                            priceId\n                          }`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                          },\n                        )\n                          .then(async (res) => {\n                            const data = await res.json();\n                            const { id: sessionId } = data;\n                            const stripe = await getStripe(isOldAccount);\n                            stripe?.redirectToCheckout({ sessionId });\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      }\n                    }}\n                  >\n                    {selectedPlan === planOption\n                      ? \"Redirecting to Stripe...\"\n                      : `Upgrade to ${displayPlanName} ${capitalize(period)}`}\n                  </Button>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n        <div className=\"flex flex-col items-center text-center text-sm text-muted-foreground\">\n          All plans include unlimited visitors and page by page document\n          analytics.\n          <div className=\"flex items-center gap-2\">\n            <Link\n              href={`/settings/upgrade${\n                clickedPlan === PlanEnum.Pro\n                  ? \"?view=documents\"\n                  : clickedPlan === PlanEnum.Business\n                    ? \"?view=business-datarooms\"\n                    : \"\"\n              }`}\n              className=\"underline underline-offset-4 hover:text-foreground\"\n            >\n              See all plans\n            </Link>\n            {((teamPlan === \"free\" && !isTrial) ||\n              (teamPlan === \"pro\" && !isTrial)) && (\n              <>\n                <span>|</span>\n                <StartDataRoomTrialButton teamId={teamId} />\n              </>\n            )}\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/billing/yearly-upgrade-banner.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/router\";\n\nimport { Dispatch, SetStateAction, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum, getPlanFeatures } from \"@/ee/stripe/constants\";\nimport { getPriceIdFromPlan } from \"@/ee/stripe/functions/get-price-id-from-plan\";\nimport { PLANS } from \"@/ee/stripe/utils\";\nimport Cookies from \"js-cookie\";\nimport { CheckIcon, Sparkles, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { UpgradePlanModal } from \"./upgrade-plan-modal\";\n\ninterface YearlyUpgradeBannerProps {\n  setShowBanner: Dispatch<SetStateAction<boolean | null>>;\n}\n\nexport default function YearlyUpgradeBanner({\n  setShowBanner,\n}: YearlyUpgradeBannerProps) {\n  const router = useRouter();\n  const { plan: teamPlan } = usePlan();\n\n  const handleHideBanner = () => {\n    setShowBanner(false);\n    Cookies.set(\"hideYearlyUpgradeBanner\", \"yearly-upgrade-banner\", {\n      expires: 7,\n    });\n  };\n\n  // Get next higher plan\n  const getNextPlan = () => {\n    if (teamPlan === \"pro\") return PlanEnum.Business;\n    if (teamPlan === \"business\") return PlanEnum.DataRooms;\n    if (teamPlan === \"datarooms\") return PlanEnum.DataRoomsPlus;\n    if (teamPlan === \"datarooms-plus\") return PlanEnum.DataRoomsPremium;\n    return null;\n  };\n\n  const nextPlan = getNextPlan();\n\n  if (!nextPlan) return null; // Don't show banner if no next plan\n\n  return (\n    <AnimatePresence>\n      <motion.aside\n        initial={{ x: 400, opacity: 0 }}\n        animate={{ x: 0, opacity: 1 }}\n        exit={{ x: 400, opacity: 0 }}\n        transition={{ type: \"spring\", damping: 25, stiffness: 200 }}\n        className={cn(\n          \"fixed right-0 top-1/2 z-50 w-80 -translate-y-1/2 rounded-l-lg border-b border-l border-t border-gray-200 bg-white p-6 shadow-xl dark:border-gray-800 dark:bg-gray-900\",\n        )}\n      >\n        <button\n          type=\"button\"\n          onClick={handleHideBanner}\n          className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n        >\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close</span>\n        </button>\n\n        <div className=\"mb-4 flex items-center gap-2\">\n          <Sparkles className=\"h-5 w-5 text-[#fb7a00]\" />\n          <span className=\"text-lg font-bold text-black dark:text-white\">\n            UPGRADE YOUR PLAN\n          </span>\n        </div>\n\n        <div className=\"mb-2\">\n          <p className=\"text-xs text-gray-500 dark:text-gray-400\">\n            Save up to 35% with yearly plans\n          </p>\n        </div>\n\n        {/* Next Plan Card - Orange */}\n        {(() => {\n          const planData = PLANS.find((p) => p.name === nextPlan);\n          if (!planData) return null;\n\n          const yearlyPrice = planData.price.yearly.amount;\n          const monthlyPrice = planData.price.monthly.amount;\n          const monthlyForYear = monthlyPrice * 12;\n          const yearlyForYear = yearlyPrice * 12;\n          const savings = monthlyForYear - yearlyForYear;\n          const planFeatures = getPlanFeatures(nextPlan, {\n            period: \"yearly\",\n            maxFeatures: 4,\n          });\n\n          return (\n            <div className=\"rounded-lg border-2 border-[#fb7a00] bg-orange-50 p-3 dark:bg-orange-900/20\">\n              <div className=\"mb-1 flex items-center justify-between\">\n                <span className=\"text-xs font-bold text-[#fb7a00]\">\n                  RECOMMENDED\n                </span>\n                {savings > 0 && (\n                  <span className=\"text-xs font-medium text-[#fb7a00]\">\n                    Save €{savings}/year\n                  </span>\n                )}\n              </div>\n              <p className=\"mb-1 text-sm font-semibold text-gray-900 dark:text-white\">\n                {nextPlan} plan\n              </p>\n              <div className=\"mb-3 flex items-baseline gap-2\">\n                <span className=\"text-2xl font-semibold text-gray-900 dark:text-white\">\n                  €{yearlyPrice}\n                </span>\n                <span className=\"text-xs text-gray-600 dark:text-gray-400\">\n                  /mo, billed annually\n                </span>\n              </div>\n\n              {/* Features */}\n              <ul className=\"mb-3 space-y-1.5 text-xs text-gray-700 dark:text-gray-300\">\n                {planFeatures.features.slice(0, 4).map((feature, i) => (\n                  <li key={i} className=\"flex items-center gap-2\">\n                    <CheckIcon className=\"h-3.5 w-3.5 flex-shrink-0 text-[#fb7a00]\" />\n                    <span className=\"leading-tight\">{feature.text}</span>\n                  </li>\n                ))}\n              </ul>\n\n              <UpgradePlanModal\n                clickedPlan={nextPlan}\n                trigger=\"yearly_upgrade_banner\"\n              >\n                <Button\n                  type=\"button\"\n                  size=\"sm\"\n                  className=\"w-full bg-[#fb7a00] py-3 leading-tight text-white hover:bg-[#fb7a00]/90\"\n                >\n                  Upgrade to {nextPlan} Yearly\n                </Button>\n              </UpgradePlanModal>\n            </div>\n          );\n        })()}\n\n        <p\n          onClick={() => router.push(\"/settings/upgrade\")}\n          className=\"mt-4 cursor-pointer text-center text-xs text-gray-500 underline hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300\"\n        >\n          Compare all plans\n        </p>\n      </motion.aside>\n    </AnimatePresence>\n  );\n}\n"
  },
  {
    "path": "components/blur-image.tsx",
    "content": "\"use client\";\n\nimport Image, { ImageProps } from \"next/image\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport function BlurImage(props: ImageProps) {\n  const [loading, setLoading] = useState(true);\n  const [src, setSrc] = useState(props.src);\n  useEffect(() => setSrc(props.src), [props.src]); // update the `src` value when the `prop.src` value changes\n\n  const handleLoad = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {\n    setLoading(false);\n    const target = e.target as HTMLImageElement;\n    if (target.naturalWidth <= 16 && target.naturalHeight <= 16) {\n      setSrc(`https://avatar.vercel.sh/${encodeURIComponent(props.alt)}`);\n    }\n  };\n\n  return (\n    <Image\n      {...props}\n      src={src}\n      alt={props.alt}\n      className={cn(loading ? \"blur-[2px]\" : \"blur-0\", props.className)}\n      onLoad={handleLoad}\n      onError={() => {\n        setSrc(`https://avatar.vercel.sh/${encodeURIComponent(props.alt)}`); // if the image fails to load, use the default avatar\n      }}\n      unoptimized\n    />\n  );\n}\n"
  },
  {
    "path": "components/charts/bar-chart-tooltip.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useDocumentThumbnail } from \"@/lib/swr/use-document\";\n\nimport { getColorForVersion, timeFormatter } from \"./utils\";\n\nconst CustomTooltip = ({\n  payload,\n  active,\n}: {\n  payload: any;\n  active: boolean | undefined;\n}) => {\n  const router = useRouter();\n  const routerDocumentId = router.query.id as string;\n\n  const pageNumber =\n    payload && payload.length > 0 ? parseInt(payload[0].payload.pageNumber) : 0;\n\n  const documentId =\n    payload && payload.length > 0 && payload[0].payload.documentId\n      ? (payload[0].payload.documentId as string)\n      : routerDocumentId;\n\n  const versionNumber =\n    payload && payload.length > 0 && !isNaN(parseInt(payload[0].payload.versionNumber))\n      ? parseInt(payload[0].payload.versionNumber)\n      : 1;\n  const { data, error } = useDocumentThumbnail(\n    pageNumber,\n    documentId,\n    versionNumber,\n  );\n\n  const imageUrl = data && !error ? data.imageUrl : null; // Always called, regardless of `active` or `payload`\n\n  if (!active || !payload || payload.length === 0) return null;\n\n  return (\n    <>\n      <div className=\"w-52 rounded-md border border-tremor-border bg-tremor-background text-sm leading-6 dark:border-dark-tremor-border dark:bg-dark-tremor-background\">\n        <div className=\"rounded-t-md border-b border-tremor-border bg-tremor-background px-2.5 py-2 dark:border-dark-tremor-border dark:bg-dark-tremor-background\">\n          <p className=\"font-medium text-tremor-content dark:text-dark-tremor-content\">\n            Page {payload[0].payload.pageNumber}\n          </p>\n          {imageUrl ? (\n            <img\n              src={imageUrl}\n              alt={`Page ${payload[0].payload.pageNumber} Thumbnail`}\n            />\n          ) : null}\n        </div>\n        {payload.map((item: any, idx: number) => (\n          <div\n            className=\"flex w-full items-center justify-between space-x-4 px-2.5 py-2\"\n            key={idx}\n          >\n            <div className=\"text-overflow-ellipsis flex items-center space-x-2 overflow-hidden whitespace-nowrap\">\n              <span\n                className={`bg-${getColorForVersion(item.dataKey)}-500 h-2.5 w-2.5 flex-shrink-0 rounded-full`}\n                aria-hidden=\"true\"\n              ></span>\n              <p className=\"text-overflow-ellipsis overflow-hidden whitespace-nowrap text-tremor-content dark:text-dark-tremor-content\">\n                {item.dataKey}\n              </p>\n            </div>\n            <p className=\"font-medium text-tremor-content-emphasis dark:text-dark-tremor-content-emphasis\">\n              {timeFormatter(item.value)}\n            </p>\n          </div>\n        ))}\n      </div>\n    </>\n  );\n};\n\nexport default CustomTooltip;\n"
  },
  {
    "path": "components/charts/bar-chart.tsx",
    "content": "import { useState } from \"react\";\n\nimport { BarChart } from \"@tremor/react\";\n\nimport CustomTooltip from \"./bar-chart-tooltip\";\nimport {\n  type Data,\n  type SumData,\n  type TransformedData,\n  getColors,\n  timeFormatter,\n} from \"./utils\";\n\nconst renameDummyDurationKey = (data: Data[]): TransformedData[] => {\n  return data.reduce((acc, { pageNumber, data }) => {\n    const transformedItem: Partial<TransformedData> = { pageNumber };\n\n    data.forEach(({ versionNumber, avg_duration }) => {\n      transformedItem[`Example time spent per page`] = avg_duration;\n    });\n\n    acc.push(transformedItem as TransformedData);\n    return acc;\n  }, [] as TransformedData[]);\n};\n\nconst renameSumDurationKey = (\n  data: SumData[],\n  versionNumber?: number,\n  documentId?: string,\n) => {\n  return data.map((item) => {\n    return {\n      ...item,\n      \"Time spent per page\": item.sum_duration,\n      sum_duration: undefined,\n      versionNumber: versionNumber ?? 1,\n      documentId: documentId,\n    };\n  });\n};\n\n// Transform data\nconst transformData = (data: Data[]): TransformedData[] => {\n  return data.reduce((acc, { pageNumber, data }) => {\n    const transformedItem: Partial<TransformedData> = { pageNumber };\n\n    data.forEach(({ versionNumber, avg_duration }) => {\n      transformedItem[`Version ${versionNumber}`] = avg_duration;\n    });\n\n    acc.push(transformedItem as TransformedData);\n    return acc;\n  }, [] as TransformedData[]);\n};\n\nconst getVersionNumbers = (data: TransformedData[]) => {\n  return [\n    ...new Set(\n      data.flatMap((item) =>\n        Object.keys(item).filter((key) => key !== \"pageNumber\"),\n      ),\n    ),\n  ];\n};\n\nexport default function BarChartComponent({\n  data,\n  isSum = false,\n  isDummy = false,\n  versionNumber,\n  documentId,\n}: {\n  data: any;\n  isSum?: boolean;\n  isDummy?: boolean;\n  versionNumber?: number;\n  documentId?: string;\n}) {\n  const [, setValue] = useState<any>(null);\n\n  if (isSum) {\n    const renamedData = renameSumDurationKey(data, versionNumber, documentId);\n\n    return (\n      <BarChart\n        className=\"mt-6 rounded-tremor-small\"\n        data={renamedData}\n        index=\"pageNumber\"\n        categories={[\"Time spent per page\"]}\n        colors={[\"emerald\"]}\n        valueFormatter={timeFormatter}\n        yAxisWidth={50}\n        showGridLines={false}\n        onValueChange={(v) => setValue(v)}\n        customTooltip={isDummy ? undefined : CustomTooltip}\n      />\n    );\n  }\n\n  let renamedData = transformData(data);\n  let versionNumbers = getVersionNumbers(renamedData);\n  let colors = getColors(versionNumbers);\n\n  if (isDummy) {\n    colors = [\"gray-300\"];\n    renamedData = renameDummyDurationKey(data);\n    versionNumbers = getVersionNumbers(renamedData);\n  }\n\n  return (\n    <BarChart\n      className=\"mt-6 rounded-tremor-small\"\n      data={renamedData}\n      index=\"pageNumber\"\n      categories={versionNumbers}\n      colors={colors}\n      valueFormatter={timeFormatter}\n      yAxisWidth={50}\n      showGridLines={false}\n      onValueChange={(v) => setValue(v)}\n      customTooltip={isDummy ? undefined : CustomTooltip}\n    />\n  );\n}\n"
  },
  {
    "path": "components/charts/utils.ts",
    "content": "export type Data = {\n  pageNumber: string;\n  data: {\n    versionNumber: number;\n    avg_duration: number;\n  }[];\n};\n\nexport type SumData = {\n  pageNumber: string;\n  sum_duration: number;\n};\n\nexport type TransformedData = {\n  pageNumber: string;\n  [key: string]: number | string; // Adjusted type to accommodate version keys\n};\n\nexport type CustomTooltipTypeBar = {\n  payload: any;\n  active: boolean | undefined;\n  label: any;\n};\n\nexport type Color =\n  | \"neutral\"\n  | \"emerald\"\n  | \"gray\"\n  | \"slate\"\n  | \"zinc\"\n  | \"stone\"\n  | \"red\"\n  | \"orange\"\n  | \"amber\"\n  | \"yellow\"\n  | \"lime\"\n  | \"green\"\n  | \"teal\"\n  | \"cyan\"\n  | \"sky\"\n  | \"blue\"\n  | \"indigo\"\n  | \"violet\"\n  | \"purple\"\n  | \"fuchsia\"\n  | \"pink\"\n  | \"rose\"\n  | \"gray-300\";\n\nexport const getColors = (versionNumbers: string[]): Color[] => {\n  const colorArray = [\n    \"emerald\",\n    \"teal\",\n    \"gray\",\n    \"orange\",\n    \"zinc\",\n    \"neutral\",\n    \"stone\",\n    \"red\",\n    \"amber\",\n    \"yellow\",\n    \"lime\",\n    \"green\",\n    \"cyan\",\n    \"sky\",\n    \"blue\",\n    \"indigo\",\n    \"violet\",\n    \"purple\",\n    \"fuchsia\",\n    \"pink\",\n    \"rose\",\n  ];\n  return versionNumbers.map((versionNumber: string) => {\n    const versionIndex = parseInt(versionNumber.split(\" \")[1]) - 1;\n    return colorArray[versionIndex % colorArray.length] as Color;\n  });\n};\n\nexport const getColorForVersion = (versionNumber: string): Color => {\n  const versionIndex = parseInt(versionNumber.split(\" \")[1]) - 1;\n  const colorArray = [\n    \"emerald\",\n    \"teal\",\n    \"gray\",\n    \"orange\",\n    \"zinc\",\n    \"neutral\",\n    \"stone\",\n    \"red\",\n    \"amber\",\n    \"yellow\",\n    \"lime\",\n    \"green\",\n    \"cyan\",\n    \"sky\",\n    \"blue\",\n    \"indigo\",\n    \"violet\",\n    \"purple\",\n    \"fuchsia\",\n    \"pink\",\n    \"rose\",\n  ];\n  return colorArray[versionIndex % colorArray.length] as Color;\n};\n\nexport const timeFormatter = (number: number) => {\n  const totalSeconds = Math.floor(number / 1000);\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = Math.round(totalSeconds % 60);\n\n  // Adding zero padding if seconds less than 10\n  const secondsFormatted = seconds < 10 ? `0${seconds}` : `${seconds}`;\n\n  return `${minutes}:${secondsFormatted}`;\n};\n"
  },
  {
    "path": "components/conversations/index.tsx",
    "content": "\"use client\";\n\nimport { ConversationListItem as ConversationListItemEE } from \"@/ee/features/conversations/components/dashboard/conversation-list-item\";\nimport { ConversationMessage as ConversationMessageEE } from \"@/ee/features/conversations/components/shared/conversation-message\";\n\nexport function ConversationListItem(props: any) {\n  return <ConversationListItemEE {...props} />;\n}\n\nexport function ConversationMessage(props: any) {\n  return <ConversationMessageEE {...props} />;\n}\n"
  },
  {
    "path": "components/datarooms/actions/download-dataroom.tsx",
    "content": "import { useState } from \"react\";\n\nimport { DownloadIcon } from \"lucide-react\";\n\nimport { DownloadProgressModal } from \"@/components/datarooms/download-progress-modal\";\nimport { ResponsiveButton } from \"@/components/ui/responsive-button\";\n\nexport default function DownloadDataroomButton({\n  teamId,\n  dataroomId,\n  dataroomName,\n}: {\n  teamId: string;\n  dataroomId: string;\n  dataroomName?: string;\n}) {\n  const [showDownloadModal, setShowDownloadModal] = useState(false);\n\n  const openDownloadModal = () => {\n    // Open the modal - it will show existing downloads and allow starting new ones\n    setShowDownloadModal(true);\n  };\n\n  const handleCloseDownloadModal = () => {\n    setShowDownloadModal(false);\n  };\n\n  return (\n    <>\n      <ResponsiveButton\n        icon={<DownloadIcon className=\"h-4 w-4\" />}\n        text=\"Download\"\n        onClick={openDownloadModal}\n        variant=\"outline\"\n        size=\"sm\"\n      />\n\n      {/* Download Progress Modal */}\n      <DownloadProgressModal\n        isOpen={showDownloadModal}\n        onClose={handleCloseDownloadModal}\n        jobId={null}\n        dataroomName={dataroomName}\n        teamId={teamId}\n        dataroomId={dataroomId}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/actions/generate-index-button.tsx",
    "content": "import GenerateIndexDialog from \"./generate-index-dialog\";\n\ninterface GenerateIndexButtonProps {\n  teamId: string;\n  dataroomId: string;\n  disabled?: boolean;\n}\n\nexport default function GenerateIndexButton({\n  teamId,\n  dataroomId,\n  disabled = false,\n}: GenerateIndexButtonProps) {\n  return (\n    <GenerateIndexDialog\n      teamId={teamId}\n      dataroomId={dataroomId}\n      disabled={disabled}\n    />\n  );\n}\n"
  },
  {
    "path": "components/datarooms/actions/generate-index-dialog.tsx",
    "content": "import { useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  FileJson,\n  FileSlidersIcon,\n  FileSpreadsheet,\n  FileText,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDataroomLinks } from \"@/lib/swr/use-dataroom\";\nimport { IndexFileFormat } from \"@/lib/types/index-file\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { ResponsiveButton } from \"@/components/ui/responsive-button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface GenerateIndexDialogProps {\n  teamId: string;\n  dataroomId: string;\n  disabled?: boolean;\n}\n\nexport default function GenerateIndexDialog({\n  teamId,\n  dataroomId,\n  disabled = false,\n}: GenerateIndexDialogProps) {\n  const { links } = useDataroomLinks();\n  const { isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n  const analytics = useAnalytics();\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [selectedLinkId, setSelectedLinkId] = useState<string>(\"\");\n  const [selectedFormat, setSelectedFormat] =\n    useState<IndexFileFormat>(\"excel\");\n  const [isOpen, setIsOpen] = useState(false);\n\n  const hasDataroomsPlan = isDatarooms || isDataroomsPlus || isTrial;\n\n  const handleGenerateIndex = async () => {\n    if (!hasDataroomsPlan) {\n      toast.error(\"Upgrade to a Data Rooms plan to generate index files.\");\n      return;\n    }\n\n    if (!selectedLinkId) {\n      toast.error(\"Please select a link first\");\n      return;\n    }\n\n    try {\n      setIsLoading(true);\n\n      const response = await fetch(\n        `/api/teams/${teamId}/datarooms/${dataroomId}/generate-index`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            format: selectedFormat,\n            linkId: selectedLinkId,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        throw new Error(errorData.error || \"Failed to generate index\");\n      }\n\n      // Get filename from Content-Disposition header\n      const contentDisposition = response.headers.get(\"Content-Disposition\");\n      const filename = contentDisposition?.split(\"filename=\")[1] || \"index\";\n\n      // Create a blob from the response\n      const blob = await response.blob();\n\n      // Create a download link and trigger it\n      const url = window.URL.createObjectURL(blob);\n      const link = document.createElement(\"a\");\n      link.href = url;\n      link.download = filename;\n      link.rel = \"noopener noreferrer\";\n      document.body.appendChild(link);\n      link.click();\n\n      setTimeout(() => {\n        window.URL.revokeObjectURL(url);\n        document.body.removeChild(link);\n      }, 100);\n\n      analytics.capture(\"Generate Index File\", {\n        teamId,\n        dataroomId,\n        linkId: selectedLinkId,\n        format: selectedFormat,\n      });\n\n      toast.success(\"Index file generated successfully\");\n      setIsOpen(false);\n    } catch (error) {\n      console.error(\"Error generating index:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to generate index\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const activeLinks = links?.filter((link) => !link.isArchived) || [];\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <ResponsiveButton\n          icon={<FileSlidersIcon className=\"h-4 w-4\" />}\n          text=\"Generate Index\"\n          variant=\"outline\"\n          size=\"sm\"\n          disabled={disabled}\n        />\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>Generate Dataroom Index File</DialogTitle>\n          <DialogDescription>\n            {hasDataroomsPlan\n              ? \"Select a link and format to generate the index file.\"\n              : \"Upgrade to a Data Rooms plan to generate index files.\"}\n          </DialogDescription>\n        </DialogHeader>\n        <div className=\"grid gap-4 py-4\">\n          <div className=\"space-y-2\">\n            <h4 className=\"text-sm font-medium\">Select Link</h4>\n            <Select\n              value={selectedLinkId}\n              onValueChange={(value) => setSelectedLinkId(value)}\n            >\n              <SelectTrigger className=\"text-left\">\n                <SelectValue placeholder=\"Select a link\" />\n              </SelectTrigger>\n              <SelectContent className=\"max-h-[50vh] overflow-y-scroll\">\n                {activeLinks.map((link) => (\n                  <SelectItem key={link.id} value={link.id}>\n                    <div className=\"flex flex-col space-y-1\">\n                      <span>{link.name || `Link #${link.id.slice(-5)}`}</span>\n                      <span className=\"text-xs text-muted-foreground\">\n                        {link.domainId\n                          ? `${link.domainSlug}/${link.slug}`\n                          : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`}\n                      </span>\n                    </div>\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n          <Separator />\n          <div className=\"space-y-2\">\n            <h4 className=\"text-sm font-medium\">Select Format</h4>\n            <div className=\"grid grid-cols-2 gap-2\">\n              <Button\n                variant={selectedFormat === \"excel\" ? \"default\" : \"outline\"}\n                onClick={() => {\n                  setSelectedFormat(\"excel\");\n                }}\n                className=\"justify-start\"\n                size=\"sm\"\n              >\n                <FileSpreadsheet className=\"mr-2 h-4 w-4\" />\n                Excel\n              </Button>\n              <Button\n                variant={selectedFormat === \"csv\" ? \"default\" : \"outline\"}\n                onClick={() => setSelectedFormat(\"csv\")}\n                className=\"justify-start\"\n                size=\"sm\"\n              >\n                <FileText className=\"mr-2 h-4 w-4\" />\n                CSV\n              </Button>\n              <Button\n                variant={selectedFormat === \"json\" ? \"default\" : \"outline\"}\n                onClick={() => setSelectedFormat(\"json\")}\n                className=\"justify-start\"\n                size=\"sm\"\n              >\n                <FileJson className=\"mr-2 h-4 w-4\" />\n                JSON\n              </Button>\n            </div>\n          </div>\n        </div>\n        <DialogFooter>\n          {hasDataroomsPlan ? (\n            <Button\n              onClick={handleGenerateIndex}\n              disabled={!selectedLinkId || isLoading}\n            >\n              {isLoading ? \"Generating...\" : \"Generate\"}\n            </Button>\n          ) : (\n            <UpgradePlanModal\n              clickedPlan={PlanEnum.DataRooms}\n              trigger=\"datarooms_generate_index_button\"\n            >\n              <Button>Upgrade to generate</Button>\n            </UpgradePlanModal>\n          )}\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/actions/rebuild-index-button.tsx",
    "content": "import { useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { ListOrderedIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { ResponsiveButton } from \"@/components/ui/responsive-button\";\n\ninterface RebuildIndexButtonProps {\n  teamId: string;\n  dataroomId: string;\n  disabled?: boolean;\n}\n\nexport default function RebuildIndexButton({\n  teamId,\n  dataroomId,\n  disabled = false,\n}: RebuildIndexButtonProps) {\n  const { isFeatureEnabled } = useFeatureFlags();\n  const { isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n  const [isLoading, setIsLoading] = useState(false);\n  const [isOpen, setIsOpen] = useState(false);\n\n  const isDataroomIndexEnabled = isFeatureEnabled(\"dataroomIndex\");\n  const hasDataroomsPlan = isDatarooms || isDataroomsPlus || isTrial;\n  const hasDataroomsPlusPlan = isDataroomsPlus;\n\n  // Show button if: feature flag is enabled OR user has datarooms plan or higher\n  const shouldShowButton = isDataroomIndexEnabled || hasDataroomsPlan;\n\n  // Allow usage if: feature flag is enabled OR user has datarooms-plus plan\n  const canUseFeature = isDataroomIndexEnabled || hasDataroomsPlusPlan;\n\n  // Don't render if conditions aren't met\n  if (!shouldShowButton) {\n    return null;\n  }\n\n  const handleRebuildIndex = async () => {\n    if (!canUseFeature) {\n      toast.error(\"Upgrade to Data Rooms Plus plan to use this feature.\");\n      return;\n    }\n\n    try {\n      setIsLoading(true);\n\n      const response = await fetch(\n        `/api/teams/${teamId}/datarooms/${dataroomId}/calculate-indexes`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        throw new Error(errorData.message || \"Failed to rebuild indexes\");\n      }\n\n      const result = await response.json();\n\n      toast.success(\n        `Hierarchical indexes rebuilt successfully! Updated ${result.totalUpdated} items (${result.foldersUpdated} folders, ${result.documentsUpdated} documents).`,\n      );\n\n      setIsOpen(false);\n\n      // Trigger a page refresh to show updated indexes\n      window.location.reload();\n    } catch (error) {\n      console.error(\"Error rebuilding indexes:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to rebuild indexes\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <ResponsiveButton\n          icon={<ListOrderedIcon className=\"h-4 w-4\" />}\n          text=\"Rebuild Index\"\n          variant=\"outline\"\n          size=\"sm\"\n          disabled={disabled}\n        />\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center\">\n            Rebuild Hierarchical Index\n          </DialogTitle>\n          <DialogDescription>\n            Recalculate the hierarchical numbering based on the dataroom\n            items&apos; current order.\n          </DialogDescription>\n        </DialogHeader>\n        <div className=\"py-4\">\n          <div className=\"rounded-lg bg-muted p-4\">\n            <div className=\"flex items-start gap-3\">\n              <div className=\"text-sm text-muted-foreground\">\n                <p className=\"mb-1 font-medium\">What this does:</p>\n                <ul className=\"list-inside list-disc space-y-1\">\n                  <li>\n                    Analyzes the current folder structure and document order\n                  </li>\n                  <li>\n                    Assigns hierarchical numbers (1, 1.1, 1.1.1, 2, 2.1, etc.)\n                  </li>\n                  <li>\n                    Updates the display to show these numbers alongside names\n                  </li>\n                  <li>Maintains the existing order and hierarchy</li>\n                </ul>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <DialogFooter>\n          {canUseFeature ? (\n            <>\n              <Button variant=\"outline\" onClick={() => setIsOpen(false)}>\n                Cancel\n              </Button>\n              <Button onClick={handleRebuildIndex} loading={isLoading}>\n                <ListOrderedIcon className=\"h-4 w-4\" />\n                Rebuild Index\n              </Button>\n            </>\n          ) : (\n            <UpgradePlanModal\n              clickedPlan={PlanEnum.DataRoomsPlus}\n              trigger=\"datarooms_rebuild_index_button\"\n              highlightItem={[\"indexing\"]}\n            >\n              <Button>Upgrade to rebuild index</Button>\n            </UpgradePlanModal>\n          )}\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/actions/remove-document-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\nimport { Modal } from \"@/components/ui/modal\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nfunction RemoveDataroomItemsModal({\n  showRemoveDataroomItemsModal,\n  setShowRemoveDataroomItemModal,\n  documentIds,\n  dataroomId,\n  setSelectedDocuments,\n  folderIds,\n  setSelectedFolders,\n}: {\n  showRemoveDataroomItemsModal: boolean;\n  setShowRemoveDataroomItemModal: Dispatch<SetStateAction<boolean>>;\n  documentIds: string[];\n  dataroomId: string;\n  setSelectedDocuments: Dispatch<SetStateAction<string[]>>;\n  folderIds: string[];\n  setSelectedFolders: Dispatch<SetStateAction<string[]>>;\n}) {\n  const router = useRouter();\n  const folderPathName = router.query.name as string[] | undefined;\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n  const parentFolderPath = folderPathName\n    ?.join(\"/\")\n    ?.substring(0, folderPathName?.lastIndexOf(\"/\"));\n\n  const [deleting, setDeleting] = useState(false);\n\n  async function deleteDocumentsAndFolders(\n    documentIds: string[],\n    folderIds: string[],\n  ) {\n    return new Promise(async (resolve, reject) => {\n      setDeleting(true);\n\n      try {\n        const deleteDocumentPromises = documentIds.map((documentId) =>\n          fetch(\n            `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents/${documentId}`,\n            { method: \"DELETE\" },\n          ).then(async (res) => {\n            if (!res.ok) {\n              const error = await res.json();\n              throw new Error(error.message || \"Failed to remove dataroom document\");\n            }\n            analytics.capture(\"Dataroom Document Removed\", {\n              team: teamInfo?.currentTeam?.id,\n              documentId,\n            });\n            return documentId; // Return the ID of the successfully removed document\n          }),\n        );\n        const deleteFolderPromises = folderIds.map((folderId) =>\n          fetch(\n            `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders/manage/${folderId}`,\n            { method: \"DELETE\" },\n          ).then(async (res) => {\n            if (!res.ok) {\n              const error = await res.json();\n              throw new Error(error.message || \"Failed to remove dataroom folder\");\n            }\n            analytics.capture(\"Dataroom folder Removed\", {\n              team: teamInfo?.currentTeam?.id,\n              folderId,\n            });\n            return folderId; // Return the ID of the successfully removed folder\n          }),\n        );\n\n        const results = await Promise.allSettled([\n          ...deleteDocumentPromises,\n          ...deleteFolderPromises,\n        ]);\n\n        const successfullyDeletedItems = results\n          .filter((result) => result.status === \"fulfilled\")\n          .map((result) => (result as PromiseFulfilledResult<string>).value);\n\n        const errors = results\n          .filter((result) => result.status === \"rejected\")\n          .map((result) => (result as PromiseRejectedResult).reason);\n\n        // Call mutate only once, after all deletions\n        await mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}${folderPathName ? `/folders/documents/${folderPathName.join(\"/\")}` : \"/documents\"}`,\n        );\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders${folderPathName ? `/${folderPathName.join(\" / \")}` : \"?root=true\"}`,\n        );\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders`,\n        );\n        setDeleting(false);\n        // Deselect only the successfully deleted documents\n        setSelectedDocuments((prevSelected) =>\n          prevSelected.filter((id) => !successfullyDeletedItems.includes(id)),\n        );\n        setSelectedFolders((prevSelected) =>\n          prevSelected.filter((id) => !successfullyDeletedItems.includes(id)),\n        );\n        if (errors.length) {\n          reject(errors);\n        } else {\n          resolve(null);\n        }\n      } catch (error) {\n        setDeleting(false);\n        reject((error as Error).message);\n      } finally {\n        setShowRemoveDataroomItemModal(false);\n      }\n    });\n  }\n\n  return (\n    <Modal\n      showModal={showRemoveDataroomItemsModal}\n      setShowModal={setShowRemoveDataroomItemModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <DialogTitle className=\"text-2xl\">\n          Remove {documentIds.length + folderIds.length} item\n          {documentIds.length + folderIds.length > 1 && \"s\"}\n        </DialogTitle>\n        <DialogDescription className=\"space-y-2\">\n          {documentIds.length > 0 && (\n            <p>\n              <strong>Documents Info</strong>: Existing views will not be\n              affected. You can always add removed documents back to the\n              dataroom.\n            </p>\n          )}\n          {folderIds.length > 0 && (\n            <p>\n              <strong>Folders Info</strong>: This will remove the folder and its\n              contents from this dataroom. The original documents will remain in\n              your workspace.\n            </p>\n          )}\n        </DialogDescription>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          const title = `${documentIds.length > 0 ? `${documentIds.length} document${documentIds.length > 1 ? \"s\" : \"\"}` : \"\"}${\n            documentIds.length > 0 && folderIds.length > 0 ? \" and \" : \"\"\n          }${folderIds.length > 0 ? `${folderIds.length} folder${folderIds.length > 1 ? \"s\" : \"\"}` : \"\"}`;\n          toast.promise(deleteDocumentsAndFolders(documentIds, folderIds), {\n            loading: `Deleting ${title}...`,\n            success: `${title} deleted successfully!`,\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <Button variant=\"destructive\" loading={deleting}>\n          Confirm Remove\n          {documentIds.length > 0 &&\n            ` ${documentIds.length} document${documentIds.length > 1 ? \"s\" : \"\"}`}\n          {documentIds.length > 0 && folderIds.length > 0 && \" and\"}\n          {folderIds.length > 0 &&\n            ` ${folderIds.length} folder${folderIds.length > 1 ? \"s\" : \"\"}`}\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useRemoveDataroomItemsModal({\n  documentIds,\n  dataroomId,\n  setSelectedDocuments,\n  folderIds,\n  setSelectedFolders,\n}: {\n  setSelectedFolders: Dispatch<SetStateAction<string[]>>;\n  folderIds: string[];\n  documentIds: string[];\n  dataroomId: string;\n  setSelectedDocuments: Dispatch<SetStateAction<string[]>>;\n}) {\n  const [showRemoveDataroomItemsModal, setShowRemoveDataroomItemModal] =\n    useState(false);\n\n  const RemoveDataroomItemModal = useCallback(() => {\n    return (\n      <RemoveDataroomItemsModal\n        showRemoveDataroomItemsModal={showRemoveDataroomItemsModal}\n        setShowRemoveDataroomItemModal={setShowRemoveDataroomItemModal}\n        documentIds={documentIds}\n        dataroomId={dataroomId}\n        setSelectedDocuments={setSelectedDocuments}\n        folderIds={folderIds}\n        setSelectedFolders={setSelectedFolders}\n      />\n    );\n  }, [\n    showRemoveDataroomItemsModal,\n    setShowRemoveDataroomItemModal,\n    documentIds,\n    dataroomId,\n    setSelectedDocuments,\n    folderIds,\n    setSelectedFolders,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowRemoveDataroomItemModal,\n      RemoveDataroomItemModal: RemoveDataroomItemModal,\n    }),\n    [setShowRemoveDataroomItemModal, RemoveDataroomItemModal],\n  );\n}\n"
  },
  {
    "path": "components/datarooms/add-dataroom-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  Brain,\n  BriefcaseIcon,\n  BuildingIcon,\n  FileTextIcon,\n  FolderKanbanIcon,\n  FolderIcon,\n  HomeIcon,\n  LineChartIcon,\n  RocketIcon,\n  ShoppingCartIcon,\n  Sparkles,\n  TrendingUpIcon,\n  XIcon,\n  Zap,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\n\nexport function AddDataroomModal({\n  children,\n  openModal = false,\n  setOpenModal,\n}: {\n  children?: React.ReactNode;\n  openModal?: boolean;\n  setOpenModal?: React.Dispatch<React.SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const [dataroomName, setDataroomName] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n  const [open, setOpen] = useState<boolean>(openModal);\n  const [activeTab, setActiveTab] = useState<string>(\"create\");\n  const [dataroomType, setDataroomType] = useState<string>(\"\");\n  const [aiDescription, setAiDescription] = useState<string>(\"\");\n  const [aiGenerating, setAiGenerating] = useState<boolean>(false);\n  const [generatedFolders, setGeneratedFolders] = useState<any[] | null>(null);\n  const [showPreview, setShowPreview] = useState<boolean>(false);\n  const [selectedFolderPaths, setSelectedFolderPaths] = useState<Set<string>>(\n    new Set(),\n  );\n  const [editingFolderPath, setEditingFolderPath] = useState<string | null>(null);\n  const [editingFolderName, setEditingFolderName] = useState<string>(\"\");\n\n  const teamInfo = useTeam();\n  const { isFree, isPro, isTrial } = usePlan();\n  const analytics = useAnalytics();\n\n  const useTemplate = activeTab === \"generate\";\n  const useAI = activeTab === \"ai\";\n\n  const dataroomSchema = z.object({\n    name: z.string().trim().min(3, {\n      message: \"Please provide a dataroom name with at least 3 characters.\",\n    }),\n    type: z.string().optional(),\n  });\n\n  const dataroomSchemaWithType = z.object({\n    type: z.enum(\n      [\n        \"startup-fundraising\",\n        \"raising-first-fund\",\n        \"ma-acquisition\",\n        \"series-a-plus\",\n        \"real-estate-transaction\",\n        \"fund-management\",\n        \"portfolio-management\",\n        \"project-management\",\n        \"sales-dataroom\",\n      ],\n      {\n        errorMap: () => ({ message: \"Please select a dataroom type.\" }),\n      },\n    ),\n  });\n\n  const TEMPLATES = [\n    {\n      id: \"startup-fundraising\",\n      name: \"Startup Fundraising\",\n      icon: RocketIcon,\n    },\n    {\n      id: \"series-a-plus\",\n      name: \"Series A+\",\n      icon: TrendingUpIcon,\n    },\n    {\n      id: \"raising-first-fund\",\n      name: \"Raising a Fund\",\n      icon: LineChartIcon,\n    },\n    {\n      id: \"ma-acquisition\",\n      name: \"M&A / Acquisition\",\n      icon: BriefcaseIcon,\n    },\n    {\n      id: \"sales-dataroom\",\n      name: \"Sales Data Room\",\n      icon: ShoppingCartIcon,\n    },\n    {\n      id: \"real-estate-transaction\",\n      name: \"Real Estate\",\n      icon: HomeIcon,\n    },\n    {\n      id: \"fund-management\",\n      name: \"Fundraising & Reporting\",\n      icon: BuildingIcon,\n    },\n    {\n      id: \"portfolio-management\",\n      name: \"Portfolio Management\",\n      icon: FolderKanbanIcon,\n    },\n    {\n      id: \"project-management\",\n      name: \"Project Management\",\n      icon: FileTextIcon,\n    },\n  ];\n\n  const handleGenerateFolders = async () => {\n    if (!aiDescription.trim()) {\n      return toast.error(\"Please describe what kind of dataroom you want to create.\");\n    }\n\n    setAiGenerating(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai-structure`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            description: aiDescription.trim(),\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setAiGenerating(false);\n        toast.error(message || \"Failed to generate folder structure\");\n        return;\n      }\n\n      const { name, folders } = await response.json();\n      setGeneratedFolders(folders);\n      // Set the generated name as default, but allow user to edit it\n      if (name) {\n        setDataroomName(name);\n      }\n      // Initialize all folders as selected\n      initializeFolderSelection(folders);\n      setShowPreview(true);\n      setAiGenerating(false);\n    } catch (error) {\n      setAiGenerating(false);\n      toast.error(\"Error generating folder structure. Please try again.\");\n    }\n  };\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    // For AI generation, if we're in preview mode, create the dataroom\n    if (useAI && showPreview && generatedFolders) {\n      // Validate dataroom name\n      if (!dataroomName.trim()) {\n        return toast.error(\"Please provide a dataroom name.\");\n      }\n\n      if (dataroomName.trim().length < 3) {\n        return toast.error(\n          \"Please provide a dataroom name with at least 3 characters.\",\n        );\n      }\n\n      // Filter folders based on selection\n      const filteredFolders = filterSelectedFolders(generatedFolders);\n      \n      if (filteredFolders.length === 0) {\n        return toast.error(\"Please select at least one folder to include.\");\n      }\n\n      setLoading(true);\n      try {\n        const response = await fetch(\n          `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              name: dataroomName.trim(),\n              folders: filteredFolders,\n            }),\n          },\n        );\n\n        if (!response.ok) {\n          const { message } = await response.json();\n          setLoading(false);\n          toast.error(message);\n          return;\n        }\n\n        const { dataroom } = await response.json();\n\n        analytics.capture(\"Dataroom Generated with AI\", {\n          dataroomName: dataroomName.trim(),\n        });\n\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);\n        toast.success(\"Dataroom successfully generated! 🎉\");\n        router.push(`/datarooms/${dataroom.id}/documents`);\n      } catch (error) {\n        setLoading(false);\n        toast.error(\"Error creating dataroom. Please try again.\");\n        return;\n      } finally {\n        setLoading(false);\n        setOpen(false);\n        if (openModal && setOpenModal) setOpenModal(false);\n      }\n      return;\n    }\n\n    // Validate based on whether template is enabled\n    const schema = useTemplate ? dataroomSchemaWithType : dataroomSchema;\n    const validation = schema.safeParse({\n      name: useTemplate ? undefined : dataroomName,\n      type: useTemplate ? dataroomType : undefined,\n    });\n\n    if (!validation.success) {\n      return toast.error(validation.error.errors[0].message);\n    }\n\n    setLoading(true);\n\n    try {\n      // Use different endpoint based on whether template is enabled\n      const endpoint = useTemplate\n        ? `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate`\n        : `/api/teams/${teamInfo?.currentTeam?.id}/datarooms`;\n\n      const body = useTemplate\n        ? {\n            type: dataroomType,\n            // Name will be taken from template\n          }\n        : { name: dataroomName.trim() };\n\n      const response = await fetch(endpoint, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(body),\n      });\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message);\n        return;\n      }\n\n      const { dataroom } = await response.json();\n\n      analytics.capture(\n        useTemplate ? \"Dataroom Generated\" : \"Dataroom Created\",\n        {\n          dataroomName: dataroomName,\n          ...(useTemplate && { dataroomType: dataroomType }),\n        },\n      );\n\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);\n      toast.success(\n        useTemplate\n          ? \"Dataroom successfully created! 🎉\"\n          : \"Dataroom successfully created! 🎉\",\n      );\n      router.push(`/datarooms/${dataroom.id}/documents`);\n    } catch (error) {\n      setLoading(false);\n      toast.error(\"Error adding dataroom. Please try again.\");\n      return;\n    } finally {\n      setLoading(false);\n      setOpen(false);\n      if (openModal && setOpenModal) setOpenModal(false);\n    }\n  };\n\n  if ((isFree || isPro) && !isTrial) {\n    if (children) {\n      return (\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.DataRooms}\n          trigger={\"add_dataroom_overview\"}\n        >\n          {children}\n        </UpgradePlanModal>\n      );\n    }\n  }\n\n  const onOpenChange = (open: boolean) => {\n    if (!open) {\n      setOpen(false);\n      setDataroomName(\"\");\n      setActiveTab(\"create\");\n      setDataroomType(\"\");\n      setAiDescription(\"\");\n      setGeneratedFolders(null);\n      setShowPreview(false);\n      setSelectedFolderPaths(new Set());\n      setEditingFolderPath(null);\n      setEditingFolderName(\"\");\n    } else {\n      setOpen(true);\n    }\n    if (openModal && setOpenModal) setOpenModal(false);\n  };\n\n  // Generate a unique path for each folder\n  const generateFolderPath = (folder: any, parentPath: string = \"\"): string => {\n    const currentPath = parentPath ? `${parentPath}/${folder.name}` : folder.name;\n    return currentPath;\n  };\n\n  // Update folder name in the folder structure\n  const updateFolderName = (\n    folders: any[],\n    targetPath: string,\n    newName: string,\n    currentPath: string = \"\",\n  ): any[] => {\n    return folders.map((folder) => {\n      const folderPath = generateFolderPath(folder, currentPath);\n      \n      if (folderPath === targetPath) {\n        // Update this folder's name\n        return { ...folder, name: newName };\n      }\n      \n      // Recursively update subfolders\n      if (folder.subfolders && folder.subfolders.length > 0) {\n        return {\n          ...folder,\n          subfolders: updateFolderName(\n            folder.subfolders,\n            targetPath,\n            newName,\n            folderPath,\n          ),\n        };\n      }\n      \n      return folder;\n    });\n  };\n\n  // Handle folder name edit\n  const handleFolderNameEdit = (path: string, currentName: string) => {\n    setEditingFolderPath(path);\n    setEditingFolderName(currentName);\n  };\n\n  // Save edited folder name\n  const saveFolderName = () => {\n    if (!editingFolderPath || !generatedFolders) return;\n    \n    if (editingFolderName.trim()) {\n      const updatedFolders = updateFolderName(\n        generatedFolders,\n        editingFolderPath,\n        editingFolderName.trim(),\n      );\n      setGeneratedFolders(updatedFolders);\n      \n      // Update selected paths if needed (recalculate paths)\n      initializeFolderSelection(updatedFolders);\n    }\n    \n    setEditingFolderPath(null);\n    setEditingFolderName(\"\");\n  };\n\n  // Cancel editing\n  const cancelFolderNameEdit = () => {\n    setEditingFolderPath(null);\n    setEditingFolderName(\"\");\n  };\n\n  // Toggle folder selection\n  const toggleFolderSelection = (path: string, folder: any) => {\n    const newSelected = new Set(selectedFolderPaths);\n    \n    if (newSelected.has(path)) {\n      // If unchecking, remove this folder and all its subfolders\n      newSelected.delete(path);\n      const removeSubfolders = (f: any, currentPath: string) => {\n        if (f.subfolders) {\n          f.subfolders.forEach((sub: any) => {\n            const subPath = generateFolderPath(sub, currentPath);\n            newSelected.delete(subPath);\n            removeSubfolders(sub, subPath);\n          });\n        }\n      };\n      removeSubfolders(folder, path);\n    } else {\n      // If checking, add this folder\n      newSelected.add(path);\n    }\n    \n    setSelectedFolderPaths(newSelected);\n  };\n\n  // Filter folders based on selection\n  const filterSelectedFolders = (folders: any[], parentPath: string = \"\"): any[] => {\n    return folders\n      .map((folder) => {\n        const currentPath = generateFolderPath(folder, parentPath);\n        const isSelected = selectedFolderPaths.has(currentPath);\n        \n        if (!isSelected) {\n          return null;\n        }\n        \n        const filteredSubfolders = folder.subfolders\n          ? filterSelectedFolders(folder.subfolders, currentPath)\n          : undefined;\n        \n        return {\n          ...folder,\n          subfolders: filteredSubfolders && filteredSubfolders.length > 0 \n            ? filteredSubfolders \n            : undefined,\n        };\n      })\n      .filter((folder) => folder !== null);\n  };\n\n  // Initialize all folders as selected when preview is shown\n  const initializeFolderSelection = (folders: any[], parentPath: string = \"\") => {\n    const paths = new Set<string>();\n    const collectPaths = (f: any[], currentPath: string = \"\") => {\n      f.forEach((folder) => {\n        const folderPath = generateFolderPath(folder, currentPath);\n        paths.add(folderPath);\n        if (folder.subfolders) {\n          collectPaths(folder.subfolders, folderPath);\n        }\n      });\n    };\n    collectPaths(folders);\n    setSelectedFolderPaths(paths);\n  };\n\n  const renderFolderPreview = (\n    folders: any[],\n    indent = 0,\n    parentPath: string = \"\",\n  ) => {\n    return (\n      <div className=\"space-y-1\">\n        {folders.map((folder, index) => {\n          const currentPath = generateFolderPath(folder, parentPath);\n          const isSelected = selectedFolderPaths.has(currentPath);\n          \n          return (\n            <div key={`${parentPath}-${index}`} className=\"pl-4\">\n              <div className=\"flex items-center gap-2 py-1\">\n                <div\n                  className=\"h-4 w-4\"\n                  style={{ marginLeft: `${indent * 16}px` }}\n                />\n                <Checkbox\n                  checked={isSelected}\n                  onCheckedChange={() => toggleFolderSelection(currentPath, folder)}\n                  id={`folder-${currentPath}`}\n                />\n                <label\n                  htmlFor={`folder-${currentPath}`}\n                  className=\"flex items-center gap-2 cursor-pointer flex-1\"\n                >\n                  <FolderIcon className=\"h-4 w-4 text-gray-500\" />\n                  {editingFolderPath === currentPath ? (\n                    <Input\n                      value={editingFolderName}\n                      onChange={(e) => setEditingFolderName(e.target.value)}\n                      onBlur={saveFolderName}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") {\n                          e.preventDefault();\n                          saveFolderName();\n                        } else if (e.key === \"Escape\") {\n                          e.preventDefault();\n                          cancelFolderNameEdit();\n                        }\n                      }}\n                      className=\"h-6 text-sm px-2 py-1\"\n                      autoFocus\n                      onClick={(e) => e.stopPropagation()}\n                    />\n                  ) : (\n                    <span\n                      className=\"text-sm hover:bg-gray-100 dark:hover:bg-gray-800 px-1 py-0.5 rounded cursor-text\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        handleFolderNameEdit(currentPath, folder.name);\n                      }}\n                      title=\"Click to edit folder name\"\n                    >\n                      {folder.name}\n                    </span>\n                  )}\n                </label>\n              </div>\n              {folder.subfolders && folder.subfolders.length > 0 && (\n                <div className=\"ml-4\">\n                  {renderFolderPreview(folder.subfolders, indent + 1, currentPath)}\n                </div>\n              )}\n            </div>\n          );\n        })}\n      </div>\n    );\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"border-none bg-transparent text-foreground shadow-none sm:max-w-[575px] [&>button]:hidden\">\n        <DialogTitle className=\"sr-only\">Create Dataroom</DialogTitle>\n        <DialogDescription className=\"sr-only\">\n          Create a new dataroom or generate one with pre-configured folders\n        </DialogDescription>\n\n        <Tabs\n          value={activeTab}\n          onValueChange={(value) => {\n            setActiveTab(value);\n            // Reset AI-related state when switching tabs\n            if (value !== \"ai\") {\n              setAiDescription(\"\");\n              setGeneratedFolders(null);\n              setShowPreview(false);\n            }\n          }}\n        >\n          <TabsList className=\"grid w-full grid-cols-3\">\n            <TabsTrigger value=\"create\">Create from scratch</TabsTrigger>\n            <TabsTrigger value=\"generate\">Create from template</TabsTrigger>\n            <TabsTrigger\n              value=\"ai\"\n              className={`flex items-center gap-2 ${\n                activeTab === \"ai\"\n                  ? \"text-orange-500 data-[state=active]:text-orange-500\"\n                  : \"\"\n              }`}\n            >\n              <Sparkles\n                className={`h-4 w-4 ${\n                  activeTab === \"ai\" ? \"text-orange-500\" : \"text-current\"\n                }`}\n              />\n              Generate with AI\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"create\">\n            <Card className=\"relative outline-none focus:outline-none\">\n              <button\n                onClick={() => onOpenChange(false)}\n                className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n              >\n                <XIcon className=\"h-4 w-4\" />\n                <span className=\"sr-only\">Close</span>\n              </button>\n              <CardHeader className=\"space-y-3\">\n                <CardTitle>Create dataroom</CardTitle>\n                <CardDescription>\n                  Start creating a dataroom with a name.\n                </CardDescription>\n              </CardHeader>\n              <CardContent className=\"space-y-2\">\n                <form\n                  onSubmit={handleSubmit}\n                  className=\"flex flex-col space-y-4 outline-none\"\n                >\n                  <div className=\"space-y-1\">\n                    <Label htmlFor=\"dataroom-name-create\">\n                      Dataroom Name{\" \"}\n                      <span className=\"text-black dark:text-white\">*</span>\n                    </Label>\n                    <Input\n                      id=\"dataroom-name-create\"\n                      placeholder=\"ACME Acquisition\"\n                      value={dataroomName}\n                      onChange={(e) => setDataroomName(e.target.value)}\n                    />\n                  </div>\n                  <Button type=\"submit\" className=\"w-full\" loading={loading}>\n                    Add new dataroom\n                  </Button>\n                </form>\n              </CardContent>\n            </Card>\n          </TabsContent>\n\n          <TabsContent value=\"generate\">\n            <Card className=\"relative outline-none focus:outline-none\">\n              <button\n                onClick={() => onOpenChange(false)}\n                className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n              >\n                <XIcon className=\"h-4 w-4\" />\n                <span className=\"sr-only\">Close</span>\n              </button>\n              <CardHeader className=\"space-y-3\">\n                <CardTitle>Create dataroom</CardTitle>\n                <CardDescription>\n                  Create a dataroom with pre-configured folder structure.\n                </CardDescription>\n              </CardHeader>\n              <CardContent className=\"space-y-4\">\n                <form\n                  onSubmit={handleSubmit}\n                  className=\"flex flex-col space-y-4 outline-none\"\n                >\n                  <div className=\"space-y-2\">\n                    <Label>\n                      Select Template{\" \"}\n                      <span className=\"text-black dark:text-white\">*</span>\n                    </Label>\n                    <div className=\"grid grid-cols-3 divide-x divide-y divide-border overflow-hidden rounded-md border \">\n                      {TEMPLATES.map((template) => {\n                        const Icon = template.icon;\n                        const isSelected = dataroomType === template.id;\n\n                        return (\n                          <button\n                            key={template.id}\n                            type=\"button\"\n                            onClick={() => setDataroomType(template.id)}\n                            className={`relative flex min-h-[120px] flex-col items-center justify-center space-y-3 overflow-hidden p-4 transition-colors ${\n                              isSelected\n                                ? \"bg-gray-200 dark:bg-gray-800\"\n                                : \"hover:bg-gray-100 hover:dark:bg-gray-800\"\n                            }`}\n                          >\n                            <Icon className=\"pointer-events-none h-auto w-10 text-foreground\" />\n                            <p className=\"text-sm\">{template.name}</p>\n                          </button>\n                        );\n                      })}\n                    </div>\n                  </div>\n                  <Button\n                    type=\"submit\"\n                    className=\"w-full\"\n                    loading={loading}\n                    disabled={!dataroomType}\n                  >\n                    Create new dataroom\n                  </Button>\n                </form>\n              </CardContent>\n            </Card>\n          </TabsContent>\n\n          <TabsContent value=\"ai\">\n            <Card className=\"relative outline-none focus:outline-none\">\n              <button\n                onClick={() => onOpenChange(false)}\n                className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\"\n              >\n                <XIcon className=\"h-4 w-4\" />\n                <span className=\"sr-only\">Close</span>\n              </button>\n              <CardHeader className=\"space-y-3\">\n                <CardTitle>Generate dataroom with AI</CardTitle>\n                <CardDescription>\n                  AI will create a unique and proven data room structure for your use case.\n                </CardDescription>\n              </CardHeader>\n              <CardContent className=\"space-y-4\">\n                {!showPreview ? (\n                  <>\n                    <div className=\"space-y-1\">\n                      <Label htmlFor=\"ai-description\">\n                        Describe your dataroom in details{\" \"}\n                        <span className=\"text-black dark:text-white\">*</span>\n                      </Label>\n                      <Textarea\n                        id=\"ai-description\"\n                        placeholder=\"A data room for a Series B fundraising round for a AI startup called 'Acme AI'. Create advanced data room with IP information and financials.\"\n                        value={aiDescription}\n                        onChange={(e) => setAiDescription(e.target.value)}\n                        rows={4}\n                        className=\"resize-none\"\n                      />\n                    </div>\n                    <Button\n                      type=\"button\"\n                      className=\"w-full\"\n                      loading={aiGenerating}\n                      onClick={handleGenerateFolders}\n                      disabled={!aiDescription.trim() || aiGenerating}\n                    >\n                      Generate data room structure\n                    </Button>\n                  </>\n                ) : (\n                  <form\n                    onSubmit={handleSubmit}\n                    className=\"flex flex-col space-y-4 outline-none\"\n                  >\n                    <div className=\"space-y-1\">\n                      <Label htmlFor=\"dataroom-name-ai\">\n                        Dataroom Name{\" \"}\n                        <span className=\"text-black dark:text-white\">*</span>\n                      </Label>\n                      <Input\n                        id=\"dataroom-name-ai\"\n                        placeholder=\"AI Generated Data Room\"\n                        value={dataroomName}\n                        onChange={(e) => setDataroomName(e.target.value)}\n                        required\n                      />\n                    </div>\n                    <div className=\"space-y-2\">\n                      <div className=\"flex items-center justify-between\">\n                        <Label>\n                          Generated Folder Structure{\" \"}\n                          <span className=\"text-xs text-muted-foreground font-normal\">\n                            (Select folders to include)\n                          </span>\n                        </Label>\n                        <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                          <Checkbox\n                            checked={\n                              generatedFolders &&\n                              selectedFolderPaths.size > 0 &&\n                              generatedFolders.every((folder) => {\n                                const path = generateFolderPath(folder);\n                                return selectedFolderPaths.has(path);\n                              })\n                                ? true\n                                : false\n                            }\n                            onCheckedChange={(checked) => {\n                              if (checked && generatedFolders) {\n                                initializeFolderSelection(generatedFolders);\n                              } else {\n                                setSelectedFolderPaths(new Set());\n                              }\n                            }}\n                            id=\"select-all\"\n                          />\n                          <label\n                            htmlFor=\"select-all\"\n                            className=\"cursor-pointer\"\n                          >\n                            Select/Deselect All\n                          </label>\n                        </div>\n                      </div>\n                      <div className=\"max-h-96 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900\">\n                        {generatedFolders && renderFolderPreview(generatedFolders)}\n                      </div>\n                    </div>\n                    <div className=\"flex gap-2\">\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        className=\"flex-1\"\n                        onClick={() => {\n                          setShowPreview(false);\n                          setGeneratedFolders(null);\n                        }}\n                      >\n                        Back\n                      </Button>\n                      <Button\n                        type=\"submit\"\n                        className=\"flex-1\"\n                        loading={loading}\n                      >\n                        Create Dataroom\n                      </Button>\n                    </div>\n                  </form>\n                )}\n              </CardContent>\n            </Card>\n          </TabsContent>\n        </Tabs>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/add-viewer-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nexport function AddViewerModal({\n  dataroomId,\n  open,\n  setOpen,\n  children,\n}: {\n  dataroomId: string;\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  children?: React.ReactNode;\n}) {\n  const [emails, setEmails] = useState<string[]>([]);\n  const [inputValue, setInputValue] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n  const { trial } = usePlan();\n  const isTrial = !!trial;\n\n  // Email validation regex pattern\n  const validateEmail = (email: string) => {\n    return email.match(\n      /^(([^<>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/,\n    );\n  };\n\n  // const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n  //   const val = e.target.value;\n  //   setInputValue(val);\n\n  //   if (val.endsWith(\",\")) {\n  //     const newEmail = val.slice(0, -1).trim(); // Remove the comma and trim whitespace\n  //     if (validateEmail(newEmail) && !emails.includes(newEmail)) {\n  //       setEmails([...emails, newEmail]); // Add the new email if it's valid and not already in the list\n  //       setInputValue(\"\"); // Reset input field\n  //     }\n  //   }\n  // };\n\n  // const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n  //   const inputValue = e.target.value;\n  //   setInputValue(inputValue);\n\n  //   // Split the input value by commas to support pasting comma-separated emails\n  //   const potentialEmails = inputValue\n  //     .split(\",\")\n  //     .map((email) => email.trim())\n  //     .filter((email) => email);\n\n  //   const newEmails: string[] = [];\n  //   potentialEmails.forEach((email) => {\n  //     // Check if the email is valid and not already included\n  //     if (validateEmail(email) && !emails.includes(email)) {\n  //       newEmails.push(email);\n  //     }\n  //   });\n\n  //   // If there are new valid emails, update the state\n  //   if (newEmails.length > 0) {\n  //     setEmails([...emails, ...newEmails]);\n  //     setInputValue(\"\"); // Reset input field only if new emails were added\n  //   }\n  // };\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const inputValue = e.target.value;\n    setInputValue(inputValue);\n  };\n\n  const handleInputBlurOrComma = () => {\n    // Split the current input value by commas in case of pasting multiple emails\n    const potentialEmails = inputValue\n      .split(\",\")\n      .map((email) => email.trim().toLowerCase())\n      .filter((email) => email);\n\n    const newEmails = potentialEmails.filter((email) => {\n      // Validate each email and check it's not already in the list\n      return validateEmail(email) && !emails.includes(email);\n    });\n\n    if (newEmails.length > 0) {\n      setEmails([...emails, ...newEmails]); // Add new valid emails to the list\n      setInputValue(\"\"); // Clear input field\n    }\n  };\n\n  const removeEmail = (index: number) => {\n    setEmails(emails.filter((_, i) => i !== index));\n  };\n\n  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (emails.length === 0) return;\n\n    if (isTrial) {\n      toast.error(\n        \"You are on a trial plan. You cannot send email invitations to prevent spamming.\",\n      );\n      return;\n    }\n\n    if (emails.length > 5) {\n      toast.error(\n        \"You can only send invitations to a maximum of 5 emails at a time.\",\n      );\n      return;\n    }\n\n    setLoading(true);\n\n    // POST request with multiple emails\n    const response = await fetch(\n      `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/users`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          emails: emails,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      const error = await response.json();\n      setLoading(false);\n      setOpen(false);\n      toast.error(error.message || \"Failed to send invitations.\");\n      return;\n    }\n\n    analytics.capture(\"Dataroom View Invitation Sent\", {\n      inviteeCount: emails.length,\n      teamId: teamInfo?.currentTeam?.id,\n      dataroomId: dataroomId,\n    });\n\n    mutate(\n      `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/viewers`,\n    );\n\n    toast.success(\"Invitation emails have been sent!\");\n    setOpen(false);\n    setLoading(false);\n    setEmails([]); // Reset emails state\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Invite Visitors</DialogTitle>\n          <DialogDescription>\n            Enter email addresses, separated by commas.\n          </DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"email\" className=\"opacity-80\">\n            Emails\n          </Label>\n          <div className=\"flex flex-wrap gap-2 py-2 text-sm\">\n            {emails.map((email, index) => (\n              <div\n                key={index}\n                className=\"flex items-center gap-2 rounded bg-gray-100 px-2 py-1\"\n              >\n                {email}\n                <button type=\"button\" onClick={() => removeEmail(index)}>\n                  ×\n                </button>\n              </div>\n            ))}\n            <Input\n              id=\"email\"\n              value={inputValue}\n              placeholder=\"example@domain.com\"\n              className=\"flex-1\"\n              onChange={handleInputChange}\n              onBlur={handleInputBlurOrComma} // Handle when input loses focus\n              onKeyDown={(e) => {\n                if (e.key === \",\") {\n                  e.preventDefault(); // Prevent the comma from being added to the input\n                  handleInputBlurOrComma(); // Act as if the input lost focus to process the email\n                }\n              }}\n            />\n          </div>\n\n          <DialogFooter>\n            <Button type=\"submit\" className=\"mt-8 h-9 w-full\" loading={loading}>\n              {loading ? \"Sending emails...\" : \"Add members\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/analytics/analytics-overview.tsx",
    "content": "import { useMemo } from \"react\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomDocumentStats } from \"@/lib/swr/use-dataroom-document-stats\";\nimport { useDataroomStats } from \"@/lib/swr/use-dataroom-stats\";\n\nimport StatsChart from \"@/components/documents/stats-chart\";\nimport { Gauge } from \"@/components/ui/gauge\";\n\ninterface DataroomAnalyticsOverviewProps {\n  selectedDocument: {\n    id: string;\n    name: string;\n  } | null;\n  setSelectedDocument: React.Dispatch<\n    React.SetStateAction<{\n      id: string;\n      name: string;\n    } | null>\n  >;\n}\n\nexport default function DataroomAnalyticsOverview({\n  selectedDocument,\n  setSelectedDocument,\n}: DataroomAnalyticsOverviewProps) {\n  const {\n    stats: dataroomStats,\n    loading: dataroomLoading,\n    error: dataroomError,\n  } = useDataroomStats();\n\n  // Memoize the most viewed document calculation\n  const mostViewedDocument = useMemo(() => {\n    if (!dataroomStats || selectedDocument) return null;\n\n    // Group views by document ID and count them\n    const viewsByDocument = dataroomStats.documentViews.reduce(\n      (acc, view) => {\n        if (!view.documentId) return acc;\n\n        if (!acc[view.documentId]) {\n          acc[view.documentId] = { count: 0, name: \"\" };\n        }\n        acc[view.documentId].count += 1;\n        return acc;\n      },\n      {} as Record<string, { count: number; name: string }>,\n    );\n\n    // Find document with most views\n    let maxViews = 0;\n    let mostViewedId = \"\";\n    Object.entries(viewsByDocument).forEach(([docId, data]) => {\n      if (data.count > maxViews) {\n        maxViews = data.count;\n        mostViewedId = docId;\n      }\n    });\n\n    // Return the most viewed document if found\n    return mostViewedId\n      ? {\n          id: mostViewedId,\n          name: mostViewedId, // We'll update the name once we have it\n        }\n      : null;\n  }, [dataroomStats, selectedDocument]);\n\n  // Get document stats for either selected document or most viewed document\n  const documentId = selectedDocument?.id || mostViewedDocument?.id;\n  const {\n    stats: documentStats,\n    loading: documentLoading,\n    error: documentError,\n  } = useDataroomDocumentStats(documentId);\n\n  // If neither is selected or we're still loading dataroom stats\n  const loading = documentLoading || (dataroomLoading && !documentId);\n  const error = documentError || (dataroomError && !documentId);\n\n  if (loading) {\n    return <div>Loading analytics...</div>;\n  }\n\n  if (error) {\n    return <div>Error loading analytics</div>;\n  }\n\n  const completionRate = 0;\n\n  // Get display name for the currently viewed document\n  const displayName =\n    selectedDocument?.name ||\n    (mostViewedDocument?.name !== mostViewedDocument?.id\n      ? mostViewedDocument?.name\n      : \"Most viewed document\");\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"grid grid-cols-1 gap-6\">\n        <div>\n          <h3 className=\"mb-4 text-lg font-medium\">\n            {displayName ? displayName : \"Document Engagement\"}\n          </h3>\n          {documentStats && (\n            <StatsChart\n              documentId={documentId || \"\"}\n              totalPagesMax={documentStats?.totalPagesMax || 0}\n              statsData={{\n                stats: documentStats,\n                loading: false,\n                error: null,\n              }}\n            />\n          )}\n        </div>\n\n        {/* INFO: hiding completion rate for now */}\n        {/* <div className=\"flex flex-col items-center justify-center rounded-lg border p-6\">\n          <h3 className=\"mb-4 text-lg font-medium\">\n            {displayName\n              ? `${displayName} - Completion Rate`\n              : \"Completion Rate\"}\n          </h3>\n          <div className=\"flex flex-col items-center\">\n            <Gauge value={completionRate} size=\"large\" showValue={true} />\n            <p className=\"mt-4 text-sm text-muted-foreground\">\n              Document has {documentStats?.totalViews || 0} view\n              {documentStats?.totalViews !== 1 ? \"s\" : \"\"} in this dataroom\n            </p>\n            {!selectedDocument && mostViewedDocument && (\n              <button\n                onClick={() => setSelectedDocument(mostViewedDocument)}\n                className=\"mt-2 text-xs text-primary hover:underline\"\n              >\n                View all documents\n              </button>\n            )}\n          </div>\n        </div> */}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/analytics/document-analytics-tree.tsx",
    "content": "import { useCallback, useMemo } from \"react\";\nimport { Dispatch, SetStateAction } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ItemType } from \"@prisma/client\";\nimport {\n  ColumnDef,\n  flexRender,\n  getCoreRowModel,\n  getExpandedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport {\n  ChevronDown,\n  ChevronRight,\n  Download,\n  Eye,\n  File,\n  Folder,\n} from \"lucide-react\";\n\nimport { useDataroomFoldersTree } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomStats } from \"@/lib/swr/use-dataroom-stats\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\n\n// Define types for the file/folder structure with analytics\ntype FileOrFolder = {\n  id: string;\n  name: string;\n  subItems?: FileOrFolder[];\n  analytics: {\n    views: number;\n    downloads: number;\n  };\n  itemType: ItemType;\n  documentId?: string;\n};\n\ninterface DocumentAnalyticsTreeProps {\n  dataroomId: string;\n  selectedDocument: {\n    id: string;\n    name: string;\n  } | null;\n  setSelectedDocument: Dispatch<\n    SetStateAction<{\n      id: string;\n      name: string;\n    } | null>\n  >;\n}\n\nexport default function DocumentAnalyticsTree({\n  dataroomId,\n  selectedDocument,\n  setSelectedDocument,\n}: DocumentAnalyticsTreeProps) {\n  const { folders, loading: foldersLoading } = useDataroomFoldersTree({\n    dataroomId,\n    include_documents: true,\n  });\n  const { stats: dataroomStats, loading: statsLoading } = useDataroomStats();\n\n  // Memoize the tree data building\n  const data = useMemo(() => {\n    if (!folders || foldersLoading || !dataroomStats || statsLoading) {\n      return [];\n    }\n\n    return buildTree(folders, dataroomStats);\n  }, [folders, foldersLoading, dataroomStats, statsLoading]);\n\n  const handleRowClick = useCallback(\n    (row: FileOrFolder) => {\n      // Only select documents, not folders\n      if (row.itemType === ItemType.DATAROOM_DOCUMENT && row.documentId) {\n        if (selectedDocument && selectedDocument.id === row.documentId) {\n          // If clicking on the already selected document, deselect it\n          setSelectedDocument(null);\n        } else {\n          // Otherwise select the clicked document\n          setSelectedDocument({\n            id: row.documentId,\n            name: row.name,\n          });\n        }\n      }\n    },\n    [selectedDocument, setSelectedDocument],\n  );\n\n  const createColumns = useCallback(\n    (): ColumnDef<FileOrFolder>[] => [\n      {\n        id: \"expander\",\n        header: () => null,\n        cell: ({ row }) => {\n          return row.getCanExpand() ? (\n            <Button\n              variant=\"ghost\"\n              onClick={(e) => {\n                e.stopPropagation();\n                row.getToggleExpandedHandler()();\n              }}\n              className=\"h-6 w-6 p-0\"\n            >\n              {row.getIsExpanded() ? (\n                <ChevronDown className=\"h-4 w-4\" />\n              ) : (\n                <ChevronRight className=\"h-4 w-4\" />\n              )}\n            </Button>\n          ) : null;\n        },\n      },\n      {\n        accessorKey: \"name\",\n        header: \"Name\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center text-foreground\">\n            {row.original.itemType === ItemType.DATAROOM_FOLDER ? (\n              <Folder className=\"mr-2 h-5 w-5\" />\n            ) : (\n              <File className=\"mr-2 h-5 w-5\" />\n            )}\n            <span className=\"truncate\">{row.original.name}</span>\n          </div>\n        ),\n      },\n      {\n        id: \"views\",\n        header: () => (\n          <div className=\"flex items-center justify-center\">\n            <Eye className=\"mr-1 h-4 w-4\" />\n            <span>Views</span>\n          </div>\n        ),\n        cell: ({ row }) => (\n          <div className=\"text-center\">{row.original.analytics.views}</div>\n        ),\n      },\n      {\n        id: \"downloads\",\n        header: () => (\n          <div className=\"flex items-center justify-center\">\n            <Download className=\"mr-1 h-4 w-4\" />\n            <span>Downloads</span>\n          </div>\n        ),\n        cell: ({ row }) => (\n          <div className=\"text-center\">{row.original.analytics.downloads}</div>\n        ),\n      },\n    ],\n    [],\n  );\n\n  const columns = useMemo(() => createColumns(), [createColumns]);\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getExpandedRowModel: getExpandedRowModel(),\n    getSubRows: (row) => row.subItems,\n  });\n\n  if (foldersLoading || statsLoading) {\n    return <div>Loading analytics data...</div>;\n  }\n\n  return (\n    <div className=\"rounded-md border\">\n      <Table>\n        <TableHeader>\n          {table.getHeaderGroups().map((headerGroup) => (\n            <TableRow key={headerGroup.id}>\n              {headerGroup.headers.map((header) => (\n                <TableHead key={header.id} className=\"py-2 first:w-12\">\n                  {header.isPlaceholder\n                    ? null\n                    : flexRender(\n                        header.column.columnDef.header,\n                        header.getContext(),\n                      )}\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {table.getRowModel().rows?.length ? (\n            table.getRowModel().rows.map((row) => (\n              <TableRow\n                key={row.id}\n                data-state={row.getIsSelected() && \"selected\"}\n                className={cn(\n                  \"cursor-pointer hover:bg-muted/50\",\n                  // Highlight the selected document\n                  selectedDocument &&\n                    row.original.documentId === selectedDocument.id &&\n                    \"bg-muted\",\n                )}\n                onClick={() => handleRowClick(row.original)}\n              >\n                {row.getVisibleCells().map((cell) => (\n                  <TableCell\n                    key={cell.id}\n                    style={{\n                      paddingLeft:\n                        cell.column.id === \"name\"\n                          ? `${row.depth * 1.25 + 1}rem`\n                          : undefined,\n                    }}\n                    className=\"py-2\"\n                  >\n                    {flexRender(cell.column.columnDef.cell, cell.getContext())}\n                  </TableCell>\n                ))}\n              </TableRow>\n            ))\n          ) : (\n            <TableRow>\n              <TableCell colSpan={columns.length} className=\"h-24 text-center\">\n                No analytics data available.\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n\n// Build the tree structure with analytics data\nconst buildTree = (\n  items: any[],\n  statsData: any,\n  parentId: string | null = null,\n): FileOrFolder[] => {\n  const getAnalytics = (id: string, documentId?: string) => {\n    // Count views for this specific item\n    const views =\n      statsData?.documentViews.filter(\n        (view: any) => view.itemId === id || view.documentId === documentId,\n      ).length || 0;\n\n    // Count downloads for this specific item (assuming downloads are tracked in views with a downloadedAt field)\n    const downloads =\n      statsData?.documentViews.filter(\n        (view: any) =>\n          (view.itemId === id || view.documentId === documentId) &&\n          view.downloadedAt,\n      ).length || 0;\n\n    return {\n      views,\n      downloads,\n    };\n  };\n\n  const result: FileOrFolder[] = [];\n\n  // Handle folders and their contents\n  items\n    .filter((item) => item.parentId === parentId && !item.document)\n    .forEach((folder) => {\n      const subItems = buildTree(items, statsData, folder.id);\n\n      // Add documents directly in this folder\n      const folderDocuments = (folder.documents || []).map((doc: any) => {\n        const analytics = getAnalytics(doc.id, doc.document.id);\n        return {\n          id: doc.id,\n          documentId: doc.document.id,\n          name: doc.document.name,\n          analytics,\n          itemType: ItemType.DATAROOM_DOCUMENT,\n        };\n      });\n\n      const allSubItems = [...subItems, ...folderDocuments];\n\n      // Calculate aggregated analytics for the folder\n      const folderAnalytics = {\n        views: allSubItems.reduce((sum, item) => sum + item.analytics.views, 0),\n        downloads: allSubItems.reduce(\n          (sum, item) => sum + item.analytics.downloads,\n          0,\n        ),\n      };\n\n      result.push({\n        id: folder.id,\n        name: folder.name,\n        subItems: allSubItems,\n        analytics: folderAnalytics,\n        itemType: ItemType.DATAROOM_FOLDER,\n      });\n    });\n\n  // Handle documents at the current level\n  items\n    .filter(\n      (item) =>\n        (item.parentId === parentId && item.document) ||\n        (parentId === null && item.folderId === null && item.document),\n    )\n    .forEach((doc) => {\n      const analytics = getAnalytics(doc.id, doc.document.id);\n      result.push({\n        id: doc.id,\n        documentId: doc.document.id,\n        name: doc.document.name,\n        analytics,\n        itemType: ItemType.DATAROOM_DOCUMENT,\n      });\n    });\n\n  return result;\n};\n"
  },
  {
    "path": "components/datarooms/analytics/mock-analytics-table.tsx",
    "content": "import { ChevronRight, Download, Eye, File, Folder } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\n\n// Mock data for analytics preview\nconst mockAnalyticsData = [\n  {\n    id: \"2\",\n    name: \"Financial Reports\",\n    type: \"folder\",\n    views: 38,\n    downloads: 8,\n  },\n  { id: \"4\", name: \"Legal Documents\", type: \"folder\", views: 22, downloads: 3 },\n  {\n    id: \"1\",\n    name: \"Executive Summary\",\n    type: \"file\",\n    views: 45,\n    downloads: 12,\n  },\n  {\n    id: \"3\",\n    name: \"Market Analysis.pdf\",\n    type: \"file\",\n    views: 29,\n    downloads: 15,\n  },\n  { id: \"5\", name: \"Product Roadmap\", type: \"file\", views: 18, downloads: 7 },\n  { id: \"6\", name: \"Team Structure\", type: \"file\", views: 14, downloads: 2 },\n  { id: \"7\", name: \"Marketing Plan\", type: \"file\", views: 14, downloads: 2 },\n];\n\nexport default function MockAnalyticsTable() {\n  return (\n    <div className=\"space-y-6\">\n      <h3 className=\"mb-4 text-lg font-medium\">Dataroom Analytics</h3>\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead className=\"w-12\"></TableHead>\n              <TableHead>Name</TableHead>\n              <TableHead className=\"text-center\">\n                <div className=\"flex items-center justify-center\">\n                  <Eye className=\"mr-1 h-4 w-4\" />\n                  <span>Views</span>\n                </div>\n              </TableHead>\n              <TableHead className=\"text-center\">\n                <div className=\"flex items-center justify-center\">\n                  <Download className=\"mr-1 h-4 w-4\" />\n                  <span>Downloads</span>\n                </div>\n              </TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {mockAnalyticsData.map((row, index) => (\n              <TableRow\n                key={row.id}\n                className={cn(\n                  \"cursor-pointer transition-opacity hover:bg-muted/50\",\n                  // Fade out the last 2 rows\n                  index >= 5 && \"opacity-30\",\n                )}\n              >\n                <TableCell>\n                  {row.type === \"folder\" && (\n                    <Button variant=\"ghost\" className=\"h-6 w-6 p-0\">\n                      <ChevronRight className=\"h-4 w-4\" />\n                    </Button>\n                  )}\n                </TableCell>\n                <TableCell>\n                  <div className=\"flex items-center text-foreground\">\n                    {row.type === \"folder\" ? (\n                      <Folder className=\"mr-2 h-5 w-5\" />\n                    ) : (\n                      <File className=\"mr-2 h-5 w-5\" />\n                    )}\n                    <span className=\"truncate\">{row.name}</span>\n                  </div>\n                </TableCell>\n                <TableCell className=\"text-center\">{row.views}</TableCell>\n                <TableCell className=\"text-center\">{row.downloads}</TableCell>\n              </TableRow>\n            ))}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/dataroom-breadcrumb.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport React, { useMemo } from \"react\";\n\nimport {\n  useDataroom,\n  useDataroomFolderWithParents,\n} from \"@/lib/swr/use-dataroom\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  useHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport {\n  Breadcrumb,\n  BreadcrumbEllipsis,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { TruncatedBreadcrumbLink } from \"../layouts/breadcrumb\";\n\nconst BreadcrumbFolderItem = ({\n  folder,\n  dataroomId,\n  isLast,\n}: {\n  folder: any;\n  dataroomId: string;\n  isLast: boolean;\n}) => {\n  const displayName = useHierarchicalDisplayName(\n    folder.name,\n    folder.hierarchicalIndex,\n  );\n\n  if (isLast) {\n    return (\n      <BreadcrumbPage\n        className=\"max-w-[200px] truncate\"\n        style={HIERARCHICAL_DISPLAY_STYLE}\n      >\n        {displayName}\n      </BreadcrumbPage>\n    );\n  }\n\n  return (\n    <BreadcrumbLink asChild>\n      <Link\n        href={`/datarooms/${dataroomId}/documents${folder.path}`}\n        className=\"max-w-[200px] truncate\"\n        style={HIERARCHICAL_DISPLAY_STYLE}\n      >\n        {displayName}\n      </Link>\n    </BreadcrumbLink>\n  );\n};\n\nconst BreadcrumbDropdownItem = ({\n  folder,\n  dataroomId,\n}: {\n  folder: any;\n  dataroomId: string;\n}) => {\n  const displayName = useHierarchicalDisplayName(\n    folder.name,\n    folder.hierarchicalIndex,\n  );\n\n  return (\n    <DropdownMenuItem>\n      <Link\n        href={`/datarooms/${dataroomId}/documents${folder.path}`}\n        className=\"w-full\"\n        style={HIERARCHICAL_DISPLAY_STYLE}\n      >\n        {displayName}\n      </Link>\n    </DropdownMenuItem>\n  );\n};\n\nfunction BreadcrumbComponentBase({\n  name,\n  dataroomId,\n}: {\n  name: string[];\n  dataroomId: string;\n}) {\n  const { dataroom } = useDataroom();\n  const { folders } = useDataroomFolderWithParents({\n    name,\n    dataroomId,\n  });\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/datarooms\">Datarooms</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        <BreadcrumbItem>\n          <TruncatedBreadcrumbLink\n            href={`/datarooms/${dataroomId}/documents`}\n            text={dataroom?.name}\n          />\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href={`/datarooms/${dataroomId}/documents`}>Documents</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        {folders && folders.length > 0 && <BreadcrumbSeparator />}\n        {folders && folders.length > 2 ? (\n          <>\n            <BreadcrumbItem>\n              <DropdownMenu>\n                <DropdownMenuTrigger className=\"flex items-center gap-1\">\n                  <BreadcrumbEllipsis className=\"h-4 w-4\" />\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"start\">\n                  {folders.slice(0, -2).map((folder, index) => (\n                    <BreadcrumbDropdownItem\n                      key={index}\n                      folder={folder}\n                      dataroomId={dataroomId}\n                    />\n                  ))}\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </BreadcrumbItem>\n            <BreadcrumbSeparator />\n            <BreadcrumbItem>\n              <BreadcrumbFolderItem\n                folder={folders[folders.length - 2]}\n                dataroomId={dataroomId}\n                isLast={false}\n              />\n            </BreadcrumbItem>\n            <BreadcrumbSeparator />\n            <BreadcrumbItem>\n              <BreadcrumbFolderItem\n                folder={folders[folders.length - 1]}\n                dataroomId={dataroomId}\n                isLast={true}\n              />\n            </BreadcrumbItem>\n          </>\n        ) : (\n          folders?.map((folder, index) => (\n            <React.Fragment key={index}>\n              <BreadcrumbItem>\n                <BreadcrumbFolderItem\n                  folder={folder}\n                  dataroomId={dataroomId}\n                  isLast={index === folders.length - 1}\n                />\n              </BreadcrumbItem>\n              {index < folders.length - 1 && <BreadcrumbSeparator />}\n            </React.Fragment>\n          ))\n        )}\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n}\n\nconst BreadcrumbComponent = () => {\n  const router = useRouter();\n  const name = router.query.name as string[];\n  const dataroomId = router.query.id as string;\n\n  // Use useMemo to memoize the base component with the current name value.\n  // This way, BreadcrumbComponentBase is only re-rendered when name changes.\n  const MemoizedBreadcrumbComponent = useMemo(() => {\n    return <BreadcrumbComponentBase name={name} dataroomId={dataroomId} />;\n  }, [name, dataroomId]);\n\n  return MemoizedBreadcrumbComponent;\n};\n\nexport { BreadcrumbComponent };\n"
  },
  {
    "path": "components/datarooms/dataroom-card.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { formatDistanceToNow } from \"date-fns\";\n\nimport { TagColorProps } from \"@/lib/types\";\n\nimport TagBadge from \"@/components/links/link-sheet/tags/tag-badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\ntype DataroomTag = {\n  tag: {\n    id: string;\n    name: string;\n    color: string;\n    description: string | null;\n  };\n};\n\ntype DataroomWithDetails = {\n  id: string;\n  name: string;\n  internalName?: string | null;\n  _count: {\n    documents: number;\n    views: number;\n  };\n  activeLinkCount: number;\n  lastViewedAt: Date | null;\n  createdAt: Date;\n  tags?: DataroomTag[];\n};\n\ninterface DataroomCardProps {\n  dataroom: DataroomWithDetails;\n}\n\nexport default function DataroomCard({ dataroom }: DataroomCardProps) {\n  const router = useRouter();\n\n  const isActive = dataroom.activeLinkCount > 0;\n  const activeLinkCount = dataroom.activeLinkCount;\n  const lastViewedAt = dataroom.lastViewedAt;\n  const hasDocuments = dataroom._count.documents > 0;\n\n  const handleButtonClick = (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    if (!hasDocuments) {\n      // If no documents, go to documents tab\n      router.push(`/datarooms/${dataroom.id}/documents`);\n    } else if (!isActive) {\n      // If has documents but inactive, go to permissions/share\n      router.push(`/datarooms/${dataroom.id}/permissions`);\n    }\n  };\n\n  return (\n    <Card className=\"group relative overflow-hidden duration-500 hover:border-primary/50\">\n      <Link href={`/datarooms/${dataroom.id}/documents`}>\n        <CardHeader>\n          <div className=\"flex items-start justify-between gap-2\">\n            <div className=\"flex-1 min-w-0\">\n              <CardTitle className=\"truncate text-lg\">\n                {dataroom.internalName || dataroom.name}\n              </CardTitle>\n              {dataroom.internalName && (\n                <p className=\"truncate text-sm text-muted-foreground mt-0.5\">\n                  {dataroom.name}\n                </p>\n              )}\n            </div>\n            <div className=\"flex shrink-0 items-center gap-2\">\n              <TooltipProvider>\n                <Tooltip>\n                  <TooltipTrigger asChild>\n                    <div\n                      className=\"flex items-center gap-1.5\"\n                      onClick={(e) => e.preventDefault()}\n                    >\n                      <div\n                        className={`h-2 w-2 rounded-full ${\n                          isActive ? \"bg-green-500\" : \"bg-gray-400\"\n                        }`}\n                      />\n                      <span\n                        className={`text-xs ${\n                          isActive ? \"text-green-600\" : \"text-gray-500\"\n                        }`}\n                      >\n                        {isActive ? \"Active\" : \"Inactive\"}\n                      </span>\n                    </div>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <p>\n                      {isActive\n                        ? \"Dataroom has active links\"\n                        : \"No active links\"}\n                    </p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            </div>\n          </div>\n\n          {/* Tags */}\n          {dataroom.tags && dataroom.tags.length > 0 && (\n            <div\n              className=\"flex flex-wrap gap-1.5\"\n              onClick={(e) => e.preventDefault()}\n            >\n              {dataroom.tags.slice(0, 3).map((tagItem) => (\n                <TagBadge\n                  key={tagItem.tag.id}\n                  name={tagItem.tag.name}\n                  color={tagItem.tag.color as TagColorProps}\n                  withIcon\n                />\n              ))}\n              {dataroom.tags.length > 3 && (\n                <span className=\"text-xs text-muted-foreground\">\n                  +{dataroom.tags.length - 3} more\n                </span>\n              )}\n            </div>\n          )}\n        </CardHeader>\n\n        <CardContent>\n          {/* Stats List */}\n          <div className=\"mb-4 space-y-3\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"text-sm text-muted-foreground\">Documents</div>\n              <div className=\"text-sm text-muted-foreground\">\n                {dataroom._count.documents}\n              </div>\n            </div>\n            <div className=\"flex items-center justify-between\">\n              <div className=\"text-sm text-muted-foreground\">Views</div>\n              <div className=\"text-sm text-muted-foreground\">\n                {dataroom._count.views}\n              </div>\n            </div>\n            <div className=\"flex items-center justify-between\">\n              <div className=\"text-sm text-muted-foreground\">Active Links</div>\n              <div className=\"text-sm text-muted-foreground\">\n                {activeLinkCount}\n              </div>\n            </div>\n          </div>\n\n          {/* Footer */}\n          <div className=\"flex items-center justify-between border-t pt-3\">\n            <div className=\"text-xs text-muted-foreground\">\n              {lastViewedAt ? (\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger>\n                      <span>\n                        Viewed{\" \"}\n                        {formatDistanceToNow(new Date(lastViewedAt), {\n                          addSuffix: true,\n                        })}\n                      </span>\n                    </TooltipTrigger>\n                    <TooltipContent>\n                      <p>Last viewed</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              ) : (\n                <span>No views yet</span>\n              )}\n            </div>\n\n            <div className=\"flex gap-2\" onClick={(e) => e.stopPropagation()}>\n              {!hasDocuments ? (\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <Button\n                        variant=\"outline\"\n                        size=\"sm\"\n                        className=\"h-8 gap-2 text-muted-foreground hover:text-foreground\"\n                        onClick={handleButtonClick}\n                      >\n                        <span>Add Documents</span>\n                      </Button>\n                    </TooltipTrigger>\n                    <TooltipContent>\n                      <p>Add documents to dataroom</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              ) : (\n                !isActive && (\n                  <TooltipProvider>\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <Button\n                          variant=\"outline\"\n                          size=\"sm\"\n                          className=\"h-8 gap-2 text-muted-foreground hover:text-foreground\"\n                          onClick={handleButtonClick}\n                        >\n                          <span>Share</span>\n                        </Button>\n                      </TooltipTrigger>\n                      <TooltipContent>\n                        <p>Share dataroom</p>\n                      </TooltipContent>\n                    </Tooltip>\n                  </TooltipProvider>\n                )\n              )}\n            </div>\n          </div>\n        </CardContent>\n      </Link>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/dataroom-document-card.tsx",
    "content": "import Image from \"next/image\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  ArchiveXIcon,\n  BetweenHorizontalStartIcon,\n  FilePenIcon,\n  FileSlidersIcon,\n  FolderInputIcon,\n  MoreVertical,\n} from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { type DataroomFolderDocument } from \"@/lib/swr/use-dataroom\";\nimport { type DocumentWithLinksAndLinkCountAndViewCount } from \"@/lib/types\";\nimport { cn, nFormatter, timeAgo } from \"@/lib/utils\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  useHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport BarChart from \"@/components/shared/icons/bar-chart\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { AddToDataroomModal } from \"../documents/add-document-to-dataroom-modal\";\nimport { DocumentPreviewButton } from \"../documents/document-preview-button\";\nimport FileProcessStatusBar from \"../documents/file-process-status-bar\";\nimport { EditDataroomDocumentModal } from \"./edit-dataroom-document-modal\";\nimport { SetUnifiedPermissionsModal } from \"./groups/set-unified-permissions-modal\";\nimport { MoveToDataroomFolderModal } from \"./move-dataroom-folder-modal\";\n\ntype DocumentsCardProps = {\n  document: DataroomFolderDocument;\n  teamInfo: TeamContextType | null;\n  dataroomId: string;\n  isDragging?: boolean;\n  isSelected?: boolean;\n  isHovered?: boolean;\n};\nexport default function DataroomDocumentCard({\n  document: dataroomDocument,\n  teamInfo,\n  dataroomId,\n  isDragging,\n  isSelected,\n  isHovered,\n}: DocumentsCardProps) {\n  const [groupPermissionOpen, setGroupPermissionOpen] =\n    useState<boolean>(false);\n  const { theme, systemTheme } = useTheme();\n  const isLight =\n    theme === \"light\" || (theme === \"system\" && systemTheme === \"light\");\n  const router = useRouter();\n\n  // Get hierarchical display name\n  const displayName = useHierarchicalDisplayName(\n    dataroomDocument.document.name,\n    dataroomDocument.hierarchicalIndex,\n  );\n\n  const [isFirstClick, setIsFirstClick] = useState<boolean>(false);\n  const [menuOpen, setMenuOpen] = useState<boolean>(false);\n  const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);\n  const [renameOpen, setRenameOpen] = useState<boolean>(false);\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n  const [addDataRoomOpen, setAddDataRoomOpen] = useState<boolean>(false);\n\n  /** current folder name */\n  const currentFolderPath = router.query.name as string[] | undefined;\n\n  // Add state for document processing status\n  const [isProcessing, setIsProcessing] = useState(false);\n\n  // https://github.com/radix-ui/primitives/issues/1241#issuecomment-1888232392\n  useEffect(() => {\n    if (!moveFolderOpen) {\n      setTimeout(() => {\n        document.body.style.pointerEvents = \"\";\n      });\n    }\n  }, [moveFolderOpen]);\n\n  useEffect(() => {\n    if (!renameOpen) {\n      setTimeout(() => {\n        document.body.style.pointerEvents = \"\";\n      });\n    }\n  }, [renameOpen]);\n\n  useEffect(() => {\n    function handleClickOutside(event: { target: any }) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {\n        setMenuOpen(false);\n        setIsFirstClick(false);\n      }\n    }\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, []);\n\n  const handleButtonClick = (event: any, documentId: string) => {\n    event.stopPropagation();\n    event.preventDefault();\n\n    console.log(\"isFirstClick\", isFirstClick);\n    if (isFirstClick) {\n      handleRemoveDocument(documentId);\n      setIsFirstClick(false);\n      setMenuOpen(false); // Close the dropdown after deleting\n    } else {\n      setIsFirstClick(true);\n    }\n  };\n\n  const handleRemoveDocument = async (documentId: string) => {\n    // Prevent the first click from deleting the document\n    if (!isFirstClick) {\n      setIsFirstClick(true);\n      return;\n    }\n\n    const endpoint = currentFolderPath\n      ? `/folders/documents/${currentFolderPath.join(\"/\")}`\n      : \"/documents\";\n\n    toast.promise(\n      fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents/${documentId}`,\n        {\n          method: \"DELETE\",\n        },\n      ).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to remove document\");\n        }\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}${endpoint}`,\n          null,\n          {\n            populateCache: (_, docs) => {\n              return docs.filter(\n                (doc: DocumentWithLinksAndLinkCountAndViewCount) =>\n                  doc.id !== documentId,\n              );\n            },\n            revalidate: false,\n          },\n        );\n      }),\n      {\n        loading: \"Removing document...\",\n        success: \"Document removed successfully.\",\n        error: (err) => err.message || \"Failed to remove document. Try again.\",\n      },\n    );\n  };\n\n  const handleMenuStateChange = (open: boolean) => {\n    if (isFirstClick) {\n      setMenuOpen(true); // Keep the dropdown open on the first click\n      return;\n    }\n\n    // If the menu is closed, reset the isFirstClick state\n    if (!open) {\n      setIsFirstClick(false);\n      setMenuOpen(false); // Ensure the dropdown is closed\n    } else {\n      setMenuOpen(true); // Open the dropdown\n    }\n  };\n\n  const handleCardClick = (e: React.MouseEvent) => {\n    if (isDragging || menuOpen) {\n      e.preventDefault();\n      e.stopPropagation();\n      return;\n    }\n    router.push(`/documents/${dataroomDocument.document.id}`);\n  };\n\n  return (\n    <>\n      <div\n        onClick={handleCardClick}\n        className={cn(\n          \"group/row relative flex flex-col rounded-lg border-0 bg-white ring-1 ring-gray-200 transition-all hover:bg-secondary hover:ring-gray-300 dark:bg-secondary dark:ring-gray-700 hover:dark:ring-gray-500\",\n          isDragging ? \"cursor-grabbing\" : \"cursor-pointer\",\n          isHovered && \"bg-secondary ring-gray-300 dark:ring-gray-500\",\n        )}\n      >\n        <div\n          className={cn(\n            \"flex items-center justify-between p-3 sm:p-4\",\n            isProcessing && \"opacity-60\",\n          )}\n        >\n          <div className=\"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\">\n            {!isSelected && !isHovered ? (\n              <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n                {fileIcon({\n                  fileType: dataroomDocument.document.type ?? \"\",\n                  className: \"h-8 w-8\",\n                  isLight,\n                })}\n              </div>\n            ) : (\n              <div className=\"mx-0.5 w-8 sm:mx-1\"></div>\n            )}\n\n            <div className=\"flex-col\">\n              <div className=\"flex items-center\">\n                <h2\n                  className=\"min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md\"\n                  style={HIERARCHICAL_DISPLAY_STYLE}\n                >\n                  {displayName}\n                </h2>\n              </div>\n              <div className=\"mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground\">\n                <p className=\"truncate\">\n                  {timeAgo(dataroomDocument.createdAt)}\n                </p>\n                {dataroomDocument.document._count.versions > 1 ? (\n                  <>\n                    <p>•</p>\n                    <p className=\"truncate\">{`${dataroomDocument.document._count.versions} Versions`}</p>\n                  </>\n                ) : null}\n                {dataroomDocument.document.isExternalUpload ? (\n                  <>\n                    <p>•</p>\n                    <p className=\"truncate\">Added by external collaborator</p>\n                  </>\n                ) : null}\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex flex-row space-x-2\">\n            <Link\n              onClick={(e) => {\n                e.stopPropagation();\n              }}\n              href={`/documents/${dataroomDocument.document.id}`}\n              className=\"z-10 flex items-center space-x-1 rounded-md bg-gray-200 px-1.5 py-0.5 transition-all duration-75 hover:scale-105 active:scale-100 dark:bg-gray-700 sm:px-2\"\n            >\n              <BarChart className=\"h-3 w-3 text-muted-foreground sm:h-4 sm:w-4\" />\n              <p className=\"whitespace-nowrap text-xs text-muted-foreground sm:text-sm\">\n                {nFormatter(dataroomDocument.document._count.views)}\n                <span className=\"ml-1 hidden sm:inline-block\">views</span>\n              </p>\n            </Link>\n\n            <DocumentPreviewButton\n              documentId={dataroomDocument.document.id}\n              primaryVersion={{\n                hasPages:\n                  dataroomDocument.document.versions?.[0]?.hasPages || false,\n                type: dataroomDocument.document.type,\n                numPages: null,\n              }}\n              advancedExcelEnabled={\n                dataroomDocument.document.advancedExcelEnabled\n              }\n              variant=\"outline\"\n              size=\"icon\"\n              className=\"z-10 h-8 w-8 border-gray-200 bg-transparent hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n            />\n\n            <DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>\n              <DropdownMenuTrigger asChild>\n                <Button\n                  onClick={(e) => e.stopPropagation()}\n                  variant=\"outline\"\n                  className=\"z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n                >\n                  <span className=\"sr-only\">Open menu</span>\n                  <MoreVertical className=\"h-4 w-4\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\" ref={dropdownRef}>\n                <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                <DropdownMenuItem\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setRenameOpen(true);\n                  }}\n                >\n                  <FilePenIcon className=\"mr-2 h-4 w-4\" />\n                  Rename\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setMoveFolderOpen(true);\n                  }}\n                >\n                  <FolderInputIcon className=\"mr-2 h-4 w-4\" />\n                  Move to folder\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setAddDataRoomOpen(true);\n                  }}\n                >\n                  <BetweenHorizontalStartIcon className=\"mr-2 h-4 w-4\" />\n                  Copy to other dataroom\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    setGroupPermissionOpen(true);\n                  }}\n                >\n                  <FileSlidersIcon className=\"mr-2 h-4 w-4\" />\n                  Set Group Permissions\n                </DropdownMenuItem>\n                <DropdownMenuSeparator />\n\n                <DropdownMenuItem\n                  onClick={(event) =>\n                    handleButtonClick(event, dataroomDocument.id)\n                  }\n                  className=\"text-destructive duration-200 focus:bg-destructive focus:text-destructive-foreground\"\n                >\n                  {isFirstClick ? (\n                    \"Really remove?\"\n                  ) : (\n                    <>\n                      <ArchiveXIcon className=\"mr-2 h-4 w-4\" /> Remove document\n                    </>\n                  )}\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        </div>\n\n        {[\"pdf\", \"docs\", \"slides\", \"cad\"].includes(\n          dataroomDocument.document.type,\n        ) &&\n          !dataroomDocument.document.versions?.[0]?.hasPages &&\n          dataroomDocument.document.versions?.[0]?.id && (\n            <FileProcessStatusBar\n              documentVersionId={dataroomDocument.document.versions[0].id}\n              className=\"rounded-b-lg border-t border-gray-200 dark:border-gray-700\"\n              mutateDocument={() => {\n                setIsProcessing(false);\n                mutate(\n                  `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents`,\n                );\n              }}\n              onProcessingChange={(processing) => setIsProcessing(processing)}\n            />\n          )}\n      </div>\n      {renameOpen ? (\n        <EditDataroomDocumentModal\n          open={renameOpen}\n          setOpen={setRenameOpen}\n          documentId={dataroomDocument.document.id}\n          documentName={dataroomDocument.document.name}\n          dataroomId={dataroomId}\n        />\n      ) : null}\n      {addDataRoomOpen ? (\n        <AddToDataroomModal\n          open={addDataRoomOpen}\n          setOpen={setAddDataRoomOpen}\n          documentId={dataroomDocument.document.id}\n          documentName={dataroomDocument.document.name}\n          dataroomId={dataroomId}\n        />\n      ) : null}\n      {moveFolderOpen ? (\n        <MoveToDataroomFolderModal\n          open={moveFolderOpen}\n          setOpen={setMoveFolderOpen}\n          dataroomId={dataroomDocument.dataroomId}\n          documentIds={[dataroomDocument.id]}\n          itemName={dataroomDocument.document.name}\n          folderIds={[]}\n        />\n      ) : null}\n      {groupPermissionOpen ? (\n        <SetUnifiedPermissionsModal\n          open={groupPermissionOpen}\n          setOpen={setGroupPermissionOpen}\n          dataroomId={dataroomId}\n          uploadedFiles={[\n            {\n              documentId: dataroomDocument.id,\n              dataroomDocumentId: dataroomDocument.id,\n              fileName: dataroomDocument.document.name,\n            },\n          ]}\n        />\n      ) : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/dataroom-header.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { BellRingIcon } from \"lucide-react\";\n\nimport { useDataroom, useDataroomLinks } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomLinkSheet } from \"@/components/links/link-sheet/dataroom-link-sheet\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nexport const DataroomHeader = ({\n  title,\n  description,\n  internalName,\n  actions,\n}: {\n  title: string;\n  description: string;\n  internalName?: string | null;\n  actions?: React.ReactNode[];\n}) => {\n  const [isLinkSheetOpen, setIsLinkSheetOpen] = useState<boolean>(false);\n  const { dataroom } = useDataroom();\n  const { links } = useDataroomLinks();\n\n  const actionRows: React.ReactNode[][] = [];\n  if (actions) {\n    for (let i = 0; i < actions.length; i += 3) {\n      actionRows.push(actions.slice(i, i + 3));\n    }\n  }\n\n  return (\n    <section className=\"mb-4\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex min-h-10 items-center gap-x-2 space-y-1\">\n          <div>\n            <h1 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n              {internalName || title}\n            </h1>\n            {internalName && (\n              <p className=\"text-sm text-muted-foreground\">{title}</p>\n            )}\n          </div>\n          {dataroom?.enableChangeNotifications ? (\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Link\n                  href={`/datarooms/${dataroom?.id}/settings/notifications`}\n                >\n                  <Button variant=\"ghost\" size=\"icon\" className=\"size-8\">\n                    <BellRingIcon className=\"inline-block !size-4 text-[#fb7a00]\" />\n                  </Button>\n                </Link>\n              </TooltipTrigger>\n              <TooltipPortal>\n                <TooltipContent\n                  side=\"right\"\n                  className=\"text-center text-muted-foreground\"\n                >\n                  <p>Change notifications are enabled</p>\n                </TooltipContent>\n              </TooltipPortal>\n            </Tooltip>\n          ) : null}\n        </div>\n        <div>\n          <Button onClick={() => setIsLinkSheetOpen(true)} key={1}>\n            Share\n          </Button>\n        </div>\n        <DataroomLinkSheet\n          linkType={\"DATAROOM_LINK\"}\n          isOpen={isLinkSheetOpen}\n          setIsOpen={setIsLinkSheetOpen}\n          existingLinks={links}\n        />\n      </div>\n    </section>\n  );\n};\n"
  },
  {
    "path": "components/datarooms/dataroom-items-list.tsx",
    "content": "import { Fragment, memo, useCallback, useMemo, useState } from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  DndContext,\n  DragEndEvent,\n  DragOverEvent,\n  DragOverlay,\n  DragStartEvent,\n  MeasuringStrategy,\n  MouseSensor,\n  PointerSensor,\n  TouchSensor,\n  UniqueIdentifier,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\";\nimport { DefaultPermissionStrategy } from \"@prisma/client\";\nimport {\n  ArchiveXIcon,\n  FileIcon,\n  FolderIcon,\n  FolderInputIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\n\nimport { moveDataroomDocumentToFolder } from \"@/lib/documents/move-dataroom-documents\";\nimport { moveDataroomFolderToFolder } from \"@/lib/documents/move-dataroom-folders\";\nimport { useDataroomPermissions } from \"@/lib/hooks/use-dataroom-permissions\";\nimport {\n  DataroomFolderDocument,\n  DataroomFolderWithCount,\n  useDataroom,\n} from \"@/lib/swr/use-dataroom\";\nimport useDataroomGroups from \"@/lib/swr/use-dataroom-groups\";\nimport useDataroomPermissionGroups from \"@/lib/swr/use-dataroom-permission-groups\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nimport { useRemoveDataroomItemsModal } from \"@/components/datarooms/actions/remove-document-modal\";\nimport DataroomDocumentCard from \"@/components/datarooms/dataroom-document-card\";\nimport { SetUnifiedPermissionsModal } from \"@/components/datarooms/groups/set-unified-permissions-modal\";\nimport { useDeleteFolderModal } from \"@/components/documents/actions/delete-folder-modal\";\nimport { DraggableItem } from \"@/components/documents/drag-and-drop/draggable-item\";\nimport { DroppableFolder } from \"@/components/documents/drag-and-drop/droppable-folder\";\nimport { EmptyDocuments } from \"@/components/documents/empty-document\";\nimport FolderCard from \"@/components/documents/folder-card\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Portal } from \"@/components/ui/portal\";\nimport { ButtonTooltip } from \"@/components/ui/tooltip\";\nimport { UploadNotificationDrawer } from \"@/components/upload-notification\";\nimport UploadZone, {\n  RejectedFile,\n  UploadState,\n} from \"@/components/upload-zone\";\n\nimport { itemsMessage } from \"./folders/utils\";\nimport { MoveToDataroomFolderModal } from \"./move-dataroom-folder-modal\";\n\ntype FolderOrDocument =\n  | (DataroomFolderWithCount & { itemType: \"folder\" })\n  | (DataroomFolderDocument & { itemType: \"document\" });\n\nexport function DataroomItemsList({\n  mixedItems,\n  teamInfo,\n  folderPathName,\n  dataroomId,\n  folderCount,\n  documentCount,\n}: {\n  mixedItems: FolderOrDocument[] | [];\n  teamInfo: TeamContextType | null;\n  folderPathName?: string[];\n  dataroomId: string;\n  folderCount: number;\n  documentCount: number;\n}) {\n  const { viewerGroups } = useDataroomGroups();\n  const { permissionGroups } = useDataroomPermissionGroups();\n  const { dataroom } = useDataroom();\n  const { isMobile } = useMediaQuery();\n  const { applyPermissions } = useDataroomPermissions();\n\n  const [uploads, setUploads] = useState<UploadState[]>([]);\n  const [rejectedFiles, setRejectedFiles] = useState<RejectedFile[]>([]);\n  const [showGroupPermissions, setShowGroupPermissions] = useState(false);\n  const [uploadedFiles, setUploadedFiles] = useState<\n    {\n      documentId: string;\n      dataroomDocumentId: string;\n      fileName: string;\n    }[]\n  >([]);\n\n  const [showDrawer, setShowDrawer] = useState(false);\n  const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);\n\n  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });\n  // forDoc\n  const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);\n  const [draggedDocument, setDraggedDocument] =\n    useState<FolderOrDocument | null>(null);\n\n  // forFolder\n  const [selectedFolders, setSelectedFolders] = useState<string[]>([]);\n  const [draggedFolder, setDraggedFolder] = useState<FolderOrDocument | null>(\n    null,\n  );\n  const [parentFolderId, setParentFolderId] = useState<string>(\"\");\n  const [isOverFolder, setIsOverFolder] = useState<boolean>(false);\n  const [isDragging, setIsDragging] = useState<boolean>(false);\n\n  const { setDeleteModalOpen, setFolderToDelete, DeleteFolderModal } =\n    useDeleteFolderModal(teamInfo, true, dataroomId);\n\n  const handleDeleteFolder = useCallback(\n    (folderId: string) => {\n      const folderToDelete = mixedItems.find(\n        (f) => f.id === folderId && f.itemType === \"folder\",\n      );\n      if (folderToDelete && folderToDelete.itemType === \"folder\") {\n        const { itemType, ...folder } = folderToDelete;\n        setFolderToDelete(folder);\n        setDeleteModalOpen(true);\n        setSelectedFolders((prev) => prev.filter((id) => id !== folderId));\n      }\n    },\n    [mixedItems, setFolderToDelete, setDeleteModalOpen, setSelectedFolders],\n  );\n\n  const handleCloseDrawer = () => {\n    setShowDrawer(false);\n  };\n\n  const { setShowRemoveDataroomItemModal, RemoveDataroomItemModal } =\n    useRemoveDataroomItemsModal({\n      documentIds: selectedDocuments,\n      setSelectedDocuments: setSelectedDocuments,\n      dataroomId,\n      folderIds: selectedFolders,\n      setSelectedFolders,\n    });\n\n  const sensors = useSensors(\n    useSensor(MouseSensor),\n    useSensor(TouchSensor),\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 10,\n      },\n    }),\n  );\n\n  const selectedDocumentsLength = useMemo(\n    () => selectedDocuments && selectedDocuments.length,\n    [selectedDocuments],\n  );\n\n  const selectedFoldersLength = useMemo(\n    () => selectedFolders && selectedFolders.length,\n    [selectedFolders],\n  );\n\n  const handleSelect = useCallback(\n    (id: string, type: \"document\" | \"folder\") => {\n      if (type === \"folder\") {\n        setSelectedFolders((prev) =>\n          prev.includes(id)\n            ? prev.filter((docId) => docId !== id)\n            : [...prev, id],\n        );\n      } else {\n        setSelectedDocuments((prev) =>\n          prev.includes(id)\n            ? prev.filter((docId) => docId !== id)\n            : [...prev, id],\n        );\n      }\n    },\n    [],\n  );\n  const handleDragForType = useCallback(\n    (\n      itemId: string,\n      items: { id: string }[],\n      setDraggedItem: (item: any) => void,\n      selectedItems: string[],\n      setSelectedItems: (items: string[]) => void,\n    ) => {\n      if (!items.length) return;\n\n      const draggedItem = items.find((item) => item.id === itemId) ?? null;\n      setDraggedItem(draggedItem);\n\n      const itemIndex = items.findIndex((item) => item.id === itemId);\n      const isSelected = selectedItems.includes(itemId);\n\n      let yOffset = 0;\n      if (isSelected) {\n        const firstSelectedIndex = items.findIndex((item) =>\n          selectedItems.includes(item.id),\n        );\n        yOffset = (itemIndex - firstSelectedIndex) * 80; // Adjust height accordingly\n      }\n\n      setDragOffset({ x: 0, y: yOffset });\n\n      if (!isSelected) {\n        setSelectedItems([...selectedItems, itemId]);\n      }\n    },\n    [],\n  );\n  const handleDragStart = useCallback(\n    (event: DragStartEvent) => {\n      setIsDragging(true);\n      setParentFolderId(event.active.data.current?.parentFolderId);\n      const { type } = event.active.data.current ?? {};\n      const itemId = event.active.id as string;\n\n      if (!type) return;\n\n      const isDocument = type === \"document\";\n      const filteredItems = mixedItems.filter((item) => item.itemType === type);\n\n      handleDragForType(\n        itemId,\n        filteredItems,\n        isDocument ? setDraggedDocument : setDraggedFolder,\n        isDocument ? selectedDocuments : selectedFolders,\n        isDocument ? setSelectedDocuments : setSelectedFolders,\n      );\n    },\n    [\n      mixedItems,\n      setIsDragging,\n      setDraggedDocument,\n      setDraggedFolder,\n      selectedDocuments,\n      selectedFolders,\n      setSelectedDocuments,\n      setSelectedFolders,\n      setParentFolderId,\n      handleDragForType,\n    ],\n  );\n\n  const handleDragOver = (event: DragOverEvent) => {\n    const { over } = event;\n\n    if (!over) return;\n\n    const overType = over.data.current?.type;\n    if (overType === \"folder\") {\n      setIsOverFolder(true);\n    } else {\n      setIsOverFolder(false);\n    }\n  };\n\n  const moveDocumentsAndFolders = async ({\n    documentsToMove,\n    foldersToMove,\n    overId,\n    folderPathName,\n    teamId,\n    selectedFolderPath,\n  }: {\n    documentsToMove: string[];\n    foldersToMove: string[];\n    overId: UniqueIdentifier;\n    folderPathName: string[] | undefined;\n    teamId: string;\n    selectedFolderPath: string;\n  }) => {\n    return new Promise(async (resolve, reject) => {\n      try {\n        if (documentsToMove && documentsToMove.length > 0) {\n          await moveDataroomDocumentToFolder({\n            documentIds: documentsToMove,\n            folderId: overId.toString(),\n            folderPathName,\n            dataroomId,\n            teamId: teamId,\n            folderIds: foldersToMove,\n          });\n        }\n        if (foldersToMove && foldersToMove.length > 0) {\n          await moveDataroomFolderToFolder({\n            folderIds: foldersToMove,\n            folderPathName: folderPathName ? folderPathName : undefined,\n            teamId: teamId,\n            selectedFolder: overId.toString(),\n            dataroomId: dataroomId,\n            selectedFolderPath: selectedFolderPath,\n          });\n        }\n\n        resolve(\"Successfully moved documents and folders.\");\n      } catch (error) {\n        reject(\n          error instanceof Error\n            ? error.message\n            : \"Failed to move documents and folders.\",\n        );\n      }\n    });\n  };\n\n  const handleDragEnd = async (event: DragEndEvent) => {\n    setIsDragging(false);\n    const { active, over } = event;\n\n    setDraggedDocument(null);\n    setDraggedFolder(null);\n\n    if (!over) return;\n\n    const activeId = active.id;\n    const overId = over.id;\n    if (selectedFolders.includes(overId.toString())) {\n      return toast.error(\n        \"Can not move folder and documents into selected folders\",\n      );\n    }\n    const isActiveADocument = active.data.current?.type === \"document\";\n    const isActiveAFolder = active.data.current?.type === \"folder\";\n    const isOverAFolder = over.data.current?.type === \"folder\";\n    if (activeId === overId) return;\n    if (isActiveADocument && !isOverAFolder) return;\n    if (isActiveAFolder && !isOverAFolder) return;\n\n    // Move the document(s) to the new folder\n    const documentsToMove =\n      selectedDocumentsLength > 0 ? selectedDocuments : [];\n    const foldersToMove = selectedFoldersLength > 0 ? selectedFolders : [];\n    toast.promise(\n      moveDocumentsAndFolders({\n        documentsToMove: documentsToMove,\n        foldersToMove: foldersToMove,\n        overId: overId,\n        folderPathName: folderPathName,\n        teamId: teamInfo?.currentTeam?.id!,\n        selectedFolderPath: over.data.current?.path,\n      }),\n      {\n        loading: itemsMessage(documentsToMove, foldersToMove, \"Moving\"),\n        success: () =>\n          itemsMessage(documentsToMove, foldersToMove, \"Successfully moved\"),\n        error: (err) => err,\n      },\n    );\n    setSelectedDocuments([]);\n    setSelectedFolders([]);\n    setIsOverFolder(false);\n  };\n\n  const renderItem = (item: FolderOrDocument) => {\n    const itemId = `${item.itemType}-${item.id}`;\n\n    if (isMobile) {\n      return (\n        <Fragment key={itemId}>\n          {item.itemType === \"folder\" ? (\n            <FolderCard\n              key={itemId}\n              folder={item}\n              teamInfo={teamInfo}\n              isDataroom={!!dataroomId}\n              dataroomId={dataroomId}\n              onDelete={handleDeleteFolder}\n            />\n          ) : (\n            <DataroomDocumentCard\n              key={itemId}\n              document={item as DataroomFolderDocument}\n              teamInfo={teamInfo}\n              dataroomId={dataroomId}\n            />\n          )}\n        </Fragment>\n      );\n    }\n\n    return (\n      <Fragment key={itemId}>\n        {item.itemType === \"folder\" ? (\n          <DroppableFolder\n            key={itemId}\n            id={item.id}\n            disabledFolder={selectedFolders}\n            path={item.path}\n          >\n            <DraggableItem\n              key={item.id}\n              id={item.id}\n              isSelected={selectedFolders.includes(item.id)}\n              onSelect={(id, type) => {\n                handleSelect(id, type);\n              }}\n              isDraggingSelected={isDragging}\n              type=\"folder\"\n            >\n              <FolderCard\n                folder={item}\n                teamInfo={teamInfo}\n                isDataroom={!!dataroomId}\n                dataroomId={dataroomId}\n                isSelected={selectedFolders.includes(item.id)}\n                isDragging={isDragging && selectedFolders.includes(item.id)}\n                onDelete={handleDeleteFolder}\n              />\n            </DraggableItem>\n          </DroppableFolder>\n        ) : (\n          <DraggableItem\n            key={itemId}\n            id={item.id}\n            isSelected={selectedDocuments.includes(item.id)}\n            onSelect={(id, type) => {\n              handleSelect(id, type);\n            }}\n            isDraggingSelected={isDragging}\n            type=\"document\"\n          >\n            <DataroomDocumentCard\n              document={item as DataroomFolderDocument}\n              teamInfo={teamInfo}\n              dataroomId={dataroomId}\n              isDragging={isDragging && selectedDocuments.includes(item.id)}\n            />\n          </DraggableItem>\n        )}\n      </Fragment>\n    );\n  };\n  const resetSelection = () => {\n    setSelectedDocuments([]);\n    setSelectedFolders([]);\n  };\n\n  const HeaderContent = memo(() => {\n    if (selectedDocumentsLength > 0 || selectedFoldersLength > 0) {\n      const totalItems = folderCount + documentCount;\n      const isAllSelected =\n        totalItems === selectedDocumentsLength + selectedFoldersLength;\n\n      const handleSelectAll = () => {\n        if (isAllSelected) {\n          resetSelection();\n        } else {\n          const allDocumentIds = mixedItems\n            .filter((item) => item.itemType === \"document\")\n            .map((doc) => doc.id);\n          const allFolderIds = mixedItems\n            .filter((item) => item.itemType === \"folder\")\n            .map((folder) => folder.id);\n          setSelectedDocuments(allDocumentIds);\n          setSelectedFolders(allFolderIds);\n        }\n      };\n\n      return (\n        <div className=\"mb-2 flex items-center gap-x-1 rounded-3xl bg-gray-100 text-sm text-foreground dark:bg-gray-800\">\n          <div className=\"ml-5 flex h-8 w-8 items-center justify-center rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\">\n            <ButtonTooltip\n              content={isAllSelected ? \"Deselect all\" : \"Select all\"}\n            >\n              <Checkbox\n                id=\"select-all\"\n                checked={isAllSelected}\n                onCheckedChange={handleSelectAll}\n                className=\"h-5 w-5\"\n                aria-label={isAllSelected ? \"Deselect all\" : \"Select all\"}\n              />\n            </ButtonTooltip>\n          </div>\n          <ButtonTooltip content=\"Clear selection\">\n            <Button\n              onClick={resetSelection}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <XIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n          {selectedDocumentsLength ? (\n            <div className=\"mr-2 tabular-nums\">\n              {selectedDocumentsLength} document\n              {selectedDocumentsLength > 1 ? \"s\" : \"\"} selected\n            </div>\n          ) : null}\n          {selectedFoldersLength ? (\n            <div className=\"mr-2 tabular-nums\">\n              {selectedFoldersLength} folder\n              {selectedFoldersLength > 1 ? \"s\" : \"\"} selected\n            </div>\n          ) : null}\n          <ButtonTooltip content=\"Move\">\n            <Button\n              onClick={() => setMoveFolderOpen(true)}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <FolderInputIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n          <ButtonTooltip content=\"Remove\">\n            <Button\n              onClick={() => setShowRemoveDataroomItemModal(true)}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-destructive hover:text-destructive-foreground\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <ArchiveXIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n        </div>\n      );\n    } else {\n      return (\n        <div className=\"mb-2 flex min-h-10 items-center gap-x-2\">\n          {folderCount > 0 ? (\n            <p className=\"flex items-center gap-x-1 text-sm text-gray-400\">\n              <FolderIcon className=\"h-5 w-5\" />\n              <span>\n                {folderCount} folder{folderCount > 1 ? \"s\" : \"\"}\n              </span>\n            </p>\n          ) : null}\n          {documentCount > 0 ? (\n            <p className=\"flex items-center gap-x-1 text-sm text-gray-400\">\n              <FileIcon className=\"h-5 w-5\" />\n              <span>\n                {documentCount} document{documentCount > 1 ? \"s\" : \"\"}\n              </span>\n            </p>\n          ) : null}\n        </div>\n      );\n    }\n  });\n  HeaderContent.displayName = \"HeaderContent\";\n\n  const handleUploadSuccess = (\n    files: {\n      fileName: string;\n      documentId: string;\n      dataroomDocumentId: string;\n    }[],\n  ) => {\n    // Check if there are any groups to apply permissions to\n    const hasAnyGroups =\n      (viewerGroups && viewerGroups.length > 0) ||\n      (permissionGroups && permissionGroups.length > 0);\n\n    if (!hasAnyGroups) return;\n\n    const documentIds = files.map((file) => file.documentId);\n    const strategy =\n      dataroom?.defaultPermissionStrategy ||\n      DefaultPermissionStrategy.INHERIT_FROM_PARENT;\n\n    if (strategy === DefaultPermissionStrategy.ASK_EVERY_TIME) {\n      setShowGroupPermissions(true);\n      setUploadedFiles(files);\n    } else if (strategy === DefaultPermissionStrategy.INHERIT_FROM_PARENT) {\n      const isRootLevel = !folderPathName || folderPathName.length === 0;\n\n      applyPermissions(\n        dataroomId,\n        documentIds,\n        \"INHERIT_FROM_PARENT\",\n        isRootLevel ? undefined : folderPathName?.join(\"/\"),\n        (message: string) => toast.error(message),\n      ).catch((error: any) => {\n        console.error(\"Failed to apply permissions:\", error);\n        toast.error(\"Failed to apply permissions\");\n      });\n    }\n    // strategy === DefaultPermissionStrategy.HIDDEN_BY_DEFAULT - do nothing\n  };\n\n  return (\n    <>\n      <UploadZone\n        folderPathName={folderPathName?.join(\"/\")}\n        onUploadStart={(newUploads) => {\n          setUploads((prevUploads) => [...prevUploads, ...newUploads]);\n          setShowDrawer(true);\n        }}\n        onUploadProgress={(index, progress, documentId) => {\n          setUploads((prevUploads) => {\n            const recentBatchStartIndex = prevUploads.length - index - 1;\n            if (\n              recentBatchStartIndex < 0 ||\n              recentBatchStartIndex >= prevUploads.length\n            ) {\n              return prevUploads;\n            }\n            return prevUploads.map((upload, i) =>\n              i === recentBatchStartIndex\n                ? { ...upload, progress, documentId }\n                : upload,\n            );\n          });\n        }}\n        onUploadSuccess={handleUploadSuccess}\n        onUploadRejected={(rejected) => {\n          setRejectedFiles((prevRejected) => [...prevRejected, ...rejected]);\n          setShowDrawer(true);\n        }}\n        setUploads={setUploads}\n        setRejectedFiles={setRejectedFiles}\n        dataroomId={dataroomId}\n        dataroomName={dataroom?.name}\n      >\n        {isMobile ? (\n          <div>\n            <ul role=\"list\" className=\"space-y-4\">\n              {mixedItems.map((item) => (\n                <li key={`${item.itemType}-${item.id}`}>{renderItem(item)}</li>\n              ))}\n            </ul>\n            <Portal containerId={\"documents-header-count\"}>\n              <HeaderContent />\n            </Portal>\n            {mixedItems.length === 0 && (\n              <div className=\"flex h-full justify-center\">\n                <EmptyDocuments isDataroom={true} />\n              </div>\n            )}\n          </div>\n        ) : (\n          <>\n            <DndContext\n              sensors={sensors}\n              onDragStart={handleDragStart}\n              onDragOver={handleDragOver}\n              onDragEnd={handleDragEnd}\n              onDragCancel={() => setIsOverFolder(false)}\n              measuring={{\n                droppable: {\n                  strategy: MeasuringStrategy.Always,\n                },\n              }}\n            >\n              <ul role=\"list\" className=\"space-y-4\">\n                {mixedItems.map((item) => (\n                  <li key={`${item.itemType}-${item.id}`}>\n                    {renderItem(item)}\n                  </li>\n                ))}\n              </ul>\n\n              <Portal>\n                <DragOverlay className=\"cursor-default\">\n                  <motion.div\n                    initial={{ scale: 1, opacity: 1 }}\n                    animate={{ scale: 0.9, opacity: 0.95 }}\n                    exit={{ scale: 1, opacity: 1 }}\n                    transition={{ duration: 0.2 }}\n                    className=\"relative\"\n                    style={{ transform: `translateY(${dragOffset.y}px)` }}\n                  >\n                    {draggedDocument ? (\n                      <DataroomDocumentCard\n                        document={draggedDocument as DataroomFolderDocument}\n                        teamInfo={teamInfo}\n                        dataroomId={dataroomId}\n                      />\n                    ) : null}\n                    {draggedFolder && draggedFolder.itemType === \"folder\" ? (\n                      <FolderCard\n                        folder={draggedFolder}\n                        teamInfo={teamInfo}\n                        isDataroom={!!dataroomId}\n                        dataroomId={dataroomId}\n                        onDelete={handleDeleteFolder}\n                      />\n                    ) : null}\n                    {selectedDocumentsLength + selectedFoldersLength > 1 ? (\n                      <div className=\"absolute -right-4 -top-4 rounded-full border border-border bg-foreground px-4 py-2\">\n                        <span className=\"text-sm font-semibold text-background\">\n                          {selectedDocumentsLength + selectedFoldersLength}\n                        </span>\n                      </div>\n                    ) : null}\n                  </motion.div>\n                </DragOverlay>\n              </Portal>\n\n              <Portal containerId={\"documents-header-count\"}>\n                <HeaderContent />\n              </Portal>\n\n              {mixedItems.length === 0 && (\n                <div className=\"flex h-full justify-center\">\n                  <EmptyDocuments isDataroom={true} />\n                </div>\n              )}\n            </DndContext>\n            {moveFolderOpen ? (\n              <MoveToDataroomFolderModal\n                open={moveFolderOpen}\n                setOpen={setMoveFolderOpen}\n                setSelectedDocuments={setSelectedDocuments}\n                documentIds={selectedDocuments}\n                dataroomId={dataroomId}\n                folderIds={selectedFolders}\n                folderParentId={parentFolderId}\n                setSelectedFoldersId={setSelectedFolders}\n              />\n            ) : null}\n            <RemoveDataroomItemModal />\n          </>\n        )}\n      </UploadZone>\n      {showDrawer ? (\n        <UploadNotificationDrawer\n          open={showDrawer}\n          onOpenChange={setShowDrawer}\n          uploads={uploads}\n          handleCloseDrawer={handleCloseDrawer}\n          setUploads={setUploads}\n          rejectedFiles={rejectedFiles}\n          setRejectedFiles={setRejectedFiles}\n        />\n      ) : null}\n\n      {showGroupPermissions && dataroomId && (\n        <SetUnifiedPermissionsModal\n          open={showGroupPermissions}\n          setOpen={setShowGroupPermissions}\n          dataroomId={dataroomId}\n          uploadedFiles={uploadedFiles}\n          onComplete={() => {\n            setShowGroupPermissions(false);\n            setUploadedFiles([]);\n          }}\n        />\n      )}\n      <DeleteFolderModal />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/dataroom-navigation.tsx",
    "content": "import useLimits from \"@/lib/swr/use-limits\";\n\nimport { NavMenu } from \"../navigation-menu\";\n\nexport const DataroomNavigation = ({ dataroomId }: { dataroomId?: string }) => {\n  const { limits } = useLimits();\n  if (!dataroomId) {\n    return null;\n  }\n  return (\n    <NavMenu\n      navigation={[\n        {\n          label: \"Data Room\",\n          href: `/datarooms/${dataroomId}/documents`,\n          segment: \"documents\",\n        },\n        {\n          label: \"Permissions\",\n          href: `/datarooms/${dataroomId}/permissions`,\n          segment: \"permissions\",\n        },\n        {\n          label: \"Analytics\",\n          href: `/datarooms/${dataroomId}/analytics`,\n          segment: \"analytics\",\n        },\n        {\n          label: \"Q&A\",\n          href: `/datarooms/${dataroomId}/conversations`,\n          segment: \"conversations\",\n          limited: !limits?.conversationsInDataroom,\n        },\n        {\n          label: \"Branding\",\n          href: `/datarooms/${dataroomId}/branding`,\n          segment: \"branding\",\n        },\n        {\n          label: \"Settings\",\n          href: `/datarooms/${dataroomId}/settings`,\n          segment: \"settings\",\n        },\n      ]}\n    />\n  );\n};\n"
  },
  {
    "path": "components/datarooms/dataroom-trial-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { E164Number } from \"libphonenumber-js\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport { PhoneInput } from \"../ui/phone-input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"../ui/select\";\n\nexport function DataroomTrialModal({\n  children,\n  openModal = false,\n  setOpenModal,\n}: {\n  children?: React.ReactNode;\n  openModal?: boolean;\n  setOpenModal?: React.Dispatch<React.SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n\n  const [industry, setIndustry] = useState<string>(\"\");\n  const [companySize, setCompanySize] = useState<string>(\"\");\n  const [name, setName] = useState<string>(\"\");\n  const [companyName, setCompanyName] = useState<string>(\"\");\n  const [phoneNumber, setPhoneNumber] = useState<E164Number | null>(null);\n  const [loading, setLoading] = useState<boolean>(false);\n  const [open, setOpen] = useState<boolean>(openModal);\n\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n\n  // Helper function to convert industry to proper dataroom name\n  const getDataroomName = (industryValue: string) => {\n    const industryNames: Record<string, string> = {\n      \"finance-banking\": \"Finance and Banking Data Room\",\n      legal: \"Legal Data Room\",\n      \"real-estate\": \"Real Estate Data Room\",\n      technology: \"Technology Data Room\",\n      pharmaceuticals: \"Pharmaceuticals Data Room\",\n      energy: \"Energy Data Room\",\n      manufacturing: \"Manufacturing Data Room\",\n      healthcare: \"Healthcare Data Room\",\n      consulting: \"Consulting and Professional Services Data Room\",\n      government: \"Government and Public Sector Data Room\",\n      entertainment: \"Entertainment and Media Data Room\",\n      other: \"Data Room\",\n    };\n\n    return industryNames[industryValue] || \"Data Room\";\n  };\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!name || !companyName || !industry || !companySize || !phoneNumber) {\n      toast.error(\"Please fill out all fields.\");\n      return;\n    }\n\n    setLoading(true);\n\n    const dataroomName = getDataroomName(industry);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/trial`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: dataroomName,\n            fullName: name,\n            companyName,\n            industry,\n            companySize,\n            phoneNumber,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message);\n        return;\n      }\n\n      analytics.capture(\"Dataroom Trial Created\", {\n        dataroomName: dataroomName,\n        industry,\n        companySize,\n      });\n      toast.success(\"Dataroom successfully created! 🎉\");\n\n      await Promise.all([\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),\n      ]);\n      router.push(\"/datarooms\");\n    } catch (error) {\n      setLoading(false);\n      toast.error(\"Error adding dataroom. Please try again.\");\n      return;\n    } finally {\n      setLoading(false);\n      setOpen(false);\n      if (openModal && setOpenModal) setOpenModal(false);\n    }\n  };\n\n  const onOpenChange = (open: boolean) => {\n    if (!open) {\n      setOpen(false);\n    } else {\n      setOpen(true);\n    }\n    if (openModal && setOpenModal) setOpenModal(false);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Dataroom Trial for 7 days</DialogTitle>\n          <DialogDescription>No credit card required.</DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit} className=\"space-y-4\">\n          <div className=\"space-y-1\">\n            <Label htmlFor=\"name\" className=\"opacity-80\">\n              Your Full Name\n            </Label>\n            <Input\n              id=\"name\"\n              type=\"text\"\n              autoComplete=\"off\"\n              data-1p-ignore\n              placeholder=\"John Doe\"\n              className=\"mb-4 mt-1 w-full\"\n              onChange={(e) => setName(e.target.value)}\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <Label htmlFor=\"company-name\" className=\"opacity-80\">\n              Company Name\n            </Label>\n            <Input\n              id=\"company-name\"\n              type=\"text\"\n              autoComplete=\"off\"\n              data-1p-ignore\n              placeholder=\"ACME Inc.\"\n              className=\"mb-4 mt-1 w-full\"\n              onChange={(e) => setCompanyName(e.target.value)}\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <Label className=\"opacity-80\">Industry</Label>\n            <Select onValueChange={(value) => setIndustry(value)}>\n              <SelectTrigger>\n                <SelectValue placeholder=\"Select an industry\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"finance-banking\">\n                  Finance and Banking\n                </SelectItem>\n                <SelectItem value=\"legal\">Legal</SelectItem>\n                <SelectItem value=\"real-estate\">Real Estate</SelectItem>\n                <SelectItem value=\"technology\">Technology</SelectItem>\n                <SelectItem value=\"pharmaceuticals\">Pharmaceuticals</SelectItem>\n                <SelectItem value=\"energy\">Energy</SelectItem>\n                <SelectItem value=\"manufacturing\">Manufacturing</SelectItem>\n                <SelectItem value=\"healthcare\">Healthcare</SelectItem>\n                <SelectItem value=\"consulting\">\n                  Consulting and Professional Services\n                </SelectItem>\n                <SelectItem value=\"government\">\n                  Government and Public Sector\n                </SelectItem>\n                <SelectItem value=\"entertainment\">\n                  Entertainment and Media\n                </SelectItem>\n                <SelectItem value=\"other\">Other</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n          <div className=\"space-y-1\">\n            <Label className=\"opacity-80\">Company Size</Label>\n            <Select onValueChange={(value) => setCompanySize(value)}>\n              <SelectTrigger>\n                <SelectValue placeholder=\"Select a company size\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"1\">1 employee</SelectItem>\n                <SelectItem value=\"2-10\">2-10 employees</SelectItem>\n                <SelectItem value=\"11-50\">11-50 employees</SelectItem>\n                <SelectItem value=\"51-200\">51-200 employees</SelectItem>\n                <SelectItem value=\"201-500\">201-500 employees</SelectItem>\n                <SelectItem value=\"501-1000\">501-1,000 employees</SelectItem>\n                <SelectItem value=\"1001-5000\">1,001-5,000 employees</SelectItem>\n                <SelectItem value=\"5001-10000\">\n                  5,001-10,000 employees\n                </SelectItem>\n                <SelectItem value=\"10001+\">10,001+ employees</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n          <div className=\"space-y-1\">\n            <Label className=\"opacity-80\">Phone Number</Label>\n            <PhoneInput\n              placeholder=\"+1 123 456 7890\"\n              onChange={(value) => setPhoneNumber(value)}\n              defaultCountry=\"US\"\n            />\n          </div>\n\n          <DialogFooter>\n            <div className=\"flex flex-col space-y-4\">\n              <Button\n                type=\"submit\"\n                className=\"h-9 w-full\"\n                disabled={\n                  !phoneNumber ||\n                  !companySize ||\n                  !industry ||\n                  !name ||\n                  !companyName\n                }\n                loading={loading}\n              >\n                Access your data room\n              </Button>\n\n              <div className=\"text-xs text-muted-foreground\">\n                After the trial, upgrade to{\" \"}\n                <UpgradePlanModal clickedPlan={PlanEnum.Business}>\n                  <button className=\"underline\">Papermark Business</button>\n                </UpgradePlanModal>{\" \"}\n                to continue using data rooms.\n              </div>\n            </div>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/download-progress-modal.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport {\n  AlertCircle,\n  CheckCircle2,\n  ChevronDown,\n  ChevronUp,\n  Download,\n  FileArchive,\n  Loader2,\n  Plus,\n  XCircle,\n} from \"lucide-react\";\n\nimport { DownloadJob } from \"@/lib/redis-download-job-store\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Progress } from \"@/components/ui/progress\";\n\nexport interface DownloadJobStatus {\n  id: string;\n  status: \"PENDING\" | \"PROCESSING\" | \"COMPLETED\" | \"FAILED\";\n  progress: number;\n  totalFiles: number;\n  processedFiles: number;\n  downloadUrls?: string[];\n  error?: string;\n  isReady: boolean;\n  dataroomName: string;\n  createdAt: string;\n  completedAt?: string;\n  expiresAt?: string;\n}\n\ninterface DownloadProgressModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  jobId: string | null;\n  dataroomName?: string;\n  // For team member downloads (uses next-auth session)\n  teamId: string;\n  dataroomId: string;\n}\n\nexport function DownloadProgressModal({\n  isOpen,\n  onClose,\n  jobId,\n  dataroomName,\n  teamId,\n  dataroomId,\n}: DownloadProgressModalProps) {\n  const [status, setStatus] = useState<DownloadJobStatus | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [isPolling, setIsPolling] = useState(false);\n  const [existingDownloads, setExistingDownloads] = useState<DownloadJob[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [showNewDownload, setShowNewDownload] = useState(false);\n  const [isStartingDownload, setIsStartingDownload] = useState(false);\n  const [expandedDownloadId, setExpandedDownloadId] = useState<string | null>(\n    null,\n  );\n  const [downloadProgress, setDownloadProgress] = useState<{\n    downloadId: string;\n    current: number;\n    total: number;\n  } | null>(null);\n  const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n  // Cleanup interval on component unmount\n  useEffect(() => {\n    return () => {\n      if (pollIntervalRef.current) {\n        clearInterval(pollIntervalRef.current);\n      }\n    };\n  }, []);\n\n  // Fetch existing downloads when modal opens\n  useEffect(() => {\n    if (!isOpen || !teamId || !dataroomId) return;\n\n    const fetchExistingDownloads = async () => {\n      try {\n        setLoading(true);\n\n        const endpoint = `/api/teams/${teamId}/datarooms/${dataroomId}/download/jobs`;\n\n        const response = await fetch(endpoint, {\n          method: \"GET\",\n          credentials: \"include\",\n        });\n\n        if (response.ok) {\n          const downloads = await response.json();\n          setExistingDownloads(downloads);\n\n          // If we have a current jobId, show the new download view\n          if (jobId) {\n            setShowNewDownload(true);\n          }\n        } else {\n          console.error(\"Failed to fetch existing downloads\");\n        }\n      } catch (error) {\n        console.error(\"Error fetching existing downloads:\", error);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchExistingDownloads();\n  }, [isOpen, dataroomId, teamId, jobId]);\n\n  const fetchStatus = useCallback(\n    async (statusJobId: string) => {\n      if (!statusJobId || !teamId || !dataroomId) return;\n\n      try {\n        const url = `/api/teams/${teamId}/datarooms/${dataroomId}/download/${statusJobId}`;\n\n        const response = await fetch(url, {\n          credentials: \"include\",\n        });\n        if (!response.ok) {\n          const errorData = await response.json().catch(() => ({}));\n          throw new Error(errorData.error || \"Failed to fetch download status\");\n        }\n        const data = await response.json();\n        setStatus(data);\n        setError(null);\n\n        // Stop polling when job is completed or failed\n        if (data.status === \"COMPLETED\" || data.status === \"FAILED\") {\n          setIsPolling(false);\n          if (pollIntervalRef.current) {\n            clearInterval(pollIntervalRef.current);\n            pollIntervalRef.current = null;\n          }\n        }\n      } catch (err) {\n        setError(err instanceof Error ? err.message : \"An error occurred\");\n        setIsPolling(false);\n      }\n    },\n    [teamId, dataroomId],\n  );\n\n  // Start polling when we have a jobId and showNewDownload is true\n  useEffect(() => {\n    if (isOpen && jobId && showNewDownload) {\n      setIsPolling(true);\n      setStatus(null);\n      setError(null);\n      fetchStatus(jobId);\n\n      // Start polling interval\n      pollIntervalRef.current = setInterval(() => fetchStatus(jobId), 2000);\n    }\n\n    return () => {\n      if (pollIntervalRef.current) {\n        clearInterval(pollIntervalRef.current);\n        pollIntervalRef.current = null;\n      }\n    };\n  }, [isOpen, jobId, showNewDownload, fetchStatus]);\n\n  const startNewDownload = async () => {\n    if (!teamId || !dataroomId) {\n      setError(\"Missing required parameters to start download\");\n      return;\n    }\n\n    setIsStartingDownload(true);\n    setShowNewDownload(true);\n\n    try {\n      const endpoint = `/api/teams/${teamId}/datarooms/${dataroomId}/download/bulk`;\n\n      const response = await fetch(endpoint, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({}),\n      });\n\n      const data = await response.json();\n\n      if (!response.ok) {\n        throw new Error(data.error || \"Failed to start download\");\n      }\n\n      if (data.jobId) {\n        // Start polling for this job\n        setStatus({\n          id: data.jobId,\n          status: data.status || \"PENDING\",\n          progress: 0,\n          totalFiles: 0,\n          processedFiles: 0,\n          isReady: false,\n          dataroomName: dataroomName || \"\",\n          createdAt: new Date().toISOString(),\n        });\n        setIsPolling(true);\n\n        const statusUrl = `/api/teams/${teamId}/datarooms/${dataroomId}/download/${data.jobId}`;\n\n        pollIntervalRef.current = setInterval(async () => {\n          const statusResponse = await fetch(statusUrl, {\n            credentials: \"include\",\n          });\n          if (statusResponse.ok) {\n            const statusData = await statusResponse.json();\n            setStatus(statusData);\n\n            if (\n              statusData.status === \"COMPLETED\" ||\n              statusData.status === \"FAILED\"\n            ) {\n              setIsPolling(false);\n              if (pollIntervalRef.current) {\n                clearInterval(pollIntervalRef.current);\n                pollIntervalRef.current = null;\n              }\n            }\n          }\n        }, 2000);\n      }\n    } catch (err) {\n      setError(err instanceof Error ? err.message : \"An error occurred\");\n      setShowNewDownload(false);\n    } finally {\n      setIsStartingDownload(false);\n    }\n  };\n\n  const handleDownload = (url: string) => {\n    const link = document.createElement(\"a\");\n    link.href = url;\n    link.rel = \"noopener noreferrer\";\n    document.body.appendChild(link);\n    link.click();\n    setTimeout(() => {\n      document.body.removeChild(link);\n    }, 100);\n  };\n\n  const handleDownloadAll = async (downloadId: string, urls: string[]) => {\n    if (downloadProgress) return;\n    setDownloadProgress({ downloadId, current: 0, total: urls.length });\n    for (let i = 0; i < urls.length; i++) {\n      setDownloadProgress({ downloadId, current: i + 1, total: urls.length });\n      handleDownload(urls[i]);\n      if (i < urls.length - 1) {\n        await new Promise((resolve) => setTimeout(resolve, 2000));\n      }\n    }\n    setDownloadProgress(null);\n  };\n\n  const handleClose = () => {\n    if (pollIntervalRef.current) {\n      clearInterval(pollIntervalRef.current);\n      pollIntervalRef.current = null;\n    }\n    setStatus(null);\n    setError(null);\n    setIsPolling(false);\n    setShowNewDownload(false);\n    setExistingDownloads([]);\n    setExpandedDownloadId(null);\n    setLoading(true);\n    onClose();\n  };\n\n  const getStatusIcon = (jobStatus?: string) => {\n    switch (jobStatus) {\n      case \"PENDING\":\n        return (\n          <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n        );\n      case \"PROCESSING\":\n        return <FileArchive className=\"h-4 w-4 animate-pulse text-primary\" />;\n      case \"COMPLETED\":\n        return <CheckCircle2 className=\"h-4 w-4 text-green-500\" />;\n      case \"FAILED\":\n        return <XCircle className=\"h-4 w-4 text-destructive\" />;\n      default:\n        return (\n          <Loader2 className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n        );\n    }\n  };\n\n  const getStatusColor = (jobStatus: string) => {\n    switch (jobStatus) {\n      case \"COMPLETED\":\n        return \"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300\";\n      case \"PROCESSING\":\n        return \"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300\";\n      case \"PENDING\":\n        return \"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300\";\n      case \"FAILED\":\n        return \"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300\";\n      default:\n        return \"bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300\";\n    }\n  };\n\n  const formatDate = (dateString: string) => {\n    return new Date(dateString).toLocaleString();\n  };\n\n  const formatExpirationTime = (expiresAt?: string) => {\n    if (!expiresAt) return null;\n\n    const expires = new Date(expiresAt);\n    const now = new Date();\n    const diffMs = expires.getTime() - now.getTime();\n    const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n    const diffDays = Math.floor(diffHours / 24);\n\n    if (diffDays > 0) {\n      return `${diffDays} day${diffDays > 1 ? \"s\" : \"\"}`;\n    } else if (diffHours > 0) {\n      return `${diffHours} hour${diffHours > 1 ? \"s\" : \"\"}`;\n    }\n    return \"less than an hour\";\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>\n            Download {dataroomName || status?.dataroomName || \"Dataroom\"}\n          </DialogTitle>\n          <DialogDescription>\n            {showNewDownload\n              ? status?.status === \"COMPLETED\"\n                ? \"Your files are ready to download.\"\n                : \"Please wait while we prepare your files...\"\n              : \"View previous downloads or start a new one.\"}\n          </DialogDescription>\n        </DialogHeader>\n\n        {loading ? (\n          <div className=\"flex items-center justify-center py-8\">\n            <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n          </div>\n        ) : showNewDownload ? (\n          // Show download progress\n          <div className=\"flex flex-col items-center space-y-4 py-6\">\n            {/* Status Icon */}\n            <div className=\"flex h-16 w-16 items-center justify-center rounded-full bg-muted\">\n              {status?.status === \"COMPLETED\" ? (\n                <CheckCircle2 className=\"h-8 w-8 text-green-500\" />\n              ) : status?.status === \"FAILED\" ? (\n                <XCircle className=\"h-8 w-8 text-destructive\" />\n              ) : (\n                <FileArchive className=\"h-8 w-8 animate-pulse text-primary\" />\n              )}\n            </div>\n\n            {/* Status Message */}\n            <p\n              className={cn(\n                \"text-center text-sm\",\n                status?.status === \"FAILED\"\n                  ? \"text-destructive\"\n                  : \"text-muted-foreground\",\n              )}\n            >\n              {!status\n                ? \"Starting download...\"\n                : status.status === \"PENDING\"\n                  ? \"Preparing your download...\"\n                  : status.status === \"PROCESSING\"\n                    ? `Processing ${status.processedFiles} of ${status.totalFiles} files...`\n                    : status.status === \"COMPLETED\"\n                      ? status.downloadUrls && status.downloadUrls.length > 1\n                        ? `Your download is ready! ${status.downloadUrls.length} ZIP files have been created.`\n                        : \"Your download is ready!\"\n                      : status.error || \"Download failed. Please try again.\"}\n            </p>\n\n            {/* Progress Bar */}\n            {(status?.status === \"PROCESSING\" ||\n              status?.status === \"PENDING\") && (\n              <div className=\"w-full space-y-2\">\n                <Progress\n                  value={status?.progress || 0}\n                  text={`${status?.progress || 0}%`}\n                  className=\"h-4\"\n                />\n                <p className=\"text-center text-xs text-muted-foreground\">\n                  {status?.totalFiles\n                    ? `${status.processedFiles || 0} / ${status.totalFiles} files`\n                    : \"Calculating...\"}\n                </p>\n              </div>\n            )}\n\n            {/* Download Links */}\n            {status?.status === \"COMPLETED\" && status.downloadUrls && (\n              <div className=\"w-full space-y-3\">\n                {status.downloadUrls.length === 1 ? (\n                  <Button\n                    className=\"w-full\"\n                    onClick={() => handleDownload(status.downloadUrls![0])}\n                  >\n                    <Download className=\"mr-2 h-4 w-4\" />\n                    Download ZIP\n                  </Button>\n                ) : (\n                  <>\n                    <Button\n                      className=\"w-full\"\n                      disabled={!!downloadProgress}\n                      onClick={() =>\n                        handleDownloadAll(status.id, status.downloadUrls!)\n                      }\n                    >\n                      {downloadProgress?.downloadId === status.id ? (\n                        <>\n                          <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                          Downloading {downloadProgress.current} of{\" \"}\n                          {downloadProgress.total}...\n                        </>\n                      ) : (\n                        <>\n                          <Download className=\"mr-2 h-4 w-4\" />\n                          Download All ({status.downloadUrls.length} parts)\n                        </>\n                      )}\n                    </Button>\n                    <div className=\"space-y-2\">\n                      <p className=\"text-xs text-muted-foreground\">\n                        Or download individually:\n                      </p>\n                      <div className=\"max-h-48 space-y-1 overflow-y-auto\">\n                        {status.downloadUrls.map((url, index) => (\n                          <Button\n                            key={index}\n                            variant=\"outline\"\n                            size=\"sm\"\n                            className=\"w-full justify-start\"\n                            onClick={() => handleDownload(url)}\n                          >\n                            <FileArchive className=\"mr-2 h-3 w-3\" />\n                            Part {index + 1} of {status.downloadUrls!.length}\n                          </Button>\n                        ))}\n                      </div>\n                    </div>\n                  </>\n                )}\n\n                {/* Expiration Notice */}\n                {status.expiresAt && (\n                  <div className=\"flex items-center justify-center gap-1 text-xs text-muted-foreground\">\n                    <AlertCircle className=\"h-3 w-3\" />\n                    <span>\n                      Download expires in{\" \"}\n                      {formatExpirationTime(status.expiresAt)}\n                    </span>\n                  </div>\n                )}\n              </div>\n            )}\n\n            {/* Error State */}\n            {status?.status === \"FAILED\" && (\n              <Button\n                variant=\"outline\"\n                onClick={() => setShowNewDownload(false)}\n              >\n                Back to Downloads\n              </Button>\n            )}\n\n            {/* Back button for processing state */}\n            {(status?.status === \"PROCESSING\" ||\n              status?.status === \"PENDING\") && (\n              <DialogFooter className=\"w-full sm:justify-center\">\n                <p className=\"text-xs text-muted-foreground\">\n                  You can close this dialog. We&apos;ll notify you when your\n                  download is ready.\n                </p>\n              </DialogFooter>\n            )}\n\n            {/* Loading Error */}\n            {error && (\n              <div className=\"flex items-center gap-2 text-sm text-destructive\">\n                <AlertCircle className=\"h-4 w-4\" />\n                <span>{error}</span>\n              </div>\n            )}\n          </div>\n        ) : (\n          // Show existing downloads and new download option\n          <div className=\"space-y-4 py-2\">\n            {existingDownloads.length > 0 ? (\n              <div className=\"space-y-3\">\n                <h4 className=\"text-sm font-medium\">Recent Downloads</h4>\n                <div className=\"max-h-64 space-y-2 overflow-y-auto\">\n                  {existingDownloads.map((download) => (\n                    <div key={download.id} className=\"space-y-2\">\n                      <div className=\"flex items-center justify-between rounded-md border p-3\">\n                        <div className=\"flex-1 space-y-1\">\n                          <div className=\"flex items-center gap-2\">\n                            {getStatusIcon(download.status)}\n                            <span\n                              className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${getStatusColor(download.status)}`}\n                            >\n                              {download.status}\n                            </span>\n                          </div>\n                          <div className=\"text-xs text-muted-foreground\">\n                            {formatDate(download.createdAt)}\n                          </div>\n                          {download.totalFiles > 0 && (\n                            <div className=\"text-xs text-muted-foreground\">\n                              {download.totalFiles} files\n                              {download.downloadUrls &&\n                                download.downloadUrls.length > 1 &&\n                                ` (${download.downloadUrls.length} ZIPs)`}\n                            </div>\n                          )}\n                          {download.expiresAt && (\n                            <div className=\"flex items-center gap-1 text-xs text-muted-foreground\">\n                              <AlertCircle className=\"h-3 w-3\" />\n                              Expires in{\" \"}\n                              {formatExpirationTime(download.expiresAt)}\n                            </div>\n                          )}\n                          {download.error && (\n                            <p className=\"text-xs text-destructive\">\n                              {download.error}\n                            </p>\n                          )}\n                        </div>\n                        {download.status === \"COMPLETED\" &&\n                          download.downloadUrls &&\n                          download.downloadUrls.length > 0 &&\n                          (download.downloadUrls.length === 1 ? (\n                            <Button\n                              size=\"sm\"\n                              onClick={() =>\n                                handleDownload(download.downloadUrls![0])\n                              }\n                            >\n                              <Download className=\"mr-1 h-3 w-3\" />\n                              Download\n                            </Button>\n                          ) : (\n                            <Button\n                              size=\"sm\"\n                              variant=\"outline\"\n                              onClick={() =>\n                                setExpandedDownloadId(\n                                  expandedDownloadId === download.id\n                                    ? null\n                                    : download.id,\n                                )\n                              }\n                            >\n                              {expandedDownloadId === download.id ? (\n                                <>\n                                  <ChevronUp className=\"mr-1 h-3 w-3\" />\n                                  Hide\n                                </>\n                              ) : (\n                                <>\n                                  <ChevronDown className=\"mr-1 h-3 w-3\" />\n                                  Show Downloads\n                                </>\n                              )}\n                            </Button>\n                          ))}\n                        {(download.status === \"PENDING\" ||\n                          download.status === \"PROCESSING\") && (\n                          <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                            <Loader2 className=\"h-3 w-3 animate-spin\" />\n                            {download.progress}%\n                          </div>\n                        )}\n                      </div>\n                      {/* Expanded download parts */}\n                      {expandedDownloadId === download.id &&\n                        download.downloadUrls &&\n                        download.downloadUrls.length > 1 && (\n                          <div className=\"space-y-2 rounded-md border bg-muted/30 p-3\">\n                            <Button\n                              size=\"sm\"\n                              className=\"w-full\"\n                              disabled={!!downloadProgress}\n                              onClick={() =>\n                                handleDownloadAll(\n                                  download.id,\n                                  download.downloadUrls!,\n                                )\n                              }\n                            >\n                              {downloadProgress?.downloadId === download.id ? (\n                                <>\n                                  <Loader2 className=\"mr-2 h-3 w-3 animate-spin\" />\n                                  Downloading {downloadProgress.current} of{\" \"}\n                                  {downloadProgress.total}...\n                                </>\n                              ) : (\n                                <>\n                                  <Download className=\"mr-2 h-3 w-3\" />\n                                  Download All ({download.downloadUrls.length}{\" \"}\n                                  parts)\n                                </>\n                              )}\n                            </Button>\n                            <p className=\"text-xs text-muted-foreground\">\n                              Or download individually:\n                            </p>\n                            <div className=\"max-h-32 space-y-1 overflow-y-auto\">\n                              {download.downloadUrls.map((url, index) => (\n                                <Button\n                                  key={index}\n                                  variant=\"outline\"\n                                  size=\"sm\"\n                                  className=\"w-full justify-start\"\n                                  onClick={() => handleDownload(url)}\n                                >\n                                  <FileArchive className=\"mr-2 h-3 w-3\" />\n                                  Part {index + 1} of{\" \"}\n                                  {download.downloadUrls!.length}\n                                </Button>\n                              ))}\n                            </div>\n                          </div>\n                        )}\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ) : (\n              <div className=\"py-4 text-center text-sm text-muted-foreground\">\n                No previous downloads found\n              </div>\n            )}\n\n            <div className=\"border-t pt-4\">\n              <Button\n                className=\"w-full\"\n                onClick={startNewDownload}\n                disabled={isStartingDownload}\n              >\n                {isStartingDownload ? (\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                ) : (\n                  <Plus className=\"mr-2 h-4 w-4\" />\n                )}\n                Start New Download\n              </Button>\n            </div>\n          </div>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/edit-dataroom-document-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport {\n  type DataroomFolderDocument,\n  type DataroomFolderWithDocuments,\n} from \"@/lib/swr/use-dataroom\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\ntype DataroomIncludeDocumentsItem =\n  | DataroomFolderWithDocuments\n  | {\n      id: string;\n      folderId: string | null;\n      hierarchicalIndex: string | null;\n      document: {\n        id: string;\n        name: string;\n        type: string;\n      };\n    };\n\nfunction updateDocNameInDocuments(\n  _: null,\n  docs: DataroomFolderDocument[] | undefined,\n  docId: string,\n  newName: string,\n): DataroomFolderDocument[] | undefined {\n  if (!docs) return docs;\n  return docs.map((doc) =>\n    doc.document.id === docId\n      ? { ...doc, document: { ...doc.document, name: newName } }\n      : doc,\n  );\n}\n\nfunction updateDocNameInFolderTree(\n  _: null,\n  folders: DataroomFolderWithDocuments[] | undefined,\n  docId: string,\n  newName: string,\n): DataroomFolderWithDocuments[] | undefined {\n  if (!folders) return folders;\n  const updateFolder = (\n    folder: DataroomFolderWithDocuments,\n  ): DataroomFolderWithDocuments => ({\n    ...folder,\n    documents: (folder.documents ?? []).map((doc) =>\n      doc.document.id === docId\n        ? { ...doc, document: { ...doc.document, name: newName } }\n        : doc,\n    ),\n    childFolders: (folder.childFolders ?? []).map(updateFolder),\n  });\n  return folders.map(updateFolder);\n}\n\nfunction updateDocNameInIncludeDocumentsTree(\n  _: null,\n  items: DataroomIncludeDocumentsItem[] | undefined,\n  docId: string,\n  newName: string,\n): DataroomIncludeDocumentsItem[] | undefined {\n  if (!items) return items;\n  const updateFolder = (\n    folder: DataroomFolderWithDocuments,\n  ): DataroomFolderWithDocuments => ({\n    ...folder,\n    documents: (folder.documents ?? []).map((doc) =>\n      doc.document.id === docId\n        ? { ...doc, document: { ...doc.document, name: newName } }\n        : doc,\n    ),\n    childFolders: (folder.childFolders ?? []).map(updateFolder),\n  });\n\n  return items.map((item) => {\n    if (\"childFolders\" in item) {\n      return updateFolder(item);\n    }\n\n    return item.document.id === docId\n      ? { ...item, document: { ...item.document, name: newName } }\n      : item;\n  });\n}\n\nexport function EditDataroomDocumentModal({\n  open,\n  setOpen,\n  documentId,\n  documentName,\n  dataroomId,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  documentId: string;\n  documentName: string;\n  dataroomId: string;\n}) {\n  const [name, setName] = useState<string>(documentName);\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const router = useRouter();\n  const currentFolderPath = router.query.name as string[] | undefined;\n\n  const editDocumentNameSchema = z.object({\n    name: z\n      .string()\n      .min(1, {\n        message: \"Please provide a document name.\",\n      })\n      .max(255, {\n        message: \"Document name is too long.\",\n      }),\n  });\n\n  const handleSubmit = async (event: React.FormEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const trimmedName = name.trim();\n    const validation = editDocumentNameSchema.safeParse({ name: trimmedName });\n    if (!validation.success) {\n      return toast.error(validation.error.errors[0].message);\n    }\n\n    setLoading(true);\n\n    const teamId = teamInfo?.currentTeam?.id;\n    const baseKey = `/api/teams/${teamId}/datarooms/${dataroomId}`;\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/documents/${documentId}/update-name`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: trimmedName,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { error, message } = await response.json();\n        setLoading(false);\n        toast.error(error || message || \"Failed to update document name\");\n        return;\n      }\n\n      toast.success(\"Document name updated successfully!\");\n\n      mutate(`${baseKey}/documents`, null, {\n        populateCache: (_, docs) =>\n          updateDocNameInDocuments(_, docs, documentId, trimmedName),\n        revalidate: false,\n      });\n\n      if (currentFolderPath) {\n        mutate(\n          `${baseKey}/folders/documents/${currentFolderPath.join(\"/\")}`,\n          null,\n          {\n            populateCache: (_, docs) =>\n              updateDocNameInDocuments(_, docs, documentId, trimmedName),\n            revalidate: false,\n          },\n        );\n      }\n\n      mutate(`${baseKey}/folders`, null, {\n        populateCache: (_, folders) =>\n          updateDocNameInFolderTree(_, folders, documentId, trimmedName),\n        revalidate: false,\n      });\n      mutate(`${baseKey}/folders?include_documents=true`, null, {\n        populateCache: (_, items) =>\n          updateDocNameInIncludeDocumentsTree(\n            _,\n            items,\n            documentId,\n            trimmedName,\n          ),\n        revalidate: false,\n      });\n    } catch (error) {\n      setLoading(false);\n      toast.error(\"Error updating document name. Please try again.\");\n      return;\n    } finally {\n      setLoading(false);\n      setOpen(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Rename Document</DialogTitle>\n          <DialogDescription>Enter a new document name.</DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"document-name-update\" className=\"opacity-80\">\n            Document Name\n          </Label>\n          <Input\n            id=\"document-name-update\"\n            value={name}\n            placeholder=\"document-name\"\n            className=\"mb-4 mt-1 w-full\"\n            onChange={(e) => setName(e.target.value)}\n          />\n          <DialogFooter>\n            <Button type=\"submit\" className=\"h-9 w-full\" loading={loading}>\n              Update name\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/empty-dataroom.tsx",
    "content": "import { ServerIcon } from \"lucide-react\";\n\nexport function EmptyDataroom() {\n  return (\n    <div className=\"text-center\">\n      <ServerIcon\n        className=\"mx-auto h-12 w-12 text-muted-foreground\"\n        strokeWidth={1}\n      />\n      <h3 className=\"mt-2 text-sm font-medium text-foreground\">\n        No datarooms here\n      </h3>\n      <p className=\"mt-1 text-sm text-muted-foreground\">\n        Get started by creating a new dataroom.\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/export-visits-modal.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport { ExportJob } from \"@/lib/redis-job-store\";\n\nimport { Button } from \"../ui/button\";\n\ninterface ExportStatus {\n  status: string;\n  progress?: string;\n  exportId?: string;\n  startTime?: number;\n}\n\ninterface ExportVisitsModalProps {\n  teamId: string;\n  dataroomId: string;\n  dataroomName: string;\n  groupId?: string;\n  groupName?: string;\n  onClose: () => void;\n}\n\nexport function ExportVisitsModal({\n  teamId,\n  dataroomId,\n  dataroomName,\n  groupId,\n  groupName,\n  onClose,\n}: ExportVisitsModalProps) {\n  const { data: session } = useSession();\n  const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);\n  const [showModal, setShowModal] = useState(false);\n  const [viewCount, setViewCount] = useState<number | null>(null);\n  const [existingExports, setExistingExports] = useState<ExportJob[]>([]);\n  const [showNewExport, setShowNewExport] = useState(false);\n  const [loading, setLoading] = useState(true);\n  const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);\n  const exportStartedRef = useRef<boolean>(false);\n\n  // Cleanup interval on component unmount\n  useEffect(() => {\n    return () => {\n      if (pollIntervalRef.current) {\n        clearInterval(pollIntervalRef.current);\n      }\n    };\n  }, []);\n\n  // Fetch existing exports when modal opens\n  useEffect(() => {\n    const fetchExistingExports = async () => {\n      try {\n        setLoading(true);\n        setShowModal(true);\n\n        const endpoint = groupId\n          ? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`\n          : `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;\n\n        const response = await fetch(endpoint, { method: \"GET\" });\n\n        if (response.ok) {\n          const exports = await response.json();\n          setExistingExports(exports);\n        } else {\n          console.error(\"Failed to fetch existing exports\");\n        }\n      } catch (error) {\n        console.error(\"Error fetching existing exports:\", error);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchExistingExports();\n  }, [teamId, dataroomId, groupId]);\n\n  const startNewExport = useCallback(async () => {\n    // Prevent double triggering\n    if (exportStartedRef.current) {\n      console.warn(\"Export already started, skipping duplicate request\");\n      return;\n    }\n    exportStartedRef.current = true;\n    setShowNewExport(true);\n\n    try {\n      // Get view count first\n      try {\n        const viewCountResponse = await fetch(\n          `/api/teams/${teamId}/datarooms/${dataroomId}/views-count${groupId ? `?groupId=${groupId}` : \"\"}`,\n          { method: \"GET\" },\n        );\n\n        if (viewCountResponse.ok) {\n          const viewData = await viewCountResponse.json();\n          setViewCount(viewData.count || 0);\n        }\n      } catch (error) {\n        console.error(\"Error fetching view count:\", error);\n        // Continue with export even if view count fails\n      }\n\n      // Trigger the background export job\n      const endpoint = groupId\n        ? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`\n        : `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;\n\n      const response = await fetch(endpoint, { method: \"POST\" });\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n      const data = await response.json();\n\n      if (data.exportId) {\n        const startTime = Date.now();\n        setExportStatus({\n          status: \"PROCESSING\",\n          exportId: data.exportId,\n          startTime,\n        });\n\n        // Clear any existing interval\n        if (pollIntervalRef.current) {\n          clearInterval(pollIntervalRef.current);\n        }\n\n        // Start polling\n        pollIntervalRef.current = setInterval(async () => {\n          try {\n            const statusResponse = await fetch(\n              `/api/teams/${teamId}/export-jobs/${data.exportId}`,\n              { method: \"GET\" },\n            );\n\n            if (statusResponse.ok) {\n              const statusData = await statusResponse.json();\n\n              // Update progress\n              setExportStatus((prev) => ({\n                ...prev!,\n                progress: \"Preparing export...\",\n              }));\n\n              if (statusData.status === \"COMPLETED\" && statusData.isReady) {\n                if (pollIntervalRef.current) {\n                  clearInterval(pollIntervalRef.current);\n                  pollIntervalRef.current = null;\n                }\n\n                // Create a direct link to the API endpoint (it will redirect to blob)\n                const downloadUrl = `/api/teams/${teamId}/export-jobs/${data.exportId}?download=true`;\n                try {\n                  const link = window.document.createElement(\"a\");\n                  link.href = downloadUrl;\n                  link.setAttribute(\n                    \"download\",\n                    `${statusData.resourceName || dataroomName}_${groupName ? `${groupName}_` : \"\"}visits_${new Date().toISOString().split(\"T\")[0]}.csv`,\n                  );\n                  link.rel = \"noopener noreferrer\";\n                  link.style.display = \"none\";\n\n                  window.document.body.appendChild(link);\n                  link.click();\n                  window.document.body.removeChild(link);\n                } catch (error) {\n                  // Fallback: open in new tab if programmatic download fails\n                  window.open(downloadUrl, \"_blank\");\n                  console.error(\"Download failed, opened in new tab:\", error);\n                }\n\n                handleClose();\n                toast.success(\"Export successfully downloaded\");\n              } else if (statusData.status === \"FAILED\") {\n                if (pollIntervalRef.current) {\n                  clearInterval(pollIntervalRef.current);\n                  pollIntervalRef.current = null;\n                }\n                handleClose();\n                toast.error(\n                  `Export failed: ${statusData.error || \"Unknown error\"}`,\n                );\n              }\n            }\n          } catch (error) {\n            console.error(\"Error polling export status:\", error);\n          }\n        }, 5000); // Poll every 5 seconds\n\n        // Clear interval after 10 minutes to prevent indefinite polling\n        setTimeout(() => {\n          if (pollIntervalRef.current) {\n            clearInterval(pollIntervalRef.current);\n            pollIntervalRef.current = null;\n          }\n          handleClose();\n        }, 600000);\n      }\n    } catch (error) {\n      console.error(\"Error:\", error);\n      toast.error(\n        \"An error occurred while starting the export. Please try again.\",\n      );\n      handleClose();\n    }\n  }, [teamId, dataroomId, groupId, dataroomName, groupName]);\n\n  // Send export via email\n  const sendExportEmail = async () => {\n    if (!exportStatus?.exportId || !session?.user?.email) return;\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/export-jobs/${exportStatus.exportId}/send-email`,\n        { method: \"POST\" },\n      );\n\n      if (response.ok) {\n        toast.success(\"Export will be sent to your email when ready\");\n        handleClose();\n      } else {\n        toast.error(\"Failed to setup email notification\");\n      }\n    } catch (error) {\n      console.error(\"Error sending email:\", error);\n      toast.error(\"Failed to setup email notification\");\n    }\n  };\n\n  // Cancel export\n  const cancelExport = async () => {\n    if (!exportStatus?.exportId) return;\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,\n        { method: \"PATCH\" },\n      );\n\n      if (response.ok) {\n        toast.success(\"Export cancelled successfully\");\n        handleClose();\n      } else {\n        const errorData = await response.json();\n        toast.error(errorData.error || \"Failed to cancel export\");\n      }\n    } catch (error) {\n      console.error(\"Error cancelling export:\", error);\n      toast.error(\"Failed to cancel export\");\n    }\n  };\n\n  // Download existing export\n  const downloadExport = async (exportId: string, resourceName: string) => {\n    try {\n      const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;\n      const link = window.document.createElement(\"a\");\n      link.href = downloadUrl;\n      link.setAttribute(\n        \"download\",\n        `${resourceName || dataroomName}_${groupName ? `${groupName}_` : \"\"}visits_${new Date().toISOString().split(\"T\")[0]}.csv`,\n      );\n      link.rel = \"noopener noreferrer\";\n      link.style.display = \"none\";\n\n      window.document.body.appendChild(link);\n      link.click();\n      window.document.body.removeChild(link);\n    } catch (error) {\n      console.error(\"Download failed:\", error);\n      toast.error(\"Failed to download export\");\n    }\n  };\n\n  // Format date for display\n  const formatDate = (dateString: string) => {\n    return new Date(dateString).toLocaleString();\n  };\n\n  // Get status badge color\n  const getStatusColor = (status: string) => {\n    switch (status) {\n      case \"COMPLETED\":\n        return \"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300\";\n      case \"PROCESSING\":\n        return \"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300\";\n      case \"PENDING\":\n        return \"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300\";\n      case \"FAILED\":\n        return \"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300\";\n      default:\n        return \"bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300\";\n    }\n  };\n\n  // Handle close - cleanup and call parent onClose\n  const handleClose = () => {\n    if (pollIntervalRef.current) {\n      clearInterval(pollIntervalRef.current);\n      pollIntervalRef.current = null;\n    }\n    setShowModal(false);\n    setExportStatus(null);\n    setShowNewExport(false);\n    setExistingExports([]); // Reset existing exports\n    setViewCount(null); // Reset view count\n    setLoading(true); // Reset loading state\n    exportStartedRef.current = false; // Reset for potential reuse\n    onClose();\n  };\n\n  // Don't render anything if not visible and no modal to show\n  if (!showModal) {\n    return null;\n  }\n\n  const displayName = groupName\n    ? `${dataroomName} - ${groupName}`\n    : dataroomName;\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\">\n      <div className=\"mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800\">\n        <div className=\"mb-4 flex items-center justify-between\">\n          <h3 className=\"text-lg font-semibold\">Export Views</h3>\n          <button\n            onClick={handleClose}\n            className=\"text-gray-400 hover:text-gray-600\"\n          >\n            <svg\n              className=\"h-5 w-5\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth={2}\n                d=\"M6 18L18 6M6 6l12 12\"\n              />\n            </svg>\n          </button>\n        </div>\n\n        {loading ? (\n          <div className=\"flex items-center justify-center py-8\">\n            <div className=\"h-8 w-8 animate-spin rounded-full border-b-2 border-primary\"></div>\n          </div>\n        ) : showNewExport ? (\n          // Show export progress\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center space-x-2\">\n              <div className=\"h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground\"></div>\n              <span className=\"text-sm text-gray-600\">\n                {exportStatus?.progress || \"Processing export...\"}\n              </span>\n            </div>\n\n            <div className=\"text-sm text-gray-600\">\n              Exporting views for: {displayName}\n            </div>\n\n            {viewCount !== null && (\n              <div className=\"text-sm text-gray-600\">\n                Found {viewCount} visit{viewCount !== 1 ? \"s\" : \"\"} to export\n              </div>\n            )}\n\n            {viewCount !== null && viewCount > 10 && session?.user?.email && (\n              <div className=\"rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900\">\n                <p className=\"mb-2 font-medium text-muted-foreground\">\n                  Large export detected ({viewCount} views)\n                </p>\n                <p className=\"mb-3 text-muted-foreground\">\n                  This export may take several minutes. We recommend getting it\n                  emailed to you when ready.\n                </p>\n                <button\n                  onClick={sendExportEmail}\n                  className=\"w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80\"\n                >\n                  Email to {session.user.email}\n                </button>\n              </div>\n            )}\n\n            {(!viewCount || viewCount <= 10) && (\n              <div className=\"text-sm text-gray-500\">\n                Your export will be ready shortly...\n              </div>\n            )}\n\n            {/* Cancel button - only show if export is in progress */}\n            {exportStatus?.exportId && (\n              <div className=\"flex gap-2 px-3\">\n                <Button\n                  onClick={cancelExport}\n                  variant=\"outline\"\n                  size=\"sm\"\n                  className=\"flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950\"\n                >\n                  Cancel Export\n                </Button>\n              </div>\n            )}\n          </div>\n        ) : (\n          // Show existing exports and new export option\n          <div className=\"space-y-4\">\n            <div className=\"text-sm text-gray-600\">\n              Exports for: {displayName}\n            </div>\n\n            {existingExports.length > 0 ? (\n              <div className=\"space-y-3\">\n                <h4 className=\"text-sm font-medium text-gray-900 dark:text-gray-100\">\n                  Recent Exports\n                </h4>\n                <div className=\"max-h-48 space-y-2 overflow-y-auto\">\n                  {existingExports.map((exportJob) => (\n                    <div\n                      key={exportJob.id}\n                      className=\"flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700\"\n                    >\n                      <div className=\"flex-1\">\n                        <div className=\"flex items-center gap-2\">\n                          <span\n                            className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}\n                          >\n                            {exportJob.status}\n                          </span>\n                          <span className=\"text-xs text-gray-500\">\n                            {formatDate(exportJob.createdAt)}\n                          </span>\n                        </div>\n                        {exportJob.groupId && (\n                          <div className=\"mt-1 text-xs text-gray-500\">\n                            Group: {exportJob.groupId}\n                          </div>\n                        )}\n                        {exportJob.error && (\n                          <p className=\"mt-1 text-xs text-red-600\">\n                            {exportJob.error}\n                          </p>\n                        )}\n                      </div>\n                      {exportJob.status === \"COMPLETED\" && exportJob.result && (\n                        <button\n                          onClick={() =>\n                            downloadExport(\n                              exportJob.id,\n                              exportJob.resourceName || dataroomName,\n                            )\n                          }\n                          className=\"ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80\"\n                        >\n                          Download\n                        </button>\n                      )}\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ) : (\n              <div className=\"text-center text-sm text-gray-500\">\n                No previous exports found\n              </div>\n            )}\n\n            <div className=\"border-t border-gray-200 pt-4 dark:border-gray-700\">\n              <button\n                onClick={startNewExport}\n                className=\"w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80\"\n              >\n                Start New Export\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/folders/index.tsx",
    "content": "import { SidebarFolderTreeSelection } from \"./selection-tree\";\nimport { SidebarFolderTree } from \"./sidebar-tree\";\nimport { ViewFolderTree } from \"./view-tree\";\n\nexport { SidebarFolderTree, SidebarFolderTreeSelection, ViewFolderTree };\n"
  },
  {
    "path": "components/datarooms/folders/selection-tree.tsx",
    "content": "import { memo, useMemo } from \"react\";\n\nimport {\n  DataroomFolderWithDocuments,\n  useDataroomFoldersTree,\n} from \"@/lib/swr/use-dataroom\";\n\nimport { TSelectedFolder } from \"@/components/documents/move-folder-modal\";\nimport { FileTree } from \"@/components/ui/nextra-filetree\";\n\nimport { buildNestedFolderStructure } from \"./utils\";\n\nconst FolderComponentSelection = memo(\n  ({\n    folder,\n    selectedFolder,\n    setSelectedFolder,\n    disableId,\n    ancestorDisabled,\n  }: {\n    folder: DataroomFolderWithDocuments;\n    disableId?: string[];\n    selectedFolder: TSelectedFolder;\n    setSelectedFolder: React.Dispatch<React.SetStateAction<TSelectedFolder>>;\n    ancestorDisabled?: boolean;\n  }) => {\n    const isSelfDisabled = disableId?.includes(folder.id) ?? false;\n    const isDisabled = ancestorDisabled || isSelfDisabled;\n\n    const childFolders = useMemo(\n      () =>\n        folder.childFolders.map((childFolder) => (\n          <FolderComponentSelection\n            key={childFolder.id}\n            folder={childFolder}\n            selectedFolder={selectedFolder}\n            disableId={disableId}\n            setSelectedFolder={setSelectedFolder}\n            ancestorDisabled={isDisabled}\n          />\n        )),\n      [\n        folder.childFolders,\n        selectedFolder,\n        setSelectedFolder,\n        disableId,\n        isDisabled,\n      ],\n    );\n\n    const isActive = folder.id === selectedFolder?.id;\n    const isChildActive = folder.childFolders.some(\n      (childFolder) => childFolder.id === selectedFolder?.id,\n    );\n\n    return (\n      <div\n        onClick={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          if (isDisabled) return;\n          path: folder.path,\n            setSelectedFolder({\n              id: folder.id,\n              name: folder.name,\n              path: folder.path,\n            });\n        }}\n      >\n        <FileTree.Folder\n          disable={isDisabled}\n          name={folder.name}\n          key={folder.id}\n          active={isActive}\n          childActive={isChildActive}\n          onToggle={() => {\n            if (isDisabled) return;\n            setSelectedFolder({\n              id: folder.id,\n              name: folder.name,\n              path: folder.path,\n            });\n          }}\n        >\n          {childFolders}\n        </FileTree.Folder>\n      </div>\n    );\n  },\n);\nFolderComponentSelection.displayName = \"FolderComponentSelection\";\n\nconst SidebarFoldersSelection = ({\n  folders,\n  selectedFolder,\n  setSelectedFolder,\n  disableId,\n}: {\n  folders: DataroomFolderWithDocuments[];\n  disableId?: string[];\n  selectedFolder: TSelectedFolder;\n  setSelectedFolder: React.Dispatch<React.SetStateAction<TSelectedFolder>>;\n}) => {\n  const nestedFolders = useMemo(() => {\n    if (folders) {\n      return buildNestedFolderStructure(folders);\n    }\n    return [];\n  }, [folders]);\n\n  // Create a virtual \"Home\" folder\n  const homeFolder: Partial<DataroomFolderWithDocuments> = {\n    // @ts-ignore\n    id: null,\n    name: \"Home\",\n    path: \"/\",\n    childFolders: nestedFolders,\n    documents: [],\n  };\n\n  return (\n    <FileTree>\n      {/* {nestedFolders.map((folder) => ( */}\n      <FolderComponentSelection\n        // key={folder.id}\n        // @ts-ignore\n        folder={homeFolder}\n        selectedFolder={selectedFolder}\n        setSelectedFolder={setSelectedFolder}\n        disableId={disableId}\n      />\n      {/* ))} */}\n    </FileTree>\n  );\n};\n\nexport function SidebarFolderTreeSelection({\n  dataroomId,\n  selectedFolder,\n  setSelectedFolder,\n  disableId,\n}: {\n  dataroomId: string;\n  disableId?: string[];\n  selectedFolder: TSelectedFolder;\n  setSelectedFolder: React.Dispatch<React.SetStateAction<TSelectedFolder>>;\n}) {\n  const { folders, error } = useDataroomFoldersTree({ dataroomId });\n\n  if (!folders || error) return null;\n\n  return (\n    <SidebarFoldersSelection\n      folders={folders}\n      selectedFolder={selectedFolder}\n      setSelectedFolder={setSelectedFolder}\n      disableId={disableId}\n    />\n  );\n}\n"
  },
  {
    "path": "components/datarooms/folders/sidebar-tree.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { memo, useMemo } from \"react\";\n\nimport { HomeIcon } from \"lucide-react\";\n\nimport {\n  DataroomFolderWithDocuments,\n  useDataroomFoldersTree,\n} from \"@/lib/swr/use-dataroom\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  useHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\nimport { sortByIndexThenName } from \"@/lib/utils/sort-items-by-index-name\";\n\nimport { FileTree } from \"@/components/ui/nextra-filetree\";\n\nimport { buildNestedFolderStructure } from \"./utils\";\n\ntype MixedItem =\n  | (DataroomFolderWithDocuments & { itemType: \"folder\" })\n  | (DataroomFolderWithDocuments[\"documents\"][0] & { itemType: \"document\" });\n\nconst DocumentFileItem = memo(\n  ({\n    document,\n    router,\n  }: {\n    document: DataroomFolderWithDocuments[\"documents\"][0] & {\n      itemType: \"document\";\n    };\n    router: any;\n  }) => {\n    const documentDisplayName = useHierarchicalDisplayName(\n      document.document.name,\n      document.hierarchicalIndex,\n    );\n\n    return (\n      <FileTree.File\n        name={documentDisplayName}\n        onToggle={() => router.push(`/documents/${document.document.id}`)}\n      />\n    );\n  },\n);\nDocumentFileItem.displayName = \"DocumentFileItem\";\n\nconst FolderComponent = memo(\n  ({\n    dataroomId,\n    folder,\n  }: {\n    dataroomId: string;\n    folder: DataroomFolderWithDocuments;\n  }) => {\n    const router = useRouter();\n\n    // Get hierarchical display names\n    const folderDisplayName = useHierarchicalDisplayName(\n      folder.name,\n      folder.hierarchicalIndex,\n    );\n\n    const mixedItems = useMemo(() => {\n      const allItems: MixedItem[] = [\n        ...(folder.childFolders || []).map((f) => ({\n          ...f,\n          itemType: \"folder\" as const,\n        })),\n        ...(folder.documents || []).map((d) => ({\n          ...d,\n          itemType: \"document\" as const,\n        })),\n      ];\n\n      return sortByIndexThenName(allItems);\n    }, [folder.childFolders, folder.documents]);\n\n    const renderedItems = useMemo(\n      () =>\n        mixedItems.map((item: MixedItem) => {\n          if (item.itemType === \"folder\") {\n            return (\n              <FolderComponent\n                key={item.id}\n                dataroomId={dataroomId}\n                folder={item}\n              />\n            );\n          } else {\n            return (\n              <DocumentFileItem key={item.id} document={item} router={router} />\n            );\n          }\n        }),\n      [mixedItems, dataroomId, router],\n    );\n\n    const isActive =\n      folder.path === \"/\" + (router.query.name as string[])?.join(\"/\");\n    const isChildActive = folder.childFolders.some(\n      (childFolder) =>\n        childFolder.path === \"/\" + (router.query.name as string[])?.join(\"/\"),\n    );\n\n    const handleFolderClick = () => {\n      router.push(\n        `/datarooms/${dataroomId}/documents${folder.path}`,\n        `/datarooms/${dataroomId}/documents${folder.path}`,\n        {\n          scroll: false,\n        },\n      );\n    };\n\n    return (\n      <FileTree.Folder\n        name={folderDisplayName}\n        key={folder.id}\n        active={isActive}\n        childActive={isChildActive}\n        onToggle={handleFolderClick}\n      >\n        {renderedItems}\n      </FileTree.Folder>\n    );\n  },\n);\nFolderComponent.displayName = \"FolderComponent\";\n\nconst SidebarFolders = ({\n  dataroomId,\n  folders,\n}: {\n  dataroomId: string;\n  folders: DataroomFolderWithDocuments[];\n}) => {\n  const nestedFolders = useMemo(() => {\n    if (folders) {\n      return buildNestedFolderStructure(folders);\n    }\n    return [];\n  }, [folders, dataroomId]);\n\n  return (\n    <FileTree>\n      <SidebarLink\n        href={`/datarooms/${dataroomId}/documents`}\n        label={\"Dataroom Home\"}\n      />\n      {nestedFolders.map((folder) => (\n        <FolderComponent\n          key={folder.id}\n          dataroomId={dataroomId}\n          folder={folder}\n        />\n      ))}\n    </FileTree>\n  );\n};\n\nexport function SidebarFolderTree({ dataroomId }: { dataroomId: string }) {\n  const { folders, error } = useDataroomFoldersTree({ dataroomId });\n\n  if (!folders || error) return null;\n\n  return <SidebarFolders dataroomId={dataroomId} folders={folders} />;\n}\n\nexport const SidebarLink = memo(\n  ({ href, label }: { href: string; label: string }) => {\n    const router = useRouter();\n    const isActive = router.asPath === href;\n\n    return (\n      <li\n        className={cn(\n          \"flex list-none\",\n          \"rounded-md text-foreground transition-all duration-200 ease-in-out\",\n          \"hover:bg-gray-100 hover:shadow-sm hover:dark:bg-muted\",\n          \"px-3 py-1.5 leading-6\",\n          isActive && \"bg-gray-100 font-semibold dark:bg-muted\",\n        )}\n      >\n        <span\n          className=\"inline-flex w-full cursor-pointer items-center\"\n          onClick={() => router.push(href)}\n        >\n          <HomeIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n          <span className=\"ml-2 w-fit truncate\" title={label}>\n            {label}\n          </span>\n        </span>\n      </li>\n    );\n  },\n);\n\nSidebarLink.displayName = \"SidebarLink\";\n"
  },
  {
    "path": "components/datarooms/folders/utils.ts",
    "content": "import { DataroomDocument, DataroomFolder } from \"@prisma/client\";\n\nimport { DataroomFolderWithDocuments } from \"@/lib/swr/use-dataroom\";\n\n// Helper function to build nested folder structure\nexport const buildNestedFolderStructure = (\n  folders: DataroomFolderWithDocuments[],\n) => {\n  const folderMap = new Map();\n\n  // Initialize every folder with an additional childFolders property\n  folders.forEach((folder) => {\n    folderMap.set(folder.id, { ...folder, childFolders: [] });\n  });\n\n  const rootFolders: DataroomFolderWithDocuments[] = [];\n\n  folderMap.forEach((folder, id) => {\n    if (folder.parentId) {\n      const parent = folderMap.get(folder.parentId);\n      parent.childFolders.push(folder);\n    } else {\n      rootFolders.push(folder);\n    }\n  });\n\n  return rootFolders;\n};\n\nexport const buildNestedFolderStructureWithDocs = (\n  folders: DataroomFolder[],\n  documents: DataroomDocumentWithVersion[],\n) => {\n  const folderMap = new Map();\n\n  // Initialize every folder with an additional childFolders property\n  folders.forEach((folder) => {\n    folderMap.set(folder.id, {\n      ...folder,\n      documents: documents.filter((doc) => doc.folderId === folder.id),\n      childFolders: [],\n    });\n  });\n\n  const rootFolders: DataroomFolderWithDocumentsNew[] = [];\n\n  folderMap.forEach((folder, id) => {\n    if (folder.parentId) {\n      const parent = folderMap.get(folder.parentId);\n      parent.childFolders.push(folder);\n    } else {\n      rootFolders.push(folder);\n    }\n  });\n\n  return rootFolders;\n};\n\ntype DataroomDocumentWithVersion = {\n  dataroomDocumentId: string;\n  folderId: string | null;\n  id: string;\n  name: string;\n  hierarchicalIndex: string | null;\n  versions: {\n    id: string;\n    versionNumber: number;\n    hasPages: boolean;\n  }[];\n};\n\ntype DataroomFolderWithDocumentsNew = DataroomFolder & {\n  childFolders: DataroomFolderWithDocumentsNew[];\n  documents: {\n    dataroomDocumentId: string;\n    folderId: string | null;\n    id: string;\n    name: string;\n    hierarchicalIndex: string | null;\n  }[];\n};\n\nexport const itemsMessage = (\n  documentsToMove: string[],\n  foldersToMove: string[],\n  action: \"Moving\" | \"Successfully moved\",\n) => {\n  const docCount = documentsToMove.length;\n  const folderCount = foldersToMove.length;\n\n  if (docCount && folderCount) {\n    return `${action} ${docCount} document${docCount > 1 ? \"s\" : \"\"} and ${folderCount} folder${folderCount > 1 ? \"s\" : \"\"}...`;\n  }\n  if (docCount) {\n    return `${action} ${docCount} document${docCount > 1 ? \"s\" : \"\"}...`;\n  }\n  if (folderCount) {\n    return `${action} ${folderCount} folder${folderCount > 1 ? \"s\" : \"\"}...`;\n  }\n  return `${action} items...`;\n};\n"
  },
  {
    "path": "components/datarooms/folders/view-tree.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { memo, useMemo } from \"react\";\nimport type { CSSProperties } from \"react\";\n\nimport { DataroomFolder } from \"@prisma/client\";\nimport { HomeIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  getHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport { FileTree } from \"@/components/ui/nextra-filetree\";\nimport { useViewerSurfaceTheme } from \"@/components/view/viewer/viewer-surface-theme\";\n\nimport { buildNestedFolderStructureWithDocs } from \"./utils\";\n\nconst ViewerDocumentFileItem = memo(\n  ({\n    document,\n    dataroomIndexEnabled,\n  }: {\n    document: DataroomDocumentWithVersion;\n    dataroomIndexEnabled?: boolean;\n  }) => {\n    const documentDisplayName = getHierarchicalDisplayName(\n      document.name,\n      document.hierarchicalIndex,\n      dataroomIndexEnabled || false,\n    );\n\n    return (\n      <FileTree.File\n        name={documentDisplayName}\n        // onToggle={() => router.push(`/documents/${document.id}`)}\n      />\n    );\n  },\n);\nViewerDocumentFileItem.displayName = \"ViewerDocumentFileItem\";\n\ntype DataroomDocumentWithVersion = {\n  dataroomDocumentId: string;\n  folderId: string | null;\n  id: string;\n  name: string;\n  hierarchicalIndex: string | null;\n  versions: {\n    id: string;\n    versionNumber: number;\n    hasPages: boolean;\n  }[];\n};\n\ntype DataroomFolderWithDocuments = DataroomFolder & {\n  childFolders: DataroomFolderWithDocuments[];\n  documents: {\n    dataroomDocumentId: string;\n    folderId: string | null;\n    id: string;\n    name: string;\n    hierarchicalIndex: string | null;\n  }[];\n};\n\ntype FolderPath = Set<string> | null;\n\nfunction findFolderPath(\n  folder: DataroomFolderWithDocuments,\n  folderId: string,\n  currentPath: Set<string> = new Set<string>(),\n): FolderPath {\n  if (folder.id === folderId) {\n    return currentPath.add(folder.id);\n  }\n\n  for (const child of folder.childFolders) {\n    const path = findFolderPath(child, folderId, currentPath.add(folder.id));\n    if (path) {\n      return path;\n    }\n  }\n\n  return null;\n}\n\nconst FolderComponent = memo(\n  ({\n    folder,\n    folderId,\n    setFolderId,\n    folderPath,\n    dataroomIndexEnabled,\n  }: {\n    folder: DataroomFolderWithDocuments;\n    folderId: string | null;\n    setFolderId: React.Dispatch<React.SetStateAction<string | null>>;\n    folderPath: Set<string> | null;\n    dataroomIndexEnabled?: boolean;\n  }) => {\n    const router = useRouter();\n\n    // Get hierarchical display name for the folder\n    const folderDisplayName = getHierarchicalDisplayName(\n      folder.name,\n      folder.hierarchicalIndex,\n      dataroomIndexEnabled || false,\n    );\n\n    // Memoize the rendering of the current folder's documents\n    const documents = useMemo(\n      () =>\n        folder.documents.map((doc) => (\n          <ViewerDocumentFileItem\n            key={doc.id}\n            document={{\n              ...doc,\n              versions: [], // Not needed for display\n            }}\n            dataroomIndexEnabled={dataroomIndexEnabled}\n          />\n        )),\n      [folder.documents, dataroomIndexEnabled],\n    );\n\n    // Recursively render child folders if they exist\n    const childFolders = useMemo(\n      () =>\n        folder.childFolders.map((childFolder) => (\n          <FolderComponent\n            key={childFolder.id}\n            folder={childFolder}\n            folderId={folderId}\n            setFolderId={setFolderId}\n            folderPath={folderPath}\n            dataroomIndexEnabled={dataroomIndexEnabled}\n          />\n        )),\n      [\n        folder.childFolders,\n        folderId,\n        setFolderId,\n        folderPath,\n        dataroomIndexEnabled,\n      ],\n    );\n\n    const isActive = folder.id === folderId;\n    const isChildActive =\n      folderPath?.has(folder.id) ||\n      folder.childFolders.some((childFolder) => childFolder.id === folderId);\n\n    return (\n      <div\n        onClick={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setFolderId(folder.id);\n        }}\n      >\n        <FileTree.Folder\n          name={folderDisplayName}\n          key={folder.id}\n          active={isActive}\n          childActive={isChildActive}\n          onToggle={() => setFolderId(folder.id)}\n        >\n          {childFolders}\n          {documents}\n        </FileTree.Folder>\n      </div>\n    );\n  },\n);\nFolderComponent.displayName = \"FolderComponent\";\n\nconst HomeLink = memo(\n  ({\n    folderId,\n    setFolderId,\n  }: {\n    folderId: string | null;\n    setFolderId: React.Dispatch<React.SetStateAction<string | null>>;\n  }) => {\n    const { usesLightText, palette } = useViewerSurfaceTheme();\n\n    return (\n      <li\n        className={cn(\n          \"flex list-none\",\n          \"rounded-md transition-all duration-200 ease-in-out\",\n          usesLightText\n            ? \"text-[var(--viewer-text)] hover:bg-[var(--viewer-control-bg)]\"\n            : \"text-foreground hover:bg-gray-100 hover:shadow-sm hover:dark:bg-muted\",\n          \"px-3 py-1.5 leading-6\",\n          folderId === null &&\n            (usesLightText\n              ? \"bg-[var(--viewer-panel-active)] font-semibold\"\n              : \"bg-gray-100 font-semibold dark:bg-muted\"),\n        )}\n        style={\n          usesLightText\n            ? ({\n                \"--viewer-text\": palette.textColor,\n                \"--viewer-control-bg\": palette.controlBgColor,\n                \"--viewer-panel-active\": palette.panelActiveBgColor,\n              } as CSSProperties)\n            : undefined\n        }\n      >\n        <span\n          className=\"inline-flex w-full cursor-pointer items-center\"\n          onClick={(e) => {\n            e.preventDefault();\n            setFolderId(null);\n          }}\n        >\n          <HomeIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n          <span className=\"ml-2 w-fit truncate\" title=\"Home\">\n            Dataroom Home\n          </span>\n        </span>\n      </li>\n    );\n  },\n);\nHomeLink.displayName = \"HomeLink\";\n\nconst SidebarFolders = ({\n  folders,\n  documents,\n  folderId,\n  setFolderId,\n  dataroomIndexEnabled,\n}: {\n  folders: DataroomFolder[];\n  documents: DataroomDocumentWithVersion[];\n  folderId: string | null;\n  setFolderId: React.Dispatch<React.SetStateAction<string | null>>;\n  dataroomIndexEnabled?: boolean;\n}) => {\n  const { usesLightText, palette } = useViewerSurfaceTheme();\n\n  const nestedFolders = useMemo(() => {\n    if (folders) {\n      return buildNestedFolderStructureWithDocs(folders, documents);\n    }\n    return [];\n  }, [folders, documents]);\n\n  const folderPath = useMemo(() => {\n    if (!folderId) {\n      return null;\n    }\n\n    for (let i = 0; i < nestedFolders.length; i++) {\n      const path = findFolderPath(nestedFolders[i], folderId);\n      if (path) {\n        return path;\n      }\n    }\n\n    return null;\n  }, [nestedFolders, folderId]);\n\n  return (\n    <FileTree\n      prefersLightText={usesLightText}\n      style={\n        usesLightText\n          ? ({\n              \"--viewer-text\": palette.textColor,\n              \"--viewer-muted-text\": palette.mutedTextColor,\n              \"--viewer-control-bg\": palette.controlBgColor,\n              \"--viewer-panel-active\": palette.panelActiveBgColor,\n            } as CSSProperties)\n          : undefined\n      }\n    >\n      <HomeLink folderId={folderId} setFolderId={setFolderId} />\n      {nestedFolders.map((folder) => (\n        <FolderComponent\n          key={folder.id}\n          folder={folder}\n          folderId={folderId}\n          setFolderId={setFolderId}\n          folderPath={folderPath}\n          dataroomIndexEnabled={dataroomIndexEnabled}\n        />\n      ))}\n    </FileTree>\n  );\n};\n\nexport function ViewFolderTree({\n  folders,\n  documents,\n  setFolderId,\n  folderId,\n  dataroomIndexEnabled,\n}: {\n  folders: DataroomFolder[];\n  documents: DataroomDocumentWithVersion[];\n  setFolderId: React.Dispatch<React.SetStateAction<string | null>>;\n  folderId: string | null;\n  dataroomIndexEnabled?: boolean;\n}) {\n  if (!folders) return null;\n\n  return (\n    <SidebarFolders\n      folders={folders}\n      documents={documents}\n      setFolderId={setFolderId}\n      folderId={folderId}\n      dataroomIndexEnabled={dataroomIndexEnabled}\n    />\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/add-group-modal.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nexport function AddGroupModal({\n  open,\n  setOpen,\n  onAddition,\n  dataroomId,\n  children,\n}: {\n  open?: boolean;\n  setOpen?: React.Dispatch<React.SetStateAction<boolean>>;\n  onAddition?: (folderName: string) => void;\n  dataroomId?: string;\n  children?: React.ReactNode;\n}) {\n  const [groupName, setGroupName] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n  const addGroupSchema = z.object({\n    name: z.string().min(3, {\n      message: \"Please provide a group name with at least 3 characters.\",\n    }),\n  });\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const validation = addGroupSchema.safeParse({ name: groupName });\n    if (!validation.success) {\n      return toast.error(validation.error.errors[0].message);\n    }\n\n    setLoading(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/groups`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: groupName.trim(),\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message);\n        return;\n      }\n\n      analytics.capture(\"Group Added\", { groupName: groupName, dataroomId });\n      toast.success(\"Group added successfully! 🎉\");\n\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/groups`,\n      );\n    } catch (error) {\n      setLoading(false);\n      toast.error(\"Error adding group. Please try again.\");\n      return;\n    } finally {\n      setLoading(false);\n      setOpen?.(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Add Group</DialogTitle>\n          <DialogDescription>You can easily add a group.</DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"group-name\" className=\"opacity-80\">\n            Group Name\n          </Label>\n          <Input\n            id=\"group-name\"\n            placeholder=\"Management Team\"\n            className=\"mb-4 mt-1 w-full\"\n            onChange={(e) => setGroupName(e.target.value)}\n          />\n          <DialogFooter>\n            <Button type=\"submit\" className=\"h-9 w-full\">\n              Add new group\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/add-member-modal.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nexport function AddGroupMemberModal({\n  dataroomId,\n  groupId,\n  open,\n  setOpen,\n  children,\n}: {\n  dataroomId: string;\n  groupId: string;\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  children?: React.ReactNode;\n}) {\n  const [inputValue, setInputValue] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const analytics = useAnalytics();\n\n  // Email validation regex pattern\n  const validateEmail = (email: string) => {\n    return email.match(\n      /^(([^<>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/,\n    );\n  };\n\n  // Domain validation regex pattern\n  const validateDomain = (domain: string) => {\n    return domain.match(/^@([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$/);\n  };\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const inputValue = e.target.value;\n    setInputValue(inputValue);\n  };\n\n  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const inputs = inputValue\n      .split(\",\")\n      .map((input) => input.trim().toLowerCase())\n      .filter((input) => input);\n\n    if (inputs.length === 0) return;\n\n    setLoading(true);\n\n    // Separate domains and emails\n    const domains = inputs.filter((input) => input.startsWith(\"@\"));\n    const emails = inputs.filter((input) => !input.startsWith(\"@\"));\n\n    // validate emails\n    const invalidEmails = emails.filter((email) => !validateEmail(email));\n    if (invalidEmails.length > 0) {\n      setLoading(false);\n      toast.error(\"Found one or more invalid email addresses.\");\n      return;\n    }\n\n    // validate domains\n    const invalidDomains = domains.filter((domain) => !validateDomain(domain));\n    if (invalidDomains.length > 0) {\n      setLoading(false);\n      toast.error(\n        \"Found one or more invalid domains. Domains should be in format @example.org\",\n      );\n      return;\n    }\n\n    // POST request with emails and domains\n    const response = await fetch(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/members`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          emails,\n          domains,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      const error = await response.json();\n      setLoading(false);\n      setOpen(false);\n      toast.error(error.message || \"Failed to add group members.\");\n      return;\n    }\n\n    analytics.capture(\"Dataroom Group Member Added\", {\n      emailCount: emails.length,\n      domainCount: domains.length,\n      teamId: teamId,\n      dataroomId: dataroomId,\n      groupId: groupId,\n    });\n\n    mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`);\n\n    toast.success(\"Group members added successfully!\");\n    setOpen(false);\n    setInputValue(\"\");\n    setLoading(false);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Add members</DialogTitle>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"email\">Email addresses or domains</Label>\n          <div className=\"flex flex-col gap-2 py-2 text-sm\">\n            <Textarea\n              value={inputValue}\n              onChange={handleInputChange}\n              rows={5}\n              id=\"email\"\n              placeholder=\"jane@acme.com, john@acme.com, @example.org\"\n              className=\"flex-1 bg-muted\"\n              autoComplete=\"off\"\n            />\n            <small className=\"text-xs text-muted-foreground\">\n              Use comma to separate multiple email addresses or domains. For\n              domains, use format @example.org\n            </small>\n          </div>\n\n          <DialogFooter>\n            <Button type=\"submit\" className=\"mt-6 h-9 w-full\" loading={loading}>\n              {loading ? \"Adding members...\" : \"Add members\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/delete-group/delete-group-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { CardDescription, CardTitle } from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Modal } from \"@/components/ui/modal\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nfunction DeleteGroupModal({\n  dataroomId,\n  groupName,\n  groupId,\n  showDeleteGroupModal,\n  setShowDeleteGroupModal,\n}: {\n  dataroomId: string;\n  groupName: string;\n  groupId: string;\n  showDeleteGroupModal: boolean;\n  setShowDeleteGroupModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const analytics = useAnalytics();\n\n  const [deleting, setDeleting] = useState(false);\n\n  async function deleteGroup() {\n    return new Promise((resolve, reject) => {\n      setDeleting(true);\n\n      fetch(`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`, {\n        method: \"DELETE\",\n      }).then(async (res) => {\n        if (res.ok) {\n          analytics.capture(\"Group Deleted\", {\n            dataroomId: dataroomId,\n            groupName: groupName,\n            groupId: groupId,\n          });\n          await mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/groups`);\n          router.push(`/datarooms/${dataroomId}/groups`);\n          resolve(null);\n        } else {\n          setDeleting(false);\n          const error = await res.json();\n          reject(error.message);\n        }\n      });\n    });\n  }\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <Modal\n      showModal={showDeleteGroupModal}\n      setShowModal={setShowDeleteGroupModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <CardTitle>Delete Group</CardTitle>\n        <CardDescription>\n          Warning: This will permanently delete your dataroom group, all\n          associated links and their respective views.\n        </CardDescription>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          toast.promise(deleteGroup(), {\n            loading: \"Deleting group...\",\n            success: \"Group deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <div>\n          <label\n            htmlFor=\"group-name\"\n            className=\"block text-sm font-medium text-muted-foreground\"\n          >\n            Enter the group name{\" \"}\n            <span className=\"font-semibold text-foreground\">{groupName}</span>{\" \"}\n            to continue:\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              type=\"text\"\n              name=\"group-name\"\n              id=\"group-name\"\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              required\n              pattern={groupName}\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n            />\n          </div>\n        </div>\n\n        <Button variant=\"destructive\" loading={deleting}>\n          Confirm delete group\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteGroupModal({\n  dataroomId,\n  groupId,\n  groupName,\n}: {\n  dataroomId: string;\n  groupId: string;\n  groupName: string;\n}) {\n  const [showDeleteGroupModal, setShowDeleteGroupModal] = useState(false);\n\n  const DeleteGroupModalCallback = useCallback(() => {\n    return (\n      <DeleteGroupModal\n        dataroomId={dataroomId}\n        groupId={groupId}\n        groupName={groupName}\n        showDeleteGroupModal={showDeleteGroupModal}\n        setShowDeleteGroupModal={setShowDeleteGroupModal}\n      />\n    );\n  }, [showDeleteGroupModal, setShowDeleteGroupModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteGroupModal,\n      DeleteGroupModal: DeleteGroupModalCallback,\n    }),\n    [setShowDeleteGroupModal, DeleteGroupModalCallback],\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/delete-group/index.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\n\nimport { useDeleteGroupModal } from \"./delete-group-modal\";\n\nexport default function DeleteGroup({\n  dataroomId,\n  groupName,\n  groupId,\n}: {\n  dataroomId: string;\n  groupName: string;\n  groupId: string;\n}) {\n  const { setShowDeleteGroupModal, DeleteGroupModal } = useDeleteGroupModal({\n    dataroomId,\n    groupId,\n    groupName,\n  });\n\n  return (\n    <div className=\"rounded-lg\">\n      <DeleteGroupModal />\n      <Card className=\"border-destructive bg-transparent\">\n        <CardHeader>\n          <CardTitle>Delete Group</CardTitle>\n          <CardDescription>\n            Permanently delete your group. <br />\n            <span className=\"font-medium\">This action cannot be undone</span> -\n            please proceed with caution.\n          </CardDescription>\n        </CardHeader>\n        <CardContent></CardContent>\n        <CardFooter className=\"flex items-center justify-end rounded-b-lg border-t px-6 py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              onClick={() => setShowDeleteGroupModal(true)}\n              variant=\"destructive\"\n            >\n              Delete Group\n            </Button>\n          </div>\n        </CardFooter>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/group-card-placeholder.tsx",
    "content": "import {\n  Card,\n  CardContent,\n  CardFooter,\n  CardHeader,\n} from \"@/components/ui/card\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport function GroupCardPlaceholder() {\n  return (\n    <Card className=\"w-full\">\n      <CardHeader className=\"space-y-2\">\n        <Skeleton className=\"h-6 w-1/2\" />\n        <Skeleton className=\"h-4 w-3/4\" />\n      </CardHeader>\n      <CardContent>\n        <Skeleton className=\"h-4 w-full\" />\n        <Skeleton className=\"mt-2 h-4 w-4/5\" />\n      </CardContent>\n      <CardFooter className=\"flex justify-between\">\n        <Skeleton className=\"h-8 w-20\" />\n        <Skeleton className=\"h-8 w-8 rounded-full\" />\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/group-card.tsx",
    "content": "import { useState } from \"react\";\n\nimport { ViewerGroup } from \"@prisma/client\";\nimport { BoxesIcon, Layers2Icon, PenIcon } from \"lucide-react\";\n\nimport BarChart from \"@/components/shared/icons/bar-chart\";\nimport MoreVertical from \"@/components/shared/icons/more-vertical\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { cn, nFormatter } from \"@/lib/utils\";\n\nexport default function GroupCard({\n  group,\n}: {\n  group: ViewerGroup & { _count: { members: number; views: number } };\n}) {\n  const [menuOpen, setMenuOpen] = useState(false);\n\n  return (\n    <>\n      <div className=\"hover:drop-shadow-card-hover group rounded-xl border border-gray-200 bg-white p-4 transition-[filter] dark:bg-gray-800 sm:p-5\">\n        <div className=\"flex items-center justify-between gap-3 sm:gap-4\">\n          <div className=\"flex min-w-0 items-center gap-4\">\n            <div className=\"hidden rounded-full border border-gray-200 sm:block\">\n              <div\n                className={cn(\n                  \"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3\",\n                )}\n              >\n                <BoxesIcon className=\"size-5\" />\n              </div>\n            </div>\n            <div className=\"overflow-hidden\">\n              <div className=\"flex flex-col gap-1\">\n                <p className=\"truncate text-sm font-medium text-foreground\">\n                  {group.name}\n                </p>\n                <span className=\"text-xs text-muted-foreground\">\n                  {group._count.members} members\n                </span>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex justify-end gap-2 sm:gap-3\">\n            <div className=\"z-20 flex items-center space-x-1 rounded-md bg-gray-200 px-1.5 py-0.5 transition-all duration-75 hover:scale-105 active:scale-100 dark:bg-gray-700 sm:px-2\">\n              <BarChart className=\"h-3 w-3 text-muted-foreground sm:h-4 sm:w-4\" />\n              <p className=\"whitespace-nowrap text-xs text-muted-foreground sm:text-sm\">\n                {nFormatter(group._count.views)}\n                <span className=\"ml-1 hidden sm:inline-block\">views</span>\n              </p>\n            </div>\n\n            <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n              <DropdownMenuTrigger asChild>\n                <Button\n                  // size=\"icon\"\n                  variant=\"outline\"\n                  className=\"z-20 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n                >\n                  <span className=\"sr-only\">Open menu</span>\n                  <MoreVertical className=\"h-4 w-4\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                <DropdownMenuItem>\n                  <PenIcon className=\"mr-2 h-4 w-4\" />\n                  Edit group\n                </DropdownMenuItem>\n                <DropdownMenuItem>\n                  <Layers2Icon className=\"mr-2 h-4 w-4\" />\n                  Duplicate\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/group-header.tsx",
    "content": "import Link from \"next/link\";\n\nimport { ChevronRightIcon } from \"lucide-react\";\n\nexport const GroupHeader = ({\n  dataroomId,\n  groupName,\n}: {\n  dataroomId: string;\n  groupName: string;\n}) => {\n  return (\n    <div className=\"mx-auto !mt-4 flex w-full items-center gap-2\">\n      <Link href={`/datarooms/${dataroomId}/groups`}>\n        <h2 className=\"text-md underline decoration-gray-500 underline-offset-4\">\n          All Groups\n        </h2>\n      </Link>\n      <ChevronRightIcon className=\"h-4 w-4\" />\n      <h2 className=\"text-md font-medium text-primary\">\n        {groupName ?? \"Management Team\"}\n      </h2>\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/datarooms/groups/group-member-table.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { useUninvitedMembers } from \"@/ee/features/dataroom-invitations/lib/swr/use-dataroom-invitations\";\nimport {\n  MailCheckIcon,\n  MoreHorizontalIcon,\n  PlusCircleIcon,\n  SendIcon,\n  UserXIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomGroup } from \"@/lib/swr/use-dataroom-groups\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { VisitorAvatar } from \"@/components/visitors/visitor-avatar\";\n\nimport { InviteViewersModal } from \"../../../ee/features/dataroom-invitations/components/invite-viewers-modal\";\nimport { AddGroupMemberModal } from \"./add-member-modal\";\n\nexport default function GroupMemberTable({\n  dataroomId,\n  groupId,\n}: {\n  dataroomId: string;\n  groupId: string;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { dataroom } = useDataroom();\n  const { viewerGroupMembers, viewerGroupDomains, viewerGroupAllowAll } =\n    useDataroomGroup();\n  const {\n    uninvitedCount,\n    uninvitedEmails,\n    mutate: mutateUninvited,\n  } = useUninvitedMembers(dataroomId, groupId);\n  const { isFeatureEnabled } = useFeatureFlags();\n\n  const [addMembersOpen, setAddMembersOpen] = useState<boolean>(false);\n  const [inviteOpen, setInviteOpen] = useState<boolean>(false);\n\n  const groupKey = teamId\n    ? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`\n    : null;\n\n  const handleToggleAllowAll = async () => {\n    const key =\n      groupKey ??\n      `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;\n    const response = await fetch(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,\n      {\n        method: \"PATCH\",\n        body: JSON.stringify({\n          allowAll: !viewerGroupAllowAll,\n        }),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n\n    if (!response.ok) {\n      mutate(key);\n      toast.error(\"Failed to update group settings\");\n      return;\n    }\n    mutate(key);\n    toast.success(\n      viewerGroupAllowAll\n        ? \"Group access restricted to specific members and domains\"\n        : \"Group now allows access from any email\",\n    );\n  };\n\n  const handleRemoveDomain = async (domain: string) => {\n    const key =\n      groupKey ??\n      `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;\n    const response = await fetch(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,\n      {\n        method: \"PATCH\",\n        body: JSON.stringify({\n          domains: viewerGroupDomains.filter((d) => d !== domain),\n        }),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n\n    if (!response.ok) {\n      mutate(key);\n      toast.error(\"Failed to remove domain\");\n      return;\n    }\n    mutate(key);\n    toast.success(\"Domain removed successfully\");\n  };\n\n  const handleRemoveMember = async (id: string) => {\n    // mutate the data optimistically\n    const key =\n      groupKey ??\n      `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;\n    mutate(\n      key,\n      (currentData: { members: any[] } | undefined) => {\n        if (!currentData) return currentData;\n        return {\n          ...currentData,\n          viewerGroupMembers: currentData.members.filter(\n            (member) => member.id !== id,\n          ),\n        };\n      },\n      false,\n    );\n\n    const response = await fetch(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/members/${id}`,\n      {\n        method: \"DELETE\",\n      },\n    );\n\n    if (!response.ok) {\n      mutate(key);\n      toast.error(\"Failed to remove member\");\n      return;\n    }\n    mutate(key);\n    toast.success(\"Member removed successfully\");\n  };\n\n  return (\n    <>\n      <div className=\"l\">\n        <div className=\"mb-2 flex items-center justify-between md:mb-4\">\n          <h2>All members</h2>\n          <div className=\"flex items-center gap-4\">\n            <div className=\"flex items-center gap-2\">\n              <Switch\n                checked={viewerGroupAllowAll}\n                onCheckedChange={handleToggleAllowAll}\n                aria-label=\"Allow all emails\"\n              />\n              <span className=\"text-sm text-muted-foreground\">\n                Allow all emails\n              </span>\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                size=\"sm\"\n                onClick={() => setAddMembersOpen(true)}\n                className=\"h-8 gap-1\"\n                disabled={viewerGroupAllowAll}\n              >\n                <PlusCircleIcon className=\"h-4 w-4\" />\n                Add members\n              </Button>\n              {isFeatureEnabled(\"dataroomInvitations\") && (\n                <Button\n                  size=\"sm\"\n                  variant=\"secondary\"\n                  onClick={() => setInviteOpen(true)}\n                  className=\"relative h-8 gap-1\"\n                >\n                  <SendIcon className=\"h-4 w-4\" />\n                  Share invite\n                  {uninvitedCount > 0 ? (\n                    <Badge\n                      variant=\"secondary\"\n                      className=\"ml-2 h-5 rounded-full px-2 text-xs font-medium\"\n                    >\n                      {uninvitedCount}\n                    </Badge>\n                  ) : null}\n                </Button>\n              )}\n            </div>\n          </div>\n        </div>\n        <div className=\"rounded-md border\">\n          <Table>\n            <TableHeader>\n              <TableRow className=\"*:whitespace-nowrap *:font-medium hover:bg-transparent\">\n                <TableHead>Name</TableHead>\n                {/* <TableHead>Visit Duration</TableHead> */}\n                {/* <TableHead>Last Viewed Document</TableHead> */}\n                {/* <TableHead>Last Viewed</TableHead> */}\n                <TableHead className=\"text-center\"></TableHead>\n              </TableRow>\n            </TableHeader>\n            <TableBody>\n              {viewerGroupAllowAll ? (\n                <TableRow>\n                  <TableCell colSpan={2}>\n                    <div className=\"flex h-40 w-full items-center justify-center\">\n                      <p className=\"text-sm text-muted-foreground\">\n                        All email addresses are allowed to access this group\n                      </p>\n                    </div>\n                  </TableCell>\n                </TableRow>\n              ) : viewerGroupMembers.length === 0 &&\n                viewerGroupDomains.length === 0 ? (\n                <TableRow>\n                  <TableCell colSpan={2}>\n                    <div className=\"flex h-40 w-full items-center justify-center\">\n                      <p>No views yet. Try sharing a link.</p>\n                    </div>\n                  </TableCell>\n                </TableRow>\n              ) : (\n                <>\n                  {viewerGroupDomains.length > 0 &&\n                    viewerGroupDomains.map((domain) => (\n                      <TableRow key={domain} className=\"group/row\">\n                        <TableCell className=\"\">\n                          <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                            <VisitorAvatar viewerEmail={\"@\"} />\n                            <div className=\"min-w-0 flex-1\">\n                              <div className=\"focus:outline-none\">\n                                <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                                  {domain}\n                                </p>\n                              </div>\n                            </div>\n                          </div>\n                        </TableCell>\n                        {/* Actions */}\n                        <TableCell className=\"p-0 text-center\">\n                          <DropdownMenu>\n                            <DropdownMenuTrigger asChild>\n                              <Button\n                                variant=\"ghost\"\n                                className=\"h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700\"\n                              >\n                                <span className=\"sr-only\">Open menu</span>\n                                <MoreHorizontalIcon className=\"h-4 w-4\" />\n                              </Button>\n                            </DropdownMenuTrigger>\n                            <DropdownMenuContent align=\"end\">\n                              <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                              <DropdownMenuSeparator />\n                              <DropdownMenuItem\n                                className=\"gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground\"\n                                onClick={() => handleRemoveDomain(domain)}\n                              >\n                                <XIcon className=\"h-4 w-4\" />\n                                Remove Domain\n                              </DropdownMenuItem>\n                            </DropdownMenuContent>\n                          </DropdownMenu>\n                        </TableCell>\n                      </TableRow>\n                    ))}\n                  {viewerGroupMembers ? (\n                    viewerGroupMembers.map((viewer) => {\n                      const latestInvitation = viewer.viewer.invitations?.[0];\n                      return (\n                        <TableRow key={viewer.id} className=\"group/row\">\n                          {/* Name */}\n                          <TableCell className=\"\">\n                            <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                              <VisitorAvatar\n                                viewerEmail={viewer.viewer.email}\n                              />\n                              <div className=\"min-w-0 flex-1\">\n                                <div className=\"focus:outline-none\">\n                                  <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                                    {viewer.viewer.email}\n                                    {latestInvitation && (\n                                      <TimestampTooltip\n                                        timestamp={latestInvitation.sentAt}\n                                        side=\"right\"\n                                        rows={[\"local\", \"utc\"]}\n                                      >\n                                        <MailCheckIcon className=\"h-4 w-4 text-blue-500 hover:text-blue-600\" />\n                                      </TimestampTooltip>\n                                    )}\n                                  </p>\n                                </div>\n                              </div>\n                            </div>\n                          </TableCell>\n                          {/* Last Viewed */}\n                          {/* <TableCell className=\"text-sm text-muted-foreground\">\n                        <time\n                          dateTime={new Date(viewer.viewer.updatedAt).toISOString()}\n                        >\n                          {viewer.viewer.updatedAt\n                            ? timeAgo(viewer.viewer.updatedAt)\n                            : \"-\"}\n                        </time>\n                      </TableCell> */}\n                          {/* Actions */}\n                          <TableCell className=\"p-0 text-center\">\n                            <DropdownMenu>\n                              <DropdownMenuTrigger asChild>\n                                <Button\n                                  variant=\"ghost\"\n                                  className=\"h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700\"\n                                >\n                                  <span className=\"sr-only\">Open menu</span>\n                                  <MoreHorizontalIcon className=\"h-4 w-4\" />\n                                </Button>\n                              </DropdownMenuTrigger>\n                              <DropdownMenuContent align=\"end\">\n                                <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                                <DropdownMenuSeparator />\n                                <DropdownMenuItem\n                                  className=\"gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground\"\n                                  onClick={() => handleRemoveMember(viewer.id)}\n                                >\n                                  <UserXIcon className=\"h-4 w-4\" />\n                                  Remove Member\n                                </DropdownMenuItem>\n                              </DropdownMenuContent>\n                            </DropdownMenu>\n                          </TableCell>\n                        </TableRow>\n                      );\n                    })\n                  ) : (\n                    <TableRow>\n                      <TableCell className=\"min-w-[100px]\">\n                        <Skeleton className=\"h-6 w-full\" />\n                      </TableCell>\n                      <TableCell>\n                        <Skeleton className=\"h-6 w-24\" />\n                      </TableCell>\n                    </TableRow>\n                  )}\n                </>\n              )}\n            </TableBody>\n          </Table>\n        </div>\n      </div>\n      <AddGroupMemberModal\n        dataroomId={dataroomId}\n        groupId={groupId}\n        open={addMembersOpen}\n        setOpen={setAddMembersOpen}\n      />\n      {isFeatureEnabled(\"dataroomInvitations\") && (\n        <InviteViewersModal\n          open={inviteOpen}\n          setOpen={(next) => {\n            setInviteOpen(next);\n            if (!next) {\n              mutateUninvited();\n            }\n          }}\n          dataroomId={dataroomId}\n          dataroomName={dataroom?.name ?? \"this dataroom\"}\n          groupId={groupId}\n          defaultEmails={uninvitedEmails}\n          onSuccess={() => {\n            if (groupKey) {\n              mutate(groupKey);\n            }\n            mutateUninvited();\n          }}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/group-navigation.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport {\n  ChartColumnIcon,\n  CogIcon,\n  FileSlidersIcon,\n  LinkIcon,\n  UsersIcon,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport const GroupNavigation = ({\n  dataroomId,\n  viewerGroupId,\n}: {\n  dataroomId: string;\n  viewerGroupId: string;\n}) => {\n  const router = useRouter();\n\n  return (\n    <nav className=\"grid space-y-1 text-sm\">\n      <Link\n        href={`/datarooms/${dataroomId}/groups/${viewerGroupId}`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\":\n              router.pathname === `/datarooms/[id]/groups/[groupId]`,\n          },\n        )}\n      >\n        <CogIcon className=\"h-4 w-4\" />\n        General\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/groups/${viewerGroupId}/members`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"members\"),\n          },\n        )}\n      >\n        <UsersIcon className=\"h-4 w-4\" />\n        Members\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/groups/${viewerGroupId}/permissions`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"permissions\"),\n          },\n        )}\n      >\n        <FileSlidersIcon className=\"h-4 w-4\" />\n        Permissions\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/groups/${viewerGroupId}/links`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"links\"),\n          },\n        )}\n      >\n        <LinkIcon className=\"h-4 w-4\" />\n        Links\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/groups/${viewerGroupId}/group-analytics`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"group-analytics\"),\n          },\n        )}\n      >\n        <ChartColumnIcon className=\"h-4 w-4\" />\n        Group analytics\n      </Link>\n    </nav>\n  );\n};\n"
  },
  {
    "path": "components/datarooms/groups/group-permissions.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ItemType, ViewerGroupAccessControls } from \"@prisma/client\";\nimport {\n  ColumnDef,\n  flexRender,\n  getCoreRowModel,\n  getExpandedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport {\n  ArrowDownToLineIcon,\n  ChevronDown,\n  ChevronRight,\n  EyeIcon,\n  EyeOffIcon,\n  File,\n  Folder,\n  HomeIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useDebounce } from \"use-debounce\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { useDataroomFoldersTree } from \"@/lib/swr/use-dataroom\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  getHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport CloudDownloadOff from \"@/components/shared/icons/cloud-download-off\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\n\nconst PermissionItemName = ({ item }: { item: FileOrFolder }) => {\n  const { isFeatureEnabled } = useFeatureFlags();\n  const isDataroomIndexEnabled = isFeatureEnabled(\"dataroomIndex\");\n\n  const displayName = getHierarchicalDisplayName(\n    item.name,\n    item.hierarchicalIndex,\n    isDataroomIndexEnabled,\n  );\n\n  const isRoot = item.id === \"__dataroom_root__\";\n\n  return (\n    <div className=\"flex items-center text-foreground\">\n      {isRoot ? (\n        <HomeIcon className=\"mr-2 h-5 w-5\" />\n      ) : item.itemType === ItemType.DATAROOM_FOLDER ? (\n        <Folder className=\"mr-2 h-5 w-5\" />\n      ) : (\n        <File className=\"mr-2 h-5 w-5\" />\n      )}\n      <span className=\"truncate\" style={HIERARCHICAL_DISPLAY_STYLE}>\n        {displayName}\n      </span>\n    </div>\n  );\n};\n\n// Update the FileOrFolder type to include permissions\ntype FileOrFolder = {\n  id: string;\n  name: string;\n  hierarchicalIndex?: string | null;\n  subItems?: FileOrFolder[];\n  permissions: {\n    view: boolean;\n    download: boolean;\n    partialView?: boolean;\n    partialDownload?: boolean;\n  };\n  itemType: ItemType;\n  documentId?: string;\n};\n\ntype ItemPermission = Record<\n  string,\n  { view: boolean; download: boolean; itemType: ItemType }\n>;\n\ntype ColumnExtra = {\n  updatePermissions: (id: string, newPermissions: string[]) => void;\n};\n\nconst createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [\n  {\n    accessorKey: \"name\",\n    header: \"Name\",\n    cell: ({ row }) => {\n      const isRoot = row.original.id === \"__dataroom_root__\";\n      return (\n        <div className=\"flex items-center text-foreground\">\n          {isRoot ? (\n            <div className=\"h-6 w-6 shrink-0\" />\n          ) : row.getCanExpand() ? (\n            <Button\n              variant=\"ghost\"\n              onClick={row.getToggleExpandedHandler()}\n              className=\"mr-1 h-6 w-6 shrink-0 p-0\"\n              disabled={isRoot}\n            >\n              {row.getIsExpanded() ? (\n                <ChevronDown className=\"h-4 w-4\" />\n              ) : (\n                <ChevronRight className=\"h-4 w-4\" />\n              )}\n            </Button>\n          ) : (\n            <div className=\"mr-1 h-6 w-6 shrink-0\" />\n          )}\n          <PermissionItemName item={row.original} />\n        </div>\n      );\n    },\n  },\n  {\n    id: \"actions\",\n    header: \"Actions\",\n    cell: ({ row }) => {\n      const item = row.original;\n\n      const handleValueChange = (value: string[]) => {\n        extra.updatePermissions(item.id, value);\n      };\n\n      return (\n        <ToggleGroup\n          type=\"multiple\"\n          value={Object.entries(item.permissions)\n            .filter(([_, value]) => value)\n            .map(([key, _]) => key)}\n          onValueChange={handleValueChange}\n        >\n          <ToggleGroupItem\n            value=\"view\"\n            aria-label=\"Toggle view\"\n            size=\"sm\"\n            className={cn(\n              \"px-2 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n              item.permissions.view\n                ? item.permissions.partialView\n                  ? \"data-[state=on]:bg-gray-400 data-[state=on]:text-background\"\n                  : \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                : \"\",\n            )}\n          >\n            {item.permissions.view ||\n            (item.permissions.view && item.permissions.partialView) ? (\n              <EyeIcon className=\"h-5 w-5\" />\n            ) : (\n              <EyeOffIcon className=\"h-5 w-5\" />\n            )}\n          </ToggleGroupItem>\n          <ToggleGroupItem\n            value=\"download\"\n            aria-label=\"Toggle download\"\n            size=\"sm\"\n            className={cn(\n              \"px-2 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n              item.permissions.download\n                ? item.permissions.partialDownload\n                  ? \"data-[state=on]:bg-gray-400 data-[state=on]:text-background\"\n                  : \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                : \"\",\n            )}\n          >\n            {item.permissions.download ||\n            (item.permissions.download && item.permissions.partialDownload) ? (\n              <ArrowDownToLineIcon className=\"h-5 w-5\" />\n            ) : (\n              <CloudDownloadOff className=\"h-5 w-5\" />\n            )}\n          </ToggleGroupItem>\n        </ToggleGroup>\n      );\n    },\n  },\n];\n\n// Build tree function to include permissions\nconst buildTree = (\n  items: any[],\n  permissions: ViewerGroupAccessControls[],\n  parentId: string | null = null,\n): FileOrFolder[] => {\n  const getPermissions = (id: string) => {\n    const permission = permissions.find((p) => p.itemId === id);\n\n    // If we have permission data loaded, use it. Otherwise default to true for consistency.\n    const hasPermissionData = permissions.length > 0;\n\n    return {\n      view: permission ? permission.canView : hasPermissionData ? false : true,\n      download: permission ? permission.canDownload : false,\n      partialView: false,\n      partialDownload: false,\n    };\n  };\n\n  const result: FileOrFolder[] = [];\n\n  // Handle folders and their contents\n  items\n    .filter((item) => item.parentId === parentId && !item.document)\n    .forEach((folder) => {\n      const subItems = buildTree(items, permissions, folder.id);\n\n      // Add documents directly in this folder\n      const folderDocuments = (folder.documents || []).map((doc: any) => ({\n        id: doc.id,\n        documentId: doc.document.id,\n        name: doc.document.name,\n        hierarchicalIndex: doc.hierarchicalIndex,\n        permissions: getPermissions(doc.id),\n        itemType: ItemType.DATAROOM_DOCUMENT,\n      }));\n\n      const allSubItems = [...subItems, ...folderDocuments];\n\n      const folderPermissions = getPermissions(folder.id);\n\n      // Calculate view and partialView for folders\n      let viewStatus = folderPermissions.view;\n      let partialView = false;\n      let downloadStatus = folderPermissions.download;\n      let partialDownload = false;\n\n      if (allSubItems.length > 0) {\n        const viewableItems = allSubItems.filter(\n          (item) => item.permissions.view,\n        );\n        const downloadableItems = allSubItems.filter(\n          (item) => item.permissions.download,\n        );\n\n        viewStatus = viewableItems.length > 0;\n        partialView =\n          viewableItems.length > 0 && viewableItems.length < allSubItems.length;\n        downloadStatus = downloadableItems.length > 0;\n        partialDownload =\n          downloadableItems.length > 0 &&\n          downloadableItems.length < allSubItems.length;\n      }\n\n      result.push({\n        id: folder.id,\n        name: folder.name,\n        hierarchicalIndex: folder.hierarchicalIndex,\n        subItems: allSubItems,\n        permissions: {\n          view: viewStatus,\n          download: downloadStatus,\n          partialView,\n          partialDownload,\n        },\n        itemType: ItemType.DATAROOM_FOLDER,\n      });\n    });\n\n  // Handle documents at the current level (including root level)\n  items\n    .filter(\n      (item) =>\n        (item.parentId === parentId && item.document) ||\n        (parentId === null && item.folderId === null && item.document),\n    )\n    .forEach((doc) => {\n      result.push({\n        id: doc.id,\n        documentId: doc.document.id,\n        name: doc.document.name,\n        hierarchicalIndex: doc.hierarchicalIndex,\n        permissions: getPermissions(doc.id),\n        itemType: ItemType.DATAROOM_DOCUMENT,\n      });\n    });\n\n  return result;\n};\n\n// Build tree with virtual root folder\nconst buildTreeWithRoot = (\n  items: any[],\n  permissions: ViewerGroupAccessControls[],\n  dataroomName: string = \"Dataroom Home\",\n): FileOrFolder[] => {\n  // Get all items (folders and root documents)\n  const allItems = buildTree(items, permissions, null);\n\n  // Calculate overall permissions for the virtual root\n  const calculateRootPermissions = (items: FileOrFolder[]) => {\n    const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {\n      return items.reduce((acc, item) => {\n        acc.push(item);\n        if (item.subItems) {\n          acc.push(...flattenItems(item.subItems));\n        }\n        return acc;\n      }, [] as FileOrFolder[]);\n    };\n\n    const allFlatItems = flattenItems(items);\n    const viewableItems = allFlatItems.filter((item) => item.permissions.view);\n    const downloadableItems = allFlatItems.filter(\n      (item) => item.permissions.download,\n    );\n\n    return {\n      view: viewableItems.length > 0,\n      download: downloadableItems.length > 0,\n      partialView:\n        viewableItems.length > 0 && viewableItems.length < allFlatItems.length,\n      partialDownload:\n        downloadableItems.length > 0 &&\n        downloadableItems.length < allFlatItems.length,\n    };\n  };\n\n  const rootPermissions = calculateRootPermissions(allItems);\n\n  return [\n    {\n      id: \"__dataroom_root__\",\n      name: dataroomName,\n      subItems: allItems,\n      permissions: rootPermissions,\n      itemType: ItemType.DATAROOM_FOLDER,\n    },\n  ];\n};\n\nexport default function ExpandableTable({\n  dataroomId,\n  groupId,\n  permissions,\n}: {\n  dataroomId: string;\n  groupId: string;\n  permissions: ViewerGroupAccessControls[];\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { folders, loading } = useDataroomFoldersTree({\n    dataroomId,\n    include_documents: true,\n  });\n  const [data, setData] = useState<FileOrFolder[]>([]);\n  const [pendingChanges, setPendingChanges] = useState<ItemPermission>({});\n  const [debouncedPendingChanges] = useDebounce(pendingChanges, 2000);\n\n  // Use ref to access current data without dependency\n  const dataRef = useRef<FileOrFolder[]>([]);\n\n  useEffect(() => {\n    dataRef.current = data;\n  }, [data]);\n\n  const updatePermissions = useCallback(\n    (id: string, newPermissions: string[]) => {\n      const isRoot = id === \"__dataroom_root__\";\n\n      const findItemAndParents = (\n        items: FileOrFolder[],\n        targetId: string,\n        parents: FileOrFolder[] = [],\n      ): { item: FileOrFolder; parents: FileOrFolder[] } | null => {\n        for (const item of items) {\n          if (item.id === targetId) {\n            return { item, parents };\n          }\n          if (item.subItems) {\n            const result = findItemAndParents(item.subItems, targetId, [\n              ...parents,\n              item,\n            ]);\n            if (result) return result;\n          }\n        }\n        return null;\n      };\n\n      const result = findItemAndParents(dataRef.current, id);\n      if (!result) return;\n\n      const { item, parents } = result;\n\n      const updatedPermissions = {\n        view: newPermissions.includes(\"view\"),\n        download: newPermissions.includes(\"download\"),\n        partialView: newPermissions.includes(\"partialView\"),\n        partialDownload: newPermissions.includes(\"partialDownload\"),\n      };\n\n      // Special cases\n      if (!updatedPermissions.view && item.permissions.download) {\n        updatedPermissions.download = false;\n      } else if (updatedPermissions.download && !updatedPermissions.view) {\n        updatedPermissions.view = true;\n      }\n\n      if (updatedPermissions.partialDownload) {\n        updatedPermissions.download = true;\n      }\n\n      if (updatedPermissions.partialView) {\n        updatedPermissions.view = true;\n      }\n\n      // Handle root-level permissions (affects all items)\n      if (isRoot) {\n        setData((prevData) => {\n          const updateAllItems = (items: FileOrFolder[]): FileOrFolder[] => {\n            return items.map((currentItem) => {\n              if (currentItem.id === \"__dataroom_root__\") {\n                return {\n                  ...currentItem,\n                  permissions: {\n                    view: updatedPermissions.view,\n                    download: updatedPermissions.download,\n                    partialView: false,\n                    partialDownload: false,\n                  },\n                  subItems: currentItem.subItems\n                    ? updateAllItems(currentItem.subItems)\n                    : undefined,\n                };\n              }\n\n              const updatedItem = {\n                ...currentItem,\n                permissions: {\n                  view: updatedPermissions.view,\n                  download: updatedPermissions.download,\n                  partialView: false,\n                  partialDownload: false,\n                },\n                subItems: currentItem.subItems\n                  ? updateAllItems(currentItem.subItems)\n                  : undefined,\n              };\n\n              return updatedItem;\n            });\n          };\n\n          return updateAllItems(prevData);\n        });\n\n        // Collect changes for all items\n        const collectAllChanges = (items: FileOrFolder[]): ItemPermission => {\n          let changes: ItemPermission = {};\n\n          const processItems = (items: FileOrFolder[]) => {\n            items.forEach((item) => {\n              // Don't save the virtual __dataroom_root__ item to database\n              if (item.id !== \"__dataroom_root__\") {\n                changes[item.id] = {\n                  view: updatedPermissions.view,\n                  download: updatedPermissions.download,\n                  itemType: item.itemType,\n                };\n              }\n\n              if (item.subItems) {\n                processItems(item.subItems);\n              }\n            });\n          };\n\n          processItems(items);\n          return changes;\n        };\n\n        const rootChanges = collectAllChanges(dataRef.current);\n        setPendingChanges((prev) => ({\n          ...prev,\n          ...rootChanges,\n        }));\n\n        return;\n      }\n\n      setData((prevData) => {\n        const updateItemInTree = (items: FileOrFolder[]): FileOrFolder[] => {\n          return items.map((currentItem) => {\n            if (currentItem.id === id) {\n              const updatedItem = {\n                ...currentItem,\n                permissions: {\n                  view: updatedPermissions.view,\n                  download: updatedPermissions.download,\n                  partialView: false,\n                  partialDownload: false,\n                },\n              };\n\n              // If it's a folder, update all subitems\n              if (updatedItem.itemType === ItemType.DATAROOM_FOLDER) {\n                updatedItem.subItems = updateSubItems(\n                  updatedItem.subItems || [],\n                  updatedPermissions.view,\n                  updatedPermissions.download,\n                );\n              }\n\n              return updatedItem;\n            }\n\n            // if the current item is a parent of the updated item, update the parent's permissions\n            if (parents.some((parent) => parent.id === currentItem.id)) {\n              const updatedSubItems = currentItem.subItems\n                ? updateItemInTree(currentItem.subItems)\n                : [];\n              return updateParentPermissions(currentItem, updatedSubItems);\n            }\n\n            // if the current item has subitems, update the subitems\n            if (currentItem.subItems) {\n              return {\n                ...currentItem,\n                subItems: updateItemInTree(currentItem.subItems),\n              };\n            }\n            return currentItem;\n          });\n        };\n\n        const updateSubItems = (\n          items: FileOrFolder[],\n          viewState: boolean,\n          downloadState: boolean,\n        ): FileOrFolder[] => {\n          return items.map((item) => ({\n            ...item,\n            permissions: {\n              ...item.permissions,\n              view: viewState,\n              partialView: false,\n              partialDownload: false,\n              download: downloadState,\n            },\n            subItems: item.subItems\n              ? updateSubItems(item.subItems, viewState, downloadState)\n              : undefined,\n          }));\n        };\n\n        const updateParentPermissions = (\n          parent: FileOrFolder,\n          subItems: FileOrFolder[],\n        ): FileOrFolder => {\n          const isParentRoot = parent.id === \"__dataroom_root__\";\n\n          // For root folder, calculate based on all descendants\n          const calculatePermissions = (items: FileOrFolder[]) => {\n            const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {\n              return items.reduce((acc, item) => {\n                if (item.id !== \"__dataroom_root__\") {\n                  acc.push(item);\n                }\n                if (item.subItems) {\n                  acc.push(...flattenItems(item.subItems));\n                }\n                return acc;\n              }, [] as FileOrFolder[]);\n            };\n\n            const allItems = flattenItems(items);\n            const viewableItems = allItems.filter(\n              (item) => item.permissions.view,\n            );\n            const downloadableItems = allItems.filter(\n              (item) => item.permissions.download,\n            );\n\n            return {\n              view: viewableItems.length > 0,\n              partialView:\n                viewableItems.length > 0 &&\n                viewableItems.length < allItems.length,\n              download: downloadableItems.length > 0,\n              partialDownload:\n                downloadableItems.length > 0 &&\n                downloadableItems.length < allItems.length,\n            };\n          };\n\n          if (isParentRoot) {\n            const rootPermissions = calculatePermissions(subItems);\n            return {\n              ...parent,\n              permissions: rootPermissions,\n              subItems,\n            };\n          }\n\n          // For regular folders\n          const someSubItemViewable = subItems.some(\n            (subItem) => subItem.permissions.view,\n          );\n          const allSubItemsViewable = subItems.every(\n            (subItem) => subItem.permissions.view,\n          );\n          const someSubItemDownloadable = subItems.some(\n            (subItem) => subItem.permissions.download,\n          );\n          const allSubItemsDownloadable = subItems.every(\n            (subItem) => subItem.permissions.download,\n          );\n\n          return {\n            ...parent,\n            permissions: {\n              view: someSubItemViewable,\n              partialView: someSubItemViewable && !allSubItemsViewable,\n              download: someSubItemDownloadable,\n              partialDownload:\n                someSubItemDownloadable && !allSubItemsDownloadable,\n            },\n            subItems,\n          };\n        };\n\n        return updateItemInTree(prevData);\n      });\n\n      // Collect changes for database update\n      const collectChanges = (\n        item: FileOrFolder,\n        parents: FileOrFolder[],\n      ): ItemPermission => {\n        let changes: ItemPermission = {};\n\n        // Don't save the virtual __dataroom_root__ item to database\n        if (item.id !== \"__dataroom_root__\") {\n          changes[item.id] = {\n            view: updatedPermissions.view,\n            download: updatedPermissions.download,\n            itemType: item.itemType,\n          };\n        }\n\n        // Collect changes for all subitems\n        const collectSubItemChanges = (\n          subItems: FileOrFolder[] | undefined,\n        ) => {\n          if (!subItems) return;\n          subItems.forEach((subItem) => {\n            // Don't save the virtual __dataroom_root__ item to database\n            if (subItem.id !== \"__dataroom_root__\") {\n              changes[subItem.id] = {\n                view: updatedPermissions.view,\n                download: updatedPermissions.download,\n                itemType: subItem.itemType,\n              };\n            }\n            collectSubItemChanges(subItem.subItems);\n          });\n        };\n\n        collectSubItemChanges(item.subItems);\n\n        // Ensure all parent folders are viewable if the item is being set to viewable\n        if (updatedPermissions.view || updatedPermissions.download) {\n          parents.forEach((parent) => {\n            // Don't save the virtual __dataroom_root__ item to database\n            if (parent.id !== \"__dataroom_root__\") {\n              changes[parent.id] = {\n                view: true,\n                download: updatedPermissions.download\n                  ? true\n                  : parent.permissions.download,\n                itemType: parent.itemType,\n              };\n            }\n          });\n        } else {\n          // If turning off view, recalculate parent permissions\n          [...parents].reverse().forEach((parent) => {\n            // Don't save the virtual __dataroom_root__ item to database\n            if (parent.id !== \"__dataroom_root__\") {\n              const otherChildren =\n                parent.subItems?.filter((subItem) => subItem.id !== item.id) ||\n                [];\n              const someSubItemViewable = otherChildren.some(\n                (subItem) => subItem.permissions.view,\n              );\n              const someSubItemDownloadable = otherChildren.some(\n                (subItem) => subItem.permissions.download,\n              );\n\n              changes[parent.id] = {\n                view: someSubItemViewable,\n                download: someSubItemDownloadable,\n                itemType: parent.itemType,\n              };\n            }\n          });\n        }\n\n        return changes;\n      };\n\n      setPendingChanges((prev) => ({\n        ...prev,\n        ...collectChanges(item, parents),\n      }));\n    },\n    [],\n  );\n\n  useEffect(() => {\n    if (folders && !loading) {\n      const treeData = buildTreeWithRoot(folders, permissions, \"Dataroom Home\");\n      setData(treeData);\n    }\n  }, [folders, loading, permissions]);\n\n  const saveChanges = useCallback(\n    async (changes: typeof pendingChanges) => {\n      try {\n        const response = await fetch(\n          `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/permissions`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              dataroomId,\n              groupId,\n              permissions: changes,\n            }),\n          },\n        );\n\n        if (!response.ok) {\n          throw new Error(\"Failed to save permissions\");\n        }\n\n        toast.success(\"Permissions updated successfully.\");\n\n        setPendingChanges({});\n      } catch (error) {\n        console.error(\"Error saving permissions:\", error);\n        toast.error(\"Failed to update permissions\", {\n          description: \"Please try again.\",\n        });\n      }\n    },\n    [dataroomId, groupId],\n  );\n\n  useEffect(() => {\n    if (Object.keys(debouncedPendingChanges).length > 0) {\n      saveChanges(debouncedPendingChanges);\n    }\n  }, [debouncedPendingChanges, saveChanges]);\n\n  const columns = useMemo(\n    () => createColumns({ updatePermissions }),\n    [updatePermissions],\n  );\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getExpandedRowModel: getExpandedRowModel(),\n    getSubRows: (row) => row.subItems,\n    initialState: {\n      expanded: {\n        \"0\": true, // Always expand the root folder (first row)\n      },\n    },\n    getRowCanExpand: (row) => {\n      // Root folder is always expanded and cannot be collapsed\n      if (row.original.id === \"__dataroom_root__\") {\n        return true;\n      }\n      return (row.subRows?.length ?? 0) > 0;\n    },\n  });\n\n  if (loading) return <div>Loading...</div>;\n\n  return (\n    <div className=\"rounded-md border\">\n      <Table>\n        <TableHeader>\n          {table.getHeaderGroups().map((headerGroup) => (\n            <TableRow key={headerGroup.id}>\n              {headerGroup.headers.map((header) => (\n                <TableHead\n                  key={header.id}\n                  className=\"py-2 first:w-12 last:text-right\"\n                >\n                  {header.isPlaceholder\n                    ? null\n                    : flexRender(\n                        header.column.columnDef.header,\n                        header.getContext(),\n                      )}\n                </TableHead>\n              ))}\n            </TableRow>\n          ))}\n        </TableHeader>\n        <TableBody>\n          {table.getRowModel().rows?.length ? (\n            table.getRowModel().rows.map((row) => {\n              const isRoot = row.original.id === \"__dataroom_root__\";\n              return (\n                <TableRow\n                  key={row.id}\n                  data-state={row.getIsSelected() && \"selected\"}\n                  className={cn(isRoot && \"bg-blue-50/50 dark:bg-blue-950/50\")}\n                >\n                  {row.getVisibleCells().map((cell, index) => (\n                    <TableCell\n                      key={cell.id}\n                      style={\n                        index === 0\n                          ? {\n                              paddingLeft: `${row.depth * 1.25}rem`,\n                            }\n                          : undefined\n                      }\n                      className=\"py-2 last:flex last:justify-end\"\n                    >\n                      {flexRender(\n                        cell.column.columnDef.cell,\n                        cell.getContext(),\n                      )}\n                    </TableCell>\n                  ))}\n                </TableRow>\n              );\n            })\n          ) : (\n            <TableRow>\n              <TableCell colSpan={columns.length} className=\"h-24 text-center\">\n                No results.\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/groups/set-unified-permissions-modal.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ItemType } from \"@prisma/client\";\nimport {\n  ArrowDownToLineIcon,\n  ArrowLeftIcon,\n  EyeIcon,\n  EyeOffIcon,\n  FileTextIcon,\n  LinkIcon,\n  Loader2,\n  Users,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useDataroomLinks } from \"@/lib/swr/use-dataroom\";\nimport useDataroomGroups from \"@/lib/swr/use-dataroom-groups\";\nimport useDataroomPermissionGroups from \"@/lib/swr/use-dataroom-permission-groups\";\nimport { cn } from \"@/lib/utils\";\n\nimport CloudDownloadOff from \"@/components/shared/icons/cloud-download-off\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\n\ntype GroupPermissions = Record<string, { view: boolean; download: boolean }>;\ntype LinkPermissions = Record<string, { view: boolean; download: boolean }>;\n\nexport function SetUnifiedPermissionsModal({\n  open,\n  setOpen,\n  dataroomId,\n  onComplete,\n  uploadedFiles,\n}: {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  dataroomId: string;\n  onComplete?: () => void;\n  uploadedFiles: {\n    documentId: string;\n    dataroomDocumentId: string;\n    fileName: string;\n  }[];\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const [selectedFile, setSelectedFile] = useState<{\n    documentId: string;\n    dataroomDocumentId: string;\n    fileName: string;\n  } | null>(null);\n\n  // Computed values instead of useEffect + useState\n  const { showFileList, defaultSelectedFile } = useMemo(() => {\n    const shouldShowFileList = uploadedFiles.length > 1;\n    const defaultFile = uploadedFiles.length === 1 ? uploadedFiles[0] : null;\n    return {\n      showFileList: shouldShowFileList,\n      defaultSelectedFile: defaultFile,\n    };\n  }, [uploadedFiles]);\n\n  const [showFileListState, setShowFileListState] = useState(showFileList);\n\n  // Initialize selectedFile on mount or when uploadedFiles changes\n  useEffect(() => {\n    if (defaultSelectedFile && !selectedFile) {\n      setSelectedFile(defaultSelectedFile);\n      setShowFileListState(false);\n    } else if (!defaultSelectedFile) {\n      setShowFileListState(true);\n    }\n  }, [defaultSelectedFile, selectedFile]);\n\n  const currentDocumentId = useMemo(() => {\n    return selectedFile?.documentId || uploadedFiles[0]?.documentId;\n  }, [selectedFile, uploadedFiles]);\n\n  const currentDataroomDocumentId = useMemo(() => {\n    return (\n      selectedFile?.dataroomDocumentId || uploadedFiles[0]?.dataroomDocumentId\n    );\n  }, [selectedFile, uploadedFiles]);\n\n  // Fetch viewer groups\n  const {\n    viewerGroups,\n    loading: viewerGroupsLoading,\n    mutate: mutateViewerGroups,\n  } = useDataroomGroups({\n    documentId: currentDataroomDocumentId,\n  });\n\n  // Fetch links and permission groups\n  const { links, loading: linksLoading } = useDataroomLinks();\n  const { permissionGroups, loading: permissionGroupsLoading } =\n    useDataroomPermissionGroups();\n\n  const [loading, setLoading] = useState(false);\n  // Track permissions per file using dataroomDocumentId as key\n  const [filePermissions, setFilePermissions] = useState<\n    Record<\n      string,\n      {\n        groupPermissions: GroupPermissions;\n        linkPermissions: LinkPermissions;\n      }\n    >\n  >({});\n  // Track which files have been saved\n  const [savedFiles, setSavedFiles] = useState<Set<string>>(new Set());\n\n  // Current file permissions (derived from filePermissions)\n  const selectedGroupPermissions = useMemo(() => {\n    return currentDataroomDocumentId\n      ? filePermissions[currentDataroomDocumentId]?.groupPermissions || {}\n      : {};\n  }, [filePermissions, currentDataroomDocumentId]);\n\n  const selectedLinkPermissions = useMemo(() => {\n    return currentDataroomDocumentId\n      ? filePermissions[currentDataroomDocumentId]?.linkPermissions || {}\n      : {};\n  }, [filePermissions, currentDataroomDocumentId]);\n\n  // Memoize links with permission groups to avoid recalculation\n  const linksWithPermissionGroups = useMemo(() => {\n    if (!links || !permissionGroups) return [];\n\n    return links\n      .filter(\n        (link) =>\n          link.permissionGroupId &&\n          permissionGroups.some((pg) => pg.id === link.permissionGroupId),\n      )\n      .map((link) => ({\n        ...link,\n        permissionGroup: permissionGroups.find(\n          (pg) => pg.id === link.permissionGroupId,\n        ),\n      }));\n  }, [links, permissionGroups]);\n\n  // Refetch viewer groups when selectedFile changes\n  useEffect(() => {\n    if (selectedFile) {\n      mutateViewerGroups();\n    }\n  }, [selectedFile, mutateViewerGroups]);\n\n  // Memoize initial group permissions instead of useEffect\n  const initialGroupPermissions = useMemo(() => {\n    if (!viewerGroups) return {};\n\n    const permissions: GroupPermissions = {};\n    viewerGroups.forEach((group) => {\n      permissions[group.id] = {\n        view: group.accessControls?.[0]?.canView ?? false,\n        download: group.accessControls?.[0]?.canDownload ?? false,\n      };\n    });\n    return permissions;\n  }, [viewerGroups]);\n\n  // Memoize initial link permissions instead of useEffect\n  const initialLinkPermissions = useMemo(() => {\n    if (!linksWithPermissionGroups || !currentDataroomDocumentId) return {};\n\n    const permissions: LinkPermissions = {};\n    linksWithPermissionGroups.forEach((link) => {\n      const documentPermission = link.permissionGroup?.accessControls?.find(\n        (ac) => ac.itemId === currentDataroomDocumentId,\n      );\n      permissions[link.id] = {\n        view: documentPermission?.canView ?? false,\n        download: documentPermission?.canDownload ?? false,\n      };\n    });\n    return permissions;\n  }, [linksWithPermissionGroups, currentDataroomDocumentId]);\n\n  // Update state when initial permissions change\n  useEffect(() => {\n    if (currentDataroomDocumentId) {\n      setFilePermissions((prev) => ({\n        ...prev,\n        [currentDataroomDocumentId]: {\n          groupPermissions: initialGroupPermissions,\n          linkPermissions:\n            prev[currentDataroomDocumentId]?.linkPermissions || {},\n        },\n      }));\n    }\n  }, [initialGroupPermissions, currentDataroomDocumentId]);\n\n  useEffect(() => {\n    if (currentDataroomDocumentId) {\n      setFilePermissions((prev) => ({\n        ...prev,\n        [currentDataroomDocumentId]: {\n          groupPermissions:\n            prev[currentDataroomDocumentId]?.groupPermissions || {},\n          linkPermissions: initialLinkPermissions,\n        },\n      }));\n    }\n  }, [initialLinkPermissions, currentDataroomDocumentId]);\n\n  // Memoize permission update handlers to avoid recreation\n  const updateGroupPermissions = useCallback(\n    (groupId: string, newPermissions: string[]) => {\n      if (!currentDataroomDocumentId) return;\n\n      const hasView = newPermissions.includes(\"view\");\n      const hasDownload = newPermissions.includes(\"download\");\n\n      const prevPermissions = selectedGroupPermissions[groupId] || {\n        view: false,\n        download: false,\n      };\n\n      let finalPermissions = newPermissions;\n\n      if (\n        hasDownload &&\n        !hasView &&\n        !prevPermissions.view &&\n        !prevPermissions.download\n      ) {\n        finalPermissions = [\"view\", \"download\"];\n      } else if (hasDownload && !hasView) {\n        finalPermissions = [];\n      }\n\n      setFilePermissions((prev) => ({\n        ...prev,\n        [currentDataroomDocumentId]: {\n          groupPermissions: {\n            ...prev[currentDataroomDocumentId]?.groupPermissions,\n            [groupId]: {\n              view: finalPermissions.includes(\"view\"),\n              download: finalPermissions.includes(\"download\"),\n            },\n          },\n          linkPermissions:\n            prev[currentDataroomDocumentId]?.linkPermissions || {},\n        },\n      }));\n    },\n    [selectedGroupPermissions, currentDataroomDocumentId],\n  );\n\n  const updateLinkPermissions = useCallback(\n    (linkId: string, newPermissions: string[]) => {\n      if (!currentDataroomDocumentId) return;\n\n      const hasView = newPermissions.includes(\"view\");\n      const hasDownload = newPermissions.includes(\"download\");\n\n      const prevPermissions = selectedLinkPermissions[linkId] || {\n        view: false,\n        download: false,\n      };\n\n      let finalPermissions = newPermissions;\n\n      if (\n        hasDownload &&\n        !hasView &&\n        !prevPermissions.view &&\n        !prevPermissions.download\n      ) {\n        finalPermissions = [\"view\", \"download\"];\n      } else if (hasDownload && !hasView) {\n        finalPermissions = [];\n      }\n\n      setFilePermissions((prev) => ({\n        ...prev,\n        [currentDataroomDocumentId]: {\n          groupPermissions:\n            prev[currentDataroomDocumentId]?.groupPermissions || {},\n          linkPermissions: {\n            ...prev[currentDataroomDocumentId]?.linkPermissions,\n            [linkId]: {\n              view: finalPermissions.includes(\"view\"),\n              download: finalPermissions.includes(\"download\"),\n            },\n          },\n        },\n      }));\n    },\n    [selectedLinkPermissions, currentDataroomDocumentId],\n  );\n\n  // Memoize bulk actions to avoid recreation\n  const enableViewForAll = useCallback(() => {\n    if (!currentDataroomDocumentId) return;\n\n    // Enable view for all viewer groups\n    const newGroupPermissions = { ...selectedGroupPermissions };\n    viewerGroups?.forEach((group) => {\n      newGroupPermissions[group.id] = {\n        ...newGroupPermissions[group.id],\n        view: true,\n      };\n    });\n\n    // Enable view for all links\n    const newLinkPermissions = { ...selectedLinkPermissions };\n    linksWithPermissionGroups?.forEach((link) => {\n      newLinkPermissions[link.id] = {\n        ...newLinkPermissions[link.id],\n        view: true,\n      };\n    });\n\n    setFilePermissions((prev) => ({\n      ...prev,\n      [currentDataroomDocumentId]: {\n        groupPermissions: newGroupPermissions,\n        linkPermissions: newLinkPermissions,\n      },\n    }));\n  }, [\n    selectedGroupPermissions,\n    selectedLinkPermissions,\n    viewerGroups,\n    linksWithPermissionGroups,\n    currentDataroomDocumentId,\n  ]);\n\n  const enableDownloadForAll = useCallback(() => {\n    if (!currentDataroomDocumentId) return;\n\n    // Enable view + download for all viewer groups\n    const newGroupPermissions = { ...selectedGroupPermissions };\n    viewerGroups?.forEach((group) => {\n      newGroupPermissions[group.id] = {\n        view: true,\n        download: true,\n      };\n    });\n\n    // Enable view + download for all links\n    const newLinkPermissions = { ...selectedLinkPermissions };\n    linksWithPermissionGroups?.forEach((link) => {\n      newLinkPermissions[link.id] = {\n        view: true,\n        download: true,\n      };\n    });\n\n    setFilePermissions((prev) => ({\n      ...prev,\n      [currentDataroomDocumentId]: {\n        groupPermissions: newGroupPermissions,\n        linkPermissions: newLinkPermissions,\n      },\n    }));\n  }, [\n    selectedGroupPermissions,\n    selectedLinkPermissions,\n    viewerGroups,\n    linksWithPermissionGroups,\n    currentDataroomDocumentId,\n  ]);\n\n  const handleFileSelect = useCallback((file: (typeof uploadedFiles)[0]) => {\n    setSelectedFile(file);\n    setShowFileListState(false);\n  }, []);\n\n  const handleBack = useCallback(() => {\n    setShowFileListState(true);\n  }, []);\n\n  const handleSubmit = useCallback(\n    async (event: React.FormEvent) => {\n      event.preventDefault();\n      setLoading(true);\n\n      try {\n        // Save viewer group permissions (only if changed and at least one permission is true)\n        const viewerGroupPromises = Object.entries(selectedGroupPermissions)\n          .filter(([groupId, permissions]) => {\n            // Only process groups that have actual permissions set OR have changed\n            const initialPermissions = initialGroupPermissions[groupId];\n            const hasChanged =\n              !initialPermissions ||\n              initialPermissions.view !== permissions.view ||\n              initialPermissions.download !== permissions.download;\n\n            return hasChanged && (permissions.view || permissions.download);\n          })\n          .map(([groupId, permissions]) => {\n            return fetch(\n              `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/permissions`,\n              {\n                method: \"POST\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify({\n                  dataroomId,\n                  groupId,\n                  permissions: {\n                    [currentDataroomDocumentId]: {\n                      itemType: ItemType.DATAROOM_DOCUMENT,\n                      view: permissions.view,\n                      download: permissions.download,\n                    },\n                  },\n                }),\n              },\n            );\n          });\n\n        // Filter out link permissions that haven't changed or are both false\n        const linksNeedingUpdate = Object.entries(selectedLinkPermissions)\n          .filter(([linkId, permissions]) => {\n            // Only process links that have actual permissions set OR need to be removed\n            const initialPermissions = initialLinkPermissions[linkId];\n            const hasChanged =\n              !initialPermissions ||\n              initialPermissions.view !== permissions.view ||\n              initialPermissions.download !== permissions.download;\n\n            return hasChanged;\n          })\n          .map(([linkId, permissions]) => ({ linkId, permissions }));\n\n        // Save link permissions (only fetch and update what's needed)\n        const linkPromises = linksNeedingUpdate.map(\n          async ({ linkId, permissions }) => {\n            const link = linksWithPermissionGroups.find((l) => l.id === linkId);\n            if (!link?.permissionGroupId) return Promise.resolve();\n\n            // Fetch existing permissions for this permission group\n            const existingPermissionsResponse = await fetch(\n              `/api/teams/${teamId}/datarooms/${dataroomId}/permission-groups/${link.permissionGroupId}`,\n            );\n\n            if (!existingPermissionsResponse.ok) {\n              throw new Error(\"Failed to fetch existing permissions\");\n            }\n\n            const { permissionGroup } =\n              await existingPermissionsResponse.json();\n\n            // Build the complete permissions object by merging existing with new\n            const allPermissions: Record<\n              string,\n              { view: boolean; download: boolean; itemType: ItemType }\n            > = {};\n\n            // Add all existing permissions\n            permissionGroup.accessControls.forEach((control: any) => {\n              allPermissions[control.itemId] = {\n                view: control.canView,\n                download: control.canDownload,\n                itemType: control.itemType,\n              };\n            });\n\n            // Only add/update the permission if at least one is true\n            if (permissions.view || permissions.download) {\n              allPermissions[currentDataroomDocumentId] = {\n                itemType: ItemType.DATAROOM_DOCUMENT,\n                view: permissions.view,\n                download: permissions.download,\n              };\n            } else {\n              // If both are false, remove the permission entirely\n              delete allPermissions[currentDataroomDocumentId];\n            }\n\n            // Send the complete permissions set\n            return fetch(\n              `/api/teams/${teamId}/datarooms/${dataroomId}/permission-groups/${link.permissionGroupId}`,\n              {\n                method: \"PUT\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify({\n                  permissions: allPermissions,\n                  linkId: linkId,\n                }),\n              },\n            );\n          },\n        );\n\n        await Promise.all([...viewerGroupPromises, ...linkPromises]);\n\n        toast.success(\"Permissions updated successfully!\");\n\n        await Promise.all([\n          mutateViewerGroups(),\n          mutate(\n            `/api/teams/${teamId}/datarooms/${dataroomId}/groups?documentId=${currentDocumentId}`,\n          ),\n          mutate(\n            `/api/teams/${teamId}/datarooms/${dataroomId}/permission-groups`,\n          ),\n          mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/links`),\n        ]);\n\n        // Mark current file as saved\n        setSavedFiles((prev) => new Set([...prev, currentDataroomDocumentId]));\n\n        if (uploadedFiles.length > 1) {\n          // Find next unsaved file\n          const nextUnsavedFile = uploadedFiles.find(\n            (file) =>\n              !savedFiles.has(file.dataroomDocumentId) &&\n              file.dataroomDocumentId !== currentDataroomDocumentId,\n          );\n\n          if (nextUnsavedFile) {\n            // Go to next unsaved file\n            setSelectedFile(nextUnsavedFile);\n            setShowFileListState(false);\n          } else {\n            // All files are saved, complete the flow\n            onComplete?.();\n            setOpen(false);\n          }\n        } else {\n          onComplete?.();\n          setOpen(false);\n        }\n      } catch (error) {\n        console.error(\"Error updating permissions:\", error);\n        toast.error(\"Failed to update permissions\");\n      } finally {\n        setLoading(false);\n      }\n    },\n    [\n      selectedGroupPermissions,\n      selectedLinkPermissions,\n      initialGroupPermissions,\n      initialLinkPermissions,\n      teamId,\n      dataroomId,\n      currentDataroomDocumentId,\n      linksWithPermissionGroups,\n      mutateViewerGroups,\n      currentDocumentId,\n      uploadedFiles,\n      savedFiles,\n      onComplete,\n      setOpen,\n    ],\n  );\n\n  // Memoize computed values for UI\n  const isLoading = useMemo(\n    () => viewerGroupsLoading || linksLoading || permissionGroupsLoading,\n    [viewerGroupsLoading, linksLoading, permissionGroupsLoading],\n  );\n\n  const hasViewerGroups = useMemo(\n    () => viewerGroups && viewerGroups.length > 0,\n    [viewerGroups],\n  );\n\n  const hasLinks = useMemo(\n    () => linksWithPermissionGroups && linksWithPermissionGroups.length > 0,\n    [linksWithPermissionGroups],\n  );\n\n  const hasAnyPermissions = useMemo(\n    () => hasViewerGroups || hasLinks,\n    [hasViewerGroups, hasLinks],\n  );\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"sm:max-w-[600px]\">\n        <DialogHeader className=\"w-[90%]\">\n          <div className=\"flex items-center gap-2\">\n            {!showFileListState && uploadedFiles.length > 1 && (\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                onClick={handleBack}\n                className=\"h-8 w-8\"\n              >\n                <ArrowLeftIcon className=\"h-4 w-4\" />\n              </Button>\n            )}\n            <div>\n              <DialogTitle>Set Permissions</DialogTitle>\n              <DialogDescription>\n                {showFileListState\n                  ? savedFiles.size === uploadedFiles.length\n                    ? \"All files have been processed successfully!\"\n                    : \"Select a file to set permissions\"\n                  : \"Update permissions for the selected file\"}\n                {uploadedFiles.length > 1 && (\n                  <div className=\"mt-2 text-sm text-muted-foreground\">\n                    Progress: {savedFiles.size} of {uploadedFiles.length} files\n                    completed\n                  </div>\n                )}\n              </DialogDescription>\n            </div>\n          </div>\n        </DialogHeader>\n\n        {showFileListState ? (\n          <div className=\"space-y-2\">\n            {uploadedFiles.map((file) => {\n              const isSaved = savedFiles.has(file.dataroomDocumentId);\n              const isSelected = selectedFile?.documentId === file.documentId;\n\n              return (\n                <div\n                  key={file.documentId}\n                  className={cn(\n                    \"flex cursor-pointer items-center justify-between rounded-lg border p-3\",\n                    isSelected\n                      ? \"border-primary bg-primary/5\"\n                      : isSaved\n                        ? \"border-green-200 bg-green-50\"\n                        : \"hover:bg-muted/50\",\n                  )}\n                  onClick={() => handleFileSelect(file)}\n                >\n                  <div className=\"flex items-center space-x-3\">\n                    <FileTextIcon className=\"h-5 w-5\" />\n                    <span>{file.fileName}</span>\n                  </div>\n                  <div className=\"flex items-center space-x-2\">\n                    {isSaved && (\n                      <div className=\"text-sm text-green-600\">✓ Saved</div>\n                    )}\n                    {isSelected && (\n                      <div className=\"text-sm text-primary\">Selected</div>\n                    )}\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n        ) : (\n          <>\n            <div>\n              <p className=\"text-sm font-medium\">Selected File</p>\n              <div className=\"mt-1 flex items-center space-x-2 rounded-lg border p-3\">\n                <FileTextIcon className=\"h-5 w-5\" />\n                <span>\n                  {selectedFile?.fileName || uploadedFiles[0]?.fileName}\n                </span>\n              </div>\n            </div>\n\n            {hasAnyPermissions && (\n              <div className=\"flex gap-2\">\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={enableViewForAll}\n                  className=\"flex items-center gap-2\"\n                >\n                  <EyeIcon className=\"h-4 w-4\" />\n                  Enable View for All\n                </Button>\n                <Button\n                  type=\"button\"\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={enableDownloadForAll}\n                  className=\"flex items-center gap-2\"\n                >\n                  <ArrowDownToLineIcon className=\"h-4 w-4\" />\n                  Enable Download for All\n                </Button>\n              </div>\n            )}\n\n            <form onSubmit={handleSubmit}>\n              <div className=\"rounded-md border\">\n                <div className=\"max-h-[400px] overflow-y-auto\">\n                  {isLoading ? (\n                    <div className=\"flex items-center justify-center py-8\">\n                      <Loader2 className=\"h-8 w-8 animate-spin\" />\n                    </div>\n                  ) : !hasAnyPermissions ? (\n                    <div className=\"py-8 text-center\">\n                      <p className=\"text-muted-foreground\">\n                        No groups or links found. Create groups or links to set\n                        permissions.\n                      </p>\n                    </div>\n                  ) : (\n                    <Table>\n                      <TableHeader className=\"sticky top-0 z-10 bg-secondary\">\n                        <TableRow>\n                          <TableHead className=\"w-[60%]\">Name</TableHead>\n                          <TableHead className=\"w-[20%] text-center\">\n                            View\n                          </TableHead>\n                          <TableHead className=\"w-[20%] text-center\">\n                            Download\n                          </TableHead>\n                        </TableRow>\n                      </TableHeader>\n                      <TableBody className=\"relative\">\n                        {hasViewerGroups && (\n                          <>\n                            <TableRow>\n                              <TableCell\n                                colSpan={3}\n                                className=\"bg-muted/50 font-medium\"\n                              >\n                                <div className=\"flex items-center gap-2\">\n                                  <Users className=\"h-4 w-4\" />\n                                  Viewer Groups\n                                </div>\n                              </TableCell>\n                            </TableRow>\n                            {viewerGroups?.map((group) => (\n                              <TableRow key={group.id}>\n                                <TableCell>\n                                  <div>\n                                    <p className=\"font-medium\">{group.name}</p>\n                                    <p className=\"text-sm text-muted-foreground\">\n                                      {group._count.members} members\n                                    </p>\n                                  </div>\n                                </TableCell>\n                                <TableCell className=\"text-center\">\n                                  <ToggleGroup\n                                    type=\"multiple\"\n                                    value={Object.entries(\n                                      selectedGroupPermissions[group.id] || {},\n                                    )\n                                      .filter(\n                                        ([key, value]) =>\n                                          key === \"view\" && value,\n                                      )\n                                      .map(([key, _]) => key)}\n                                    onValueChange={(value) => {\n                                      const currentPermissions = Object.entries(\n                                        selectedGroupPermissions[group.id] ||\n                                          {},\n                                      )\n                                        .filter(([_, val]) => val)\n                                        .map(([key, _]) => key);\n\n                                      if (\n                                        currentPermissions.includes(\"view\") &&\n                                        !value.includes(\"view\")\n                                      ) {\n                                        const newPerms =\n                                          currentPermissions.filter(\n                                            (p) =>\n                                              p !== \"view\" && p !== \"download\",\n                                          );\n                                        updateGroupPermissions(\n                                          group.id,\n                                          newPerms,\n                                        );\n                                      } else if (\n                                        !currentPermissions.includes(\"view\") &&\n                                        value.includes(\"view\")\n                                      ) {\n                                        const newPerms = [\n                                          ...currentPermissions,\n                                          \"view\",\n                                        ];\n                                        updateGroupPermissions(\n                                          group.id,\n                                          newPerms,\n                                        );\n                                      }\n                                    }}\n                                    className=\"justify-center\"\n                                  >\n                                    <ToggleGroupItem\n                                      value=\"view\"\n                                      aria-label=\"Toggle view\"\n                                      size=\"sm\"\n                                      className={cn(\n                                        \"px-3 py-1 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n                                        selectedGroupPermissions[group.id]?.view\n                                          ? \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                                          : \"\",\n                                      )}\n                                    >\n                                      {selectedGroupPermissions[group.id]\n                                        ?.view ? (\n                                        <EyeIcon className=\"h-5 w-5\" />\n                                      ) : (\n                                        <EyeOffIcon className=\"h-5 w-5\" />\n                                      )}\n                                    </ToggleGroupItem>\n                                  </ToggleGroup>\n                                </TableCell>\n                                <TableCell className=\"text-center\">\n                                  <ToggleGroup\n                                    type=\"multiple\"\n                                    value={Object.entries(\n                                      selectedGroupPermissions[group.id] || {},\n                                    )\n                                      .filter(\n                                        ([key, value]) =>\n                                          key === \"download\" && value,\n                                      )\n                                      .map(([key, _]) => key)}\n                                    onValueChange={(value) => {\n                                      const currentPermissions = Object.entries(\n                                        selectedGroupPermissions[group.id] ||\n                                          {},\n                                      )\n                                        .filter(([_, val]) => val)\n                                        .map(([key, _]) => key);\n\n                                      if (\n                                        value.includes(\"download\") &&\n                                        !currentPermissions.includes(\"view\")\n                                      ) {\n                                        const newPerms = [\n                                          ...currentPermissions,\n                                          \"view\",\n                                          \"download\",\n                                        ];\n                                        updateGroupPermissions(\n                                          group.id,\n                                          newPerms,\n                                        );\n                                      } else {\n                                        const newPerms =\n                                          currentPermissions.filter(\n                                            (p) => p !== \"download\",\n                                          );\n                                        if (value.includes(\"download\")) {\n                                          newPerms.push(\"download\");\n                                        }\n                                        updateGroupPermissions(\n                                          group.id,\n                                          newPerms,\n                                        );\n                                      }\n                                    }}\n                                    className=\"justify-center\"\n                                  >\n                                    <ToggleGroupItem\n                                      value=\"download\"\n                                      aria-label=\"Toggle download\"\n                                      size=\"sm\"\n                                      className={cn(\n                                        \"px-3 py-1 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n                                        selectedGroupPermissions[group.id]\n                                          ?.download\n                                          ? \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                                          : \"\",\n                                      )}\n                                    >\n                                      {selectedGroupPermissions[group.id]\n                                        ?.download ? (\n                                        <ArrowDownToLineIcon className=\"h-5 w-5\" />\n                                      ) : (\n                                        <CloudDownloadOff className=\"h-5 w-5\" />\n                                      )}\n                                    </ToggleGroupItem>\n                                  </ToggleGroup>\n                                </TableCell>\n                              </TableRow>\n                            ))}\n                          </>\n                        )}\n\n                        {hasLinks && (\n                          <>\n                            <TableRow>\n                              <TableCell\n                                colSpan={3}\n                                className=\"bg-muted/50 font-medium\"\n                              >\n                                <div className=\"flex items-center gap-2\">\n                                  <LinkIcon className=\"h-4 w-4\" />\n                                  Links\n                                </div>\n                              </TableCell>\n                            </TableRow>\n                            {linksWithPermissionGroups?.map((link) => (\n                              <TableRow key={link.id}>\n                                <TableCell>\n                                  <div>\n                                    <p className=\"font-medium\">\n                                      {link.name ||\n                                        `Link #${link.id.slice(-5)}`}\n                                    </p>\n                                    <p className=\"max-w-[300px] truncate text-sm text-muted-foreground\">\n                                      {link.domainId && link.slug\n                                        ? `${link.domainSlug}/${link.slug}`\n                                        : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`}\n                                    </p>\n                                  </div>\n                                </TableCell>\n                                <TableCell className=\"text-center\">\n                                  <ToggleGroup\n                                    type=\"multiple\"\n                                    value={Object.entries(\n                                      selectedLinkPermissions[link.id] || {},\n                                    )\n                                      .filter(\n                                        ([key, value]) =>\n                                          key === \"view\" && value,\n                                      )\n                                      .map(([key, _]) => key)}\n                                    onValueChange={(value) => {\n                                      const currentPermissions = Object.entries(\n                                        selectedLinkPermissions[link.id] || {},\n                                      )\n                                        .filter(([_, val]) => val)\n                                        .map(([key, _]) => key);\n\n                                      if (\n                                        currentPermissions.includes(\"view\") &&\n                                        !value.includes(\"view\")\n                                      ) {\n                                        const newPerms =\n                                          currentPermissions.filter(\n                                            (p) =>\n                                              p !== \"view\" && p !== \"download\",\n                                          );\n                                        updateLinkPermissions(\n                                          link.id,\n                                          newPerms,\n                                        );\n                                      } else if (\n                                        !currentPermissions.includes(\"view\") &&\n                                        value.includes(\"view\")\n                                      ) {\n                                        const newPerms = [\n                                          ...currentPermissions,\n                                          \"view\",\n                                        ];\n                                        updateLinkPermissions(\n                                          link.id,\n                                          newPerms,\n                                        );\n                                      }\n                                    }}\n                                    className=\"justify-center\"\n                                  >\n                                    <ToggleGroupItem\n                                      value=\"view\"\n                                      aria-label=\"Toggle view\"\n                                      size=\"sm\"\n                                      className={cn(\n                                        \"px-3 py-1 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n                                        selectedLinkPermissions[link.id]?.view\n                                          ? \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                                          : \"\",\n                                      )}\n                                    >\n                                      {selectedLinkPermissions[link.id]\n                                        ?.view ? (\n                                        <EyeIcon className=\"h-5 w-5\" />\n                                      ) : (\n                                        <EyeOffIcon className=\"h-5 w-5\" />\n                                      )}\n                                    </ToggleGroupItem>\n                                  </ToggleGroup>\n                                </TableCell>\n                                <TableCell className=\"text-center\">\n                                  <ToggleGroup\n                                    type=\"multiple\"\n                                    value={Object.entries(\n                                      selectedLinkPermissions[link.id] || {},\n                                    )\n                                      .filter(\n                                        ([key, value]) =>\n                                          key === \"download\" && value,\n                                      )\n                                      .map(([key, _]) => key)}\n                                    onValueChange={(value) => {\n                                      const currentPermissions = Object.entries(\n                                        selectedLinkPermissions[link.id] || {},\n                                      )\n                                        .filter(([_, val]) => val)\n                                        .map(([key, _]) => key);\n\n                                      if (\n                                        value.includes(\"download\") &&\n                                        !currentPermissions.includes(\"view\")\n                                      ) {\n                                        const newPerms = [\n                                          ...currentPermissions,\n                                          \"view\",\n                                          \"download\",\n                                        ];\n                                        updateLinkPermissions(\n                                          link.id,\n                                          newPerms,\n                                        );\n                                      } else {\n                                        const newPerms =\n                                          currentPermissions.filter(\n                                            (p) => p !== \"download\",\n                                          );\n                                        if (value.includes(\"download\")) {\n                                          newPerms.push(\"download\");\n                                        }\n                                        updateLinkPermissions(\n                                          link.id,\n                                          newPerms,\n                                        );\n                                      }\n                                    }}\n                                    className=\"justify-center\"\n                                  >\n                                    <ToggleGroupItem\n                                      value=\"download\"\n                                      aria-label=\"Toggle download\"\n                                      size=\"sm\"\n                                      className={cn(\n                                        \"px-3 py-1 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n                                        selectedLinkPermissions[link.id]\n                                          ?.download\n                                          ? \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                                          : \"\",\n                                      )}\n                                    >\n                                      {selectedLinkPermissions[link.id]\n                                        ?.download ? (\n                                        <ArrowDownToLineIcon className=\"h-5 w-5\" />\n                                      ) : (\n                                        <CloudDownloadOff className=\"h-5 w-5\" />\n                                      )}\n                                    </ToggleGroupItem>\n                                  </ToggleGroup>\n                                </TableCell>\n                              </TableRow>\n                            ))}\n                          </>\n                        )}\n                      </TableBody>\n                    </Table>\n                  )}\n                </div>\n              </div>\n              <DialogFooter className=\"mt-6\">\n                <Button\n                  type=\"submit\"\n                  loading={loading}\n                  disabled={!hasAnyPermissions}\n                >\n                  {uploadedFiles.length > 1\n                    ? (() => {\n                        const remainingFiles = uploadedFiles.filter(\n                          (file) =>\n                            !savedFiles.has(file.dataroomDocumentId) &&\n                            file.dataroomDocumentId !==\n                              currentDataroomDocumentId,\n                        );\n                        return remainingFiles.length > 0\n                          ? \"Save & Next\"\n                          : \"Save & Complete\";\n                      })()\n                    : \"Save permissions\"}\n                </Button>\n              </DialogFooter>\n            </form>\n          </>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/move-dataroom-folder-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\n\nimport { moveDataroomDocumentToFolder } from \"@/lib/documents/move-dataroom-documents\";\nimport { moveDataroomFolderToFolder } from \"@/lib/documents/move-dataroom-folders\";\n\nimport { SidebarFolderTreeSelection } from \"@/components/datarooms/folders\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nimport { TSelectedFolder } from \"../documents/move-folder-modal\";\n\nexport function MoveToDataroomFolderModal({\n  open,\n  setOpen,\n  dataroomId,\n  setSelectedDocuments,\n  documentIds,\n  itemName,\n  folderIds,\n  folderParentId,\n  setSelectedFoldersId,\n}: {\n  open: boolean;\n  folderIds: string[];\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  dataroomId: string;\n  setSelectedDocuments?: React.Dispatch<React.SetStateAction<string[]>>;\n  documentIds?: string[];\n  itemName?: string;\n  folderParentId?: string;\n  setSelectedFoldersId?: React.Dispatch<React.SetStateAction<string[]>>;\n}) {\n  const router = useRouter();\n  const [selectedFolder, setSelectedFolder] = useState<TSelectedFolder>(null);\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const currentPath = router.query.name\n    ? (router.query.name as string[]).join(\"/\")\n    : \"\";\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!selectedFolder) return;\n\n    setLoading(true);\n    if (folderParentId === selectedFolder.id) {\n      toast.error(\"Folder is already in the selected folder.\");\n      setLoading(false);\n      return;\n    }\n    if (folderIds.includes(selectedFolder.id!)) {\n      toast.error(\"Cannot move to the same folder.\");\n      setLoading(false);\n      return;\n    }\n    if (documentIds && documentIds.length > 0) {\n      await moveDataroomDocumentToFolder({\n        documentIds,\n        folderId: selectedFolder.id!,\n        folderPathName: currentPath ? currentPath.split(\"/\") : undefined,\n        dataroomId,\n        teamId,\n        folderIds,\n      });\n    }\n    if (folderIds && folderIds.length > 0) {\n      await moveDataroomFolderToFolder({\n        folderIds: folderIds,\n        folderPathName: currentPath ? currentPath.split(\"/\") : undefined,\n        teamId,\n        selectedFolder: selectedFolder.id!,\n        dataroomId: dataroomId,\n        selectedFolderPath: selectedFolder.path!,\n      });\n    }\n\n    setLoading(false);\n    setOpen(false); // Close the modal\n    setSelectedDocuments?.([]); // Clear the selected documents\n    setSelectedFoldersId?.([]); // Clear the selected folders\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>\n            Move{\" \"}\n            <span className=\"font-bold\">\n              {`${(documentIds?.length ?? 0) + (folderIds?.length ?? 0)} items`}\n            </span>\n          </DialogTitle>\n          <DialogDescription>Move your item to a folder.</DialogDescription>\n        </DialogHeader>\n        <form>\n          <div className=\"mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll\">\n            <SidebarFolderTreeSelection\n              dataroomId={dataroomId}\n              selectedFolder={selectedFolder}\n              setSelectedFolder={setSelectedFolder}\n              disableId={folderIds}\n            />\n          </div>\n\n          <DialogFooter>\n            <Button\n              onClick={handleSubmit}\n              className=\"flex h-9 w-full gap-1\"\n              loading={loading}\n              disabled={\n                !selectedFolder || folderIds?.includes(selectedFolder.id!)\n              }\n            >\n              {!selectedFolder ? (\n                \"Select a folder\"\n              ) : (\n                <>\n                  Move to{\" \"}\n                  <span className=\"max-w-[200px] truncate font-medium\">\n                    {selectedFolder.name}\n                  </span>\n                </>\n              )}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/bulk-download-settings.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\n\ninterface BulkDownloadSettingsProps {\n  dataroomId: string;\n}\n\nexport default function BulkDownloadSettings({\n  dataroomId,\n}: BulkDownloadSettingsProps) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: dataroomData, mutate: mutateDataroom } = useSWR<{\n    id: string;\n    name: string;\n    pId: string;\n    allowBulkDownload: boolean;\n  }>(\n    dataroomId ? `/api/teams/${teamId}/datarooms/${dataroomId}` : null,\n    fetcher,\n  );\n\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const handleBulkDownloadToggle = async (checked: boolean) => {\n    if (!dataroomId || !teamId || isUpdating) return;\n\n    setIsUpdating(true);\n\n    toast.promise(\n      fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          allowBulkDownload: checked,\n        }),\n      }).then(async (res) => {\n        if (!res.ok) {\n          throw new Error(\"Failed to update bulk download settings\");\n        }\n        await mutateDataroom();\n      }),\n      {\n        loading: \"Updating bulk download settings...\",\n        success: \"Bulk download settings updated successfully\",\n        error: \"Failed to update bulk download settings\",\n      },\n    );\n\n    setIsUpdating(false);\n  };\n\n  return (\n    <Card className=\"bg-transparent\">\n      <CardHeader>\n        <CardTitle className=\"flex items-center\">Dataroom Download</CardTitle>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <div className=\"flex items-center justify-between\">\n          <div className=\"space-y-1\">\n            <Label\n              htmlFor=\"bulk-download-toggle\"\n              className=\"text-sm font-medium\"\n            >\n              Allow bulk download of entire dataroom\n            </Label>\n          </div>\n          <Switch\n            id=\"bulk-download-toggle\"\n            checked={dataroomData?.allowBulkDownload ?? true}\n            onCheckedChange={handleBulkDownloadToggle}\n            disabled={isUpdating}\n          />\n        </div>\n      </CardContent>\n      <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6\">\n        <p className=\"text-sm text-muted-foreground transition-colors\">\n          When enabled, visitors can download all dataroom contents as a single\n          ZIP file. Individual document and folder downloads will still work\n          regardless of this setting.\n        </p>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/dataroom-tag-section.tsx",
    "content": "import Link from \"next/link\";\n\nimport { Dispatch, SetStateAction, useMemo, useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { CircleHelpIcon, Tag } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useTags } from \"@/lib/swr/use-tags\";\nimport { TagColorProps } from \"@/lib/types\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport { MultiSelect } from \"@/components/ui/multi-select-v2\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\ntype TagProps = {\n  id: string;\n  name: string;\n  color: string;\n  description: string | null;\n};\n\nfunction getTagOption(tag: TagProps) {\n  return {\n    value: tag.id,\n    label: tag.name,\n    icon: (\n      <Tag\n        size={20}\n        className={`rounded-sm border border-gray-200 bg-${tag.color}-100 p-1 dark:text-primary-foreground`}\n      />\n    ),\n    meta: { color: tag.color, description: tag.description },\n  };\n}\n\ninterface DataroomTagSectionProps {\n  dataroomId: string;\n  teamId: string;\n  initialTags?: {\n    tag: {\n      id: string;\n      name: string;\n      color: string;\n      description: string | null;\n    };\n  }[];\n}\n\nexport default function DataroomTagSection({\n  dataroomId,\n  teamId,\n  initialTags,\n}: DataroomTagSectionProps) {\n  const [isPopoverOpen, setIsPopoverOpen] = useState(false);\n  const [selectedValues, setSelectedValues] = useState<string[]>(\n    initialTags?.map((t) => t.tag.id) || [],\n  );\n  const [showUpgradeModal, setShowUpgradeModal] = useState(false);\n  const [isSaving, setIsSaving] = useState(false);\n  const { isFree } = usePlan();\n\n  const {\n    tagCount,\n    tags: availableTags,\n    loading: loadingTags,\n  } = useTags({\n    query: {\n      sortBy: \"createdAt\",\n      sortOrder: \"desc\",\n    },\n  });\n\n  const options = useMemo(\n    () => availableTags?.map((tag) => getTagOption(tag)),\n    [availableTags],\n  );\n\n  // Check if there are unsaved changes\n  const hasChanges = useMemo(() => {\n    const initialTagIds = initialTags?.map((t) => t.tag.id) || [];\n    if (selectedValues.length !== initialTagIds.length) return true;\n    return !selectedValues.every((id) => initialTagIds.includes(id));\n  }, [selectedValues, initialTags]);\n\n  // Callback to handle value change\n  const handleValueChange = (value: string[]) => {\n    setSelectedValues(value);\n  };\n\n  // Save tags to API\n  const handleSave = async () => {\n    setIsSaving(true);\n\n    try {\n      const res = await fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ tags: selectedValues }),\n      });\n\n      if (!res.ok) {\n        const { error } = await res.json();\n        toast.error(error);\n        return;\n      }\n\n      await Promise.all([\n        mutate(`/api/teams/${teamId}/datarooms`),\n        mutate(`/api/teams/${teamId}/datarooms?simple=true`),\n        mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`),\n      ]);\n\n      toast.success(\"Successfully updated tags!\");\n    } catch (error) {\n      toast.error(\"Failed to update tags\");\n      console.error(\"Error updating tags:\", error);\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const createTag = async (tag: string) => {\n    if (isFree && tagCount && tagCount >= 5) {\n      setShowUpgradeModal(true);\n      toast.error(\"You have reached the maximum number of tags.\");\n      return false;\n    }\n\n    const res = await fetch(`/api/teams/${teamId}/tags`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ name: tag }),\n    });\n    if (!res.ok) {\n      const { error } = await res.json();\n      toast.error(error);\n      return false;\n    }\n\n    const newTag = await res.json();\n    await mutate(\n      `/api/teams/${teamId}/tags?${new URLSearchParams({\n        sortBy: \"createdAt\",\n        sortOrder: \"desc\",\n        includeLinksCount: false,\n      } as Record<string, any>).toString()}`,\n    );\n\n    // Add to selected values - same pattern as link-sheet\n    setSelectedValues([...selectedValues, newTag.id]);\n    setIsPopoverOpen(false);\n    toast.success(\"Successfully created tag!\");\n    return true;\n  };\n\n  return (\n    <>\n      <Card className=\"bg-transparent\">\n        <CardHeader>\n          <CardTitle>Tags</CardTitle>\n          <CardDescription>\n            Organize your dataroom by adding tags for better categorization and\n            filtering\n          </CardDescription>\n        </CardHeader>\n        <CardContent>\n          <div className=\"flex justify-between\">\n            <div className=\"flex items-center gap-2\">\n              <Label htmlFor=\"dataroom-tags\">Tags</Label>\n              <BadgeTooltip content=\"Organize datarooms by tags to easily filter and find them\">\n                <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n              </BadgeTooltip>\n            </div>\n            <Link\n              href={`/settings/tags`}\n              className=\"text-xs text-muted-foreground hover:text-foreground hover:underline\"\n            >\n              Manage\n            </Link>\n          </div>\n          <div className=\"mt-2 flex gap-2\">\n            <MultiSelect\n              loading={loadingTags}\n              options={options ?? []}\n              value={selectedValues}\n              setIsPopoverOpen={setIsPopoverOpen}\n              isPopoverOpen={isPopoverOpen}\n              onValueChange={handleValueChange}\n              placeholder=\"Select tags...\"\n              maxCount={3}\n              searchPlaceholder=\"Search or add tags...\"\n              onCreate={(search) => createTag(search)}\n              popoverClassName=\"sm:w-[400px]\"\n            />\n            <Button\n              onClick={handleSave}\n              loading={isSaving}\n              disabled={!hasChanges || isSaving}\n              size=\"default\"\n            >\n              Save\n            </Button>\n          </div>\n        </CardContent>\n        <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n          <p className=\"text-sm text-muted-foreground transition-colors\">\n            Tags help you organize and filter datarooms across your workspace.\n          </p>\n        </CardFooter>\n      </Card>\n      {showUpgradeModal && (\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.Pro}\n          trigger=\"create_tag\"\n          open={showUpgradeModal}\n          setOpen={setShowUpgradeModal}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/delete-dataroooom/delete-dataroom-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { CardDescription, CardTitle } from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Modal } from \"@/components/ui/modal\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nfunction DeleteDataroomModal({\n  dataroomId,\n  dataroomName,\n  showDeleteDataroomModal,\n  setShowDeleteDataroomModal,\n}: {\n  dataroomId: string;\n  dataroomName: string;\n  showDeleteDataroomModal: boolean;\n  setShowDeleteDataroomModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n\n  const [deleting, setDeleting] = useState(false);\n\n  async function deleteDataroom() {\n    return new Promise((resolve, reject) => {\n      setDeleting(true);\n\n      fetch(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}`, {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      }).then(async (res) => {\n        if (res.ok) {\n          analytics.capture(\"Dataroom Deleted\", {\n            dataroomName: dataroomName,\n            dataroomId: dataroomId,\n          });\n          await Promise.all([\n            mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),\n            mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),\n          ]);\n          router.push(\"/datarooms\");\n          resolve(null);\n        } else {\n          setDeleting(false);\n          const error = await res.json();\n          reject(error.message);\n        }\n      });\n    });\n  }\n\n  return (\n    <Modal\n      showModal={showDeleteDataroomModal}\n      setShowModal={setShowDeleteDataroomModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <CardTitle>Delete Dataroom</CardTitle>\n        <CardDescription className=\"text-md font-semibold text-foreground\">\n          {dataroomName}\n        </CardDescription>\n        <CardDescription>\n          Warning: This will permanently delete your dataroom and all associated\n          links and their respective views.\n        </CardDescription>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          toast.promise(deleteDataroom(), {\n            loading: \"Deleting dataroom...\",\n            success: \"Dataroom deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-muted-foreground\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold text-foreground\">\n              confirm delete dataroom\n            </span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"confirm delete dataroom\"\n              required\n              autoComplete=\"off\"\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n            />\n          </div>\n        </div>\n\n        <Button variant=\"destructive\" loading={deleting}>\n          Confirm delete dataroom\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteDataroomModal({\n  dataroomId,\n  dataroomName,\n}: {\n  dataroomId: string;\n  dataroomName: string;\n}) {\n  const [showDeleteDataroomModal, setShowDeleteDataroomModal] = useState(false);\n\n  const DeleteDataroomModalCallback = useCallback(() => {\n    return (\n      <DeleteDataroomModal\n        dataroomId={dataroomId}\n        dataroomName={dataroomName}\n        showDeleteDataroomModal={showDeleteDataroomModal}\n        setShowDeleteDataroomModal={setShowDeleteDataroomModal}\n      />\n    );\n  }, [showDeleteDataroomModal, setShowDeleteDataroomModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteDataroomModal,\n      DeleteDataroomModal: DeleteDataroomModalCallback,\n    }),\n    [setShowDeleteDataroomModal, DeleteDataroomModalCallback],\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/delete-dataroooom/index.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\n\nimport { useDeleteDataroomModal } from \"./delete-dataroom-modal\";\n\nexport default function DeleteDataroom({\n  dataroomId,\n  dataroomName,\n}: {\n  dataroomId: string;\n  dataroomName: string;\n}) {\n  const { setShowDeleteDataroomModal, DeleteDataroomModal } =\n    useDeleteDataroomModal({ dataroomId, dataroomName });\n\n  return (\n    <div className=\"rounded-lg\">\n      <DeleteDataroomModal />\n      <Card className=\"border-destructive bg-transparent\">\n        <CardHeader>\n          <CardTitle>Delete Dataroom</CardTitle>\n          <CardDescription>\n            Permanently delete your dataroom all associated links and their\n            views. <br />\n            <span className=\"font-medium\">This action cannot be undone</span> -\n            please proceed with caution.\n          </CardDescription>\n        </CardHeader>\n        <CardContent></CardContent>\n        <CardFooter className=\"flex items-center justify-end rounded-b-lg border-t px-6 py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              onClick={() => setShowDeleteDataroomModal(true)}\n              variant=\"destructive\"\n            >\n              Delete Dataroom\n            </Button>\n          </div>\n        </CardFooter>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/duplicate-dataroom.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useLimits } from \"@/ee/limits/swr-handler\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useDataroomsSimple from \"@/lib/swr/use-datarooms-simple\";\n\nexport default function DuplicateDataroom({\n  dataroomId,\n  teamId,\n}: {\n  dataroomId: string;\n  teamId?: string;\n}) {\n  const router = useRouter();\n  const [loading, setLoading] = useState<boolean>(false);\n  const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);\n  const { limits } = useLimits();\n  const { isBusiness, isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n  const { datarooms: dataRooms } = useDataroomsSimple();\n  const numDatarooms = dataRooms?.length ?? 0;\n  const limitDatarooms = limits?.datarooms ?? 1;\n\n  const isTrialDatarooms = isTrial;\n  const canCreateUnlimitedDatarooms =\n    isDatarooms ||\n    isDataroomsPlus ||\n    (isBusiness && numDatarooms < limitDatarooms);\n\n  const handleDuplicateDataroom = async (\n    e: React.MouseEvent<HTMLButtonElement>,\n  ) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    if (!teamId) {\n      return;\n    }\n\n    setLoading(true);\n\n    try {\n      toast.promise(\n        fetch(`/api/teams/${teamId}/datarooms/${dataroomId}/duplicate`, {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        }).then(async (response) => {\n          if (!response.ok) {\n            const errorData = await response.json();\n            throw new Error(\n              errorData.message || \"An error occurred while copying dataroom.\",\n            );\n          }\n          return response.json();\n        }),\n        {\n          loading: \"Copying dataroom...\",\n          success: (dataroom) => {\n            mutate(`/api/teams/${teamId}/datarooms`);\n            mutate(`/api/teams/${teamId}/datarooms?simple=true`);\n            router.push(`/datarooms/${dataroom.id}/documents`);\n            return \"Dataroom copied successfully.\";\n          },\n          error: (error) => {\n            return error.message;\n          },\n        },\n      );\n    } catch (error) {\n      console.error(error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const ButtonList = () => {\n    if (\n      (isBusiness && !canCreateUnlimitedDatarooms) ||\n      (isTrialDatarooms &&\n        dataRooms &&\n        !isBusiness &&\n        !isDatarooms &&\n        !isDataroomsPlus)\n    ) {\n      return (\n        <Button onClick={(e) => setPlanModalOpen(true)} loading={loading}>\n          Upgrade to Duplicate Datarooms\n        </Button>\n      );\n    } else {\n      return (\n        <Button onClick={(e) => handleDuplicateDataroom(e)} loading={loading}>\n          Duplicate Dataroom\n        </Button>\n      );\n    }\n  };\n\n  return (\n    <div className=\"rounded-lg\">\n      <Card className=\"bg-transparent\">\n        <CardHeader>\n          <CardTitle>Duplicate Dataroom</CardTitle>\n          <CardDescription>\n            Create a new data room with the same content (folders and files) as\n            this data room.\n          </CardDescription>\n        </CardHeader>\n        <CardContent></CardContent>\n        <CardFooter className=\"flex items-center justify-end rounded-b-lg border-t px-6 py-3\">\n          <div className=\"shrink-0\">{ButtonList()}</div>\n        </CardFooter>\n      </Card>\n      {planModalOpen ? (\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.DataRooms}\n          trigger=\"datarooms\"\n          open={planModalOpen}\n          setOpen={setPlanModalOpen}\n        />\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/introduction-settings.tsx",
    "content": "\"use client\";\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { BookOpenIcon, EyeIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { uploadImage } from \"@/lib/utils\";\n\nimport PlanBadge from \"@/components/billing/plan-badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport { RichTextEditor } from \"@/components/ui/rich-text-editor\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\ninterface IntroductionSettingsProps {\n  dataroomId: string;\n}\n\ninterface FolderItem {\n  id: string;\n  name: string;\n  documents?: { document: { name: string } }[];\n}\n\ninterface DocumentItem {\n  document: { name: string };\n}\n\n// Generate TipTap JSON content for introduction based on dataroom structure\nfunction generateIntroductionContent(\n  dataroomName: string,\n  folders: FolderItem[],\n  rootDocuments: DocumentItem[],\n): any {\n  const content: any[] = [];\n\n  // Overview paragraph (no \"Welcome to\" repetition)\n  content.push({\n    type: \"paragraph\",\n    content: [\n      {\n        type: \"text\",\n        text: `This data room contains confidential documents and materials prepared for your review. Please take a moment to familiarize yourself with the structure below.`,\n      },\n    ],\n  });\n\n  // What's Inside section\n  content.push({\n    type: \"heading\",\n    attrs: { level: 2 },\n    content: [{ type: \"text\", text: \"What's Inside\" }],\n  });\n\n  // If there are folders, list them\n  if (folders.length > 0) {\n    const folderList = {\n      type: \"bulletList\",\n      content: folders.slice(0, 8).map((folder) => ({\n        type: \"listItem\",\n        content: [\n          {\n            type: \"paragraph\",\n            content: [\n              {\n                type: \"text\",\n                text: folder.name,\n                marks: [{ type: \"bold\" }],\n              },\n              ...(folder.documents && folder.documents.length > 0\n                ? [\n                    {\n                      type: \"text\",\n                      text: ` — ${folder.documents.length} document${folder.documents.length > 1 ? \"s\" : \"\"}`,\n                    },\n                  ]\n                : []),\n            ],\n          },\n        ],\n      })),\n    };\n    content.push(folderList);\n\n    if (folders.length > 8) {\n      content.push({\n        type: \"paragraph\",\n        content: [\n          {\n            type: \"text\",\n            text: `...and ${folders.length - 8} more sections.`,\n          },\n        ],\n      });\n    }\n  } else {\n    // Show placeholder sections if dataroom is empty\n    const placeholderList = {\n      type: \"bulletList\",\n      content: [\n        {\n          type: \"listItem\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [\n                { type: \"text\", text: \"Section 1\", marks: [{ type: \"bold\" }] },\n                { type: \"text\", text: \" — Company Overview & Key Documents\" },\n              ],\n            },\n          ],\n        },\n        {\n          type: \"listItem\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [\n                { type: \"text\", text: \"Section 2\", marks: [{ type: \"bold\" }] },\n                { type: \"text\", text: \" — Financial Information\" },\n              ],\n            },\n          ],\n        },\n        {\n          type: \"listItem\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [\n                { type: \"text\", text: \"Section 3\", marks: [{ type: \"bold\" }] },\n                { type: \"text\", text: \" — Legal & Compliance\" },\n              ],\n            },\n          ],\n        },\n        {\n          type: \"listItem\",\n          content: [\n            {\n              type: \"paragraph\",\n              content: [\n                { type: \"text\", text: \"Section 4\", marks: [{ type: \"bold\" }] },\n                { type: \"text\", text: \" — Additional Materials\" },\n              ],\n            },\n          ],\n        },\n      ],\n    };\n    content.push(placeholderList);\n  }\n\n  // If there are root documents, mention them\n  if (rootDocuments.length > 0) {\n    content.push({\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: `There ${rootDocuments.length === 1 ? \"is\" : \"are\"} also ${rootDocuments.length} document${rootDocuments.length > 1 ? \"s\" : \"\"} available at the root level for quick access.`,\n        },\n      ],\n    });\n  }\n\n  // How to Navigate section\n  content.push({\n    type: \"heading\",\n    attrs: { level: 2 },\n    content: [{ type: \"text\", text: \"How to Navigate\" }],\n  });\n\n  content.push({\n    type: \"bulletList\",\n    content: [\n      {\n        type: \"listItem\",\n        content: [\n          {\n            type: \"paragraph\",\n            content: [\n              {\n                type: \"text\",\n                text: \"Use the sidebar on the left to browse sections and folders\",\n              },\n            ],\n          },\n        ],\n      },\n      {\n        type: \"listItem\",\n        content: [\n          {\n            type: \"paragraph\",\n            content: [\n              {\n                type: \"text\",\n                text: \"Click on any document to open and view it\",\n              },\n            ],\n          },\n        ],\n      },\n      {\n        type: \"listItem\",\n        content: [\n          {\n            type: \"paragraph\",\n            content: [\n              {\n                type: \"text\",\n                text: \"Use the search function to find specific documents quickly\",\n              },\n            ],\n          },\n        ],\n      },\n    ],\n  });\n\n  // Placeholder for navigation screenshot\n\n  // Q&A and Conversations section\n  content.push({\n    type: \"heading\",\n    attrs: { level: 2 },\n    content: [{ type: \"text\", text: \"Q&A and Conversations\" }],\n  });\n\n  content.push({\n    type: \"paragraph\",\n    content: [\n      {\n        type: \"text\",\n        text: \"Have questions about specific documents? You can start a conversation directly within the data room. Use the chat feature to ask questions and get answers from our team.\",\n      },\n    ],\n  });\n\n  content.push({\n    type: \"bulletList\",\n    content: [\n      {\n        type: \"listItem\",\n        content: [\n          {\n            type: \"paragraph\",\n            content: [\n              {\n                type: \"text\",\n                text: \"Click the chat icon to start a new conversation\",\n              },\n            ],\n          },\n        ],\n      },\n      {\n        type: \"listItem\",\n        content: [\n          {\n            type: \"paragraph\",\n            content: [\n              {\n                type: \"text\",\n                text: \"Ask questions about any document or section\",\n              },\n            ],\n          },\n        ],\n      },\n      {\n        type: \"listItem\",\n        content: [\n          {\n            type: \"paragraph\",\n            content: [\n              {\n                type: \"text\",\n                text: \"Receive timely responses from our team\",\n              },\n            ],\n          },\n        ],\n      },\n    ],\n  });\n\n  // Placeholder for conversations screenshot\n\n  // Need Help section\n  content.push({\n    type: \"heading\",\n    attrs: { level: 2 },\n    content: [{ type: \"text\", text: \"Need Help?\" }],\n  });\n\n  content.push({\n    type: \"paragraph\",\n    content: [\n      {\n        type: \"text\",\n        text: \"If you have any questions or need assistance, please reach out to your designated contact. We're here to help you navigate this data room effectively.\",\n      },\n    ],\n  });\n\n  return {\n    type: \"doc\",\n    content,\n  };\n}\n\n// Helper to render inline text nodes with marks (bold, italic, etc.)\nfunction renderInlineContent(nodes: any[] | undefined): React.ReactNode {\n  if (!nodes) return null;\n\n  return nodes.map((textNode: any, textIndex: number) => {\n    if (textNode.type === \"text\") {\n      let text: React.ReactNode = textNode.text;\n      if (textNode.marks) {\n        textNode.marks.forEach((mark: any) => {\n          if (mark.type === \"bold\") {\n            text = (\n              <strong key={`bold-${textIndex}`} className=\"font-semibold\">\n                {text}\n              </strong>\n            );\n          } else if (mark.type === \"italic\") {\n            text = (\n              <em key={`italic-${textIndex}`} className=\"italic\">\n                {text}\n              </em>\n            );\n          }\n        });\n      }\n      return <React.Fragment key={textIndex}>{text}</React.Fragment>;\n    } else if (textNode.type === \"image\") {\n      return (\n        <img\n          key={textIndex}\n          src={textNode.attrs?.src}\n          alt={textNode.attrs?.alt || \"\"}\n          className=\"my-2 h-auto max-w-full rounded-md\"\n        />\n      );\n    }\n    return null;\n  });\n}\n\n// Render TipTap JSON content for preview\nfunction renderContent(content: any): React.ReactNode {\n  if (!content || !content.content) return null;\n\n  return content.content.map((node: any, index: number) => {\n    if (node.type === \"heading\") {\n      const level = node.attrs?.level || 1;\n      const text = node.content?.[0]?.text || \"\";\n      if (level === 1) {\n        return (\n          <h1\n            key={index}\n            className=\"mb-3 mt-4 text-xl font-bold text-gray-900 first:mt-0\"\n          >\n            {text}\n          </h1>\n        );\n      }\n      return (\n        <h2\n          key={index}\n          className=\"mb-2 mt-4 text-base font-semibold text-gray-800 first:mt-0\"\n        >\n          {text}\n        </h2>\n      );\n    } else if (node.type === \"paragraph\") {\n      return (\n        <p key={index} className=\"mb-3 text-sm leading-relaxed text-gray-700\">\n          {renderInlineContent(node.content)}\n        </p>\n      );\n    } else if (node.type === \"bulletList\") {\n      return (\n        <ul key={index} className=\"mb-3 list-disc pl-5 text-sm text-gray-700\">\n          {node.content?.map((item: any, itemIndex: number) => (\n            <li key={itemIndex} className=\"mb-1\">\n              {renderInlineContent(item.content?.[0]?.content)}\n            </li>\n          ))}\n        </ul>\n      );\n    } else if (node.type === \"orderedList\") {\n      return (\n        <ol\n          key={index}\n          className=\"mb-3 list-decimal pl-5 text-sm text-gray-700\"\n        >\n          {node.content?.map((item: any, itemIndex: number) => (\n            <li key={itemIndex} className=\"mb-1\">\n              {renderInlineContent(item.content?.[0]?.content)}\n            </li>\n          ))}\n        </ol>\n      );\n    } else if (node.type === \"blockquote\") {\n      return (\n        <blockquote\n          key={index}\n          className=\"mb-3 border-l-4 border-gray-300 pl-4 italic text-gray-600\"\n        >\n          {node.content?.map((p: any) =>\n            p.content?.map((textNode: any) =>\n              textNode.type === \"text\" ? textNode.text : null,\n            ),\n          )}\n        </blockquote>\n      );\n    } else if (node.type === \"image\") {\n      return (\n        <img\n          key={index}\n          src={node.attrs?.src}\n          alt={node.attrs?.alt || \"\"}\n          className=\"my-3 h-auto max-w-full rounded-md\"\n        />\n      );\n    } else if (node.type === \"youtube\") {\n      // Extract video ID from the src URL\n      const src = node.attrs?.src || \"\";\n      let videoId = \"\";\n\n      // Handle different YouTube URL formats\n      const youtubeMatch = src.match(\n        /(?:youtube(?:-nocookie)?\\.com\\/(?:embed\\/|watch\\?v=)|youtu\\.be\\/)([^?&]+)/,\n      );\n      if (youtubeMatch) {\n        videoId = youtubeMatch[1];\n      }\n\n      if (!videoId) return null;\n\n      return (\n        <div key={index} className=\"my-4 aspect-video w-full\">\n          <iframe\n            src={`https://www.youtube-nocookie.com/embed/${videoId}`}\n            title=\"YouTube video\"\n            className=\"h-full w-full rounded-lg\"\n            allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n            allowFullScreen\n          />\n        </div>\n      );\n    }\n    return null;\n  });\n}\n\nexport default function IntroductionSettings({\n  dataroomId,\n}: IntroductionSettingsProps) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { isDataroomsPlus, isTrial } = usePlan();\n\n  const [isFetching, setIsFetching] = useState(true);\n  const [isSaving, setIsSaving] = useState(false);\n  const [introductionEnabled, setIntroductionEnabled] = useState(false);\n  const [introductionContent, setIntroductionContent] = useState<any>({\n    type: \"doc\",\n    content: [],\n  });\n  const [showPreview, setShowPreview] = useState(false);\n  const [dataroomName, setDataroomName] = useState<string>(\"Data Room\");\n\n  const isFeatureAvailable = isDataroomsPlus || isTrial;\n  const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n  const hasInitialLoadRef = useRef(false);\n  const skipNextAutosaveRef = useRef(false);\n\n  // Fetch current introduction settings from dataroom\n  useEffect(() => {\n    const fetchSettings = async () => {\n      if (!teamId) return;\n\n      try {\n        const response = await fetch(\n          `/api/teams/${teamId}/datarooms/${dataroomId}`,\n        );\n        if (response.ok) {\n          const data = await response.json();\n          setIntroductionEnabled(data.introductionEnabled || false);\n          setDataroomName(data.name || \"Data Room\");\n\n          const existingContent = data.introductionContent;\n          const hasExistingContent =\n            existingContent?.content && existingContent.content.length > 0;\n\n          if (hasExistingContent) {\n            setIntroductionContent(existingContent);\n          } else {\n            // Auto-generate introduction if empty\n            try {\n              const foldersResponse = await fetch(\n                `/api/teams/${teamId}/datarooms/${dataroomId}/folders?include_documents=true`,\n              );\n\n              let folders: FolderItem[] = [];\n              let rootDocuments: DocumentItem[] = [];\n\n              if (foldersResponse.ok) {\n                const foldersData = await foldersResponse.json();\n                folders = foldersData.filter(\n                  (item: any) => item.name && !item.document,\n                );\n                rootDocuments = foldersData.filter(\n                  (item: any) => item.document,\n                );\n              }\n\n              const generatedContent = generateIntroductionContent(\n                data.name || \"Data Room\",\n                folders,\n                rootDocuments,\n              );\n              setIntroductionContent(generatedContent);\n            } catch (genError) {\n              console.error(\"Failed to auto-generate introduction:\", genError);\n              setIntroductionContent({ type: \"doc\", content: [] });\n            }\n          }\n        }\n      } catch (error) {\n        console.error(\"Failed to fetch introduction settings:\", error);\n      } finally {\n        setIsFetching(false);\n        hasInitialLoadRef.current = true;\n        skipNextAutosaveRef.current = true;\n      }\n    };\n\n    fetchSettings();\n  }, [teamId, dataroomId]);\n\n  // Auto-save function\n  const saveSettings = useCallback(\n    async (enabled: boolean, content: any) => {\n      if (!teamId || !isFeatureAvailable) return;\n\n      setIsSaving(true);\n      try {\n        const response = await fetch(\n          `/api/teams/${teamId}/datarooms/${dataroomId}`,\n          {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              introductionEnabled: enabled,\n              introductionContent: content,\n            }),\n          },\n        );\n\n        if (response.ok) {\n          await mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`);\n        } else {\n          toast.error(\"Failed to save introduction settings\");\n        }\n      } catch (error) {\n        console.error(\"Failed to save introduction settings:\", error);\n        toast.error(\"Failed to save introduction settings\");\n      } finally {\n        setIsSaving(false);\n      }\n    },\n    [teamId, dataroomId, isFeatureAvailable],\n  );\n\n  // Debounced auto-save on content change\n  useEffect(() => {\n    if (!hasInitialLoadRef.current) return;\n\n    // Skip the first auto-save pass after initial load\n    if (skipNextAutosaveRef.current) {\n      skipNextAutosaveRef.current = false;\n      return;\n    }\n\n    if (saveTimeoutRef.current) {\n      clearTimeout(saveTimeoutRef.current);\n    }\n\n    saveTimeoutRef.current = setTimeout(() => {\n      saveSettings(introductionEnabled, introductionContent);\n    }, 1000);\n\n    return () => {\n      if (saveTimeoutRef.current) {\n        clearTimeout(saveTimeoutRef.current);\n      }\n    };\n  }, [introductionContent, introductionEnabled, saveSettings]);\n\n  const handleImageUpload = async (file: File): Promise<string> => {\n    try {\n      const imageUrl = await uploadImage(file, \"assets\");\n      return imageUrl;\n    } catch (error) {\n      console.error(\"Failed to upload image:\", error);\n      throw new Error(\"Failed to upload image\");\n    }\n  };\n\n  const handleToggle = (checked: boolean) => {\n    if (!isFeatureAvailable) {\n      toast.error(\"This feature is only available on Data Rooms Plus plan\");\n      return;\n    }\n    setIntroductionEnabled(checked);\n    if (checked) {\n      toast.success(\"Introduction page enabled\");\n    }\n  };\n\n  const hasContent =\n    introductionContent?.content && introductionContent.content.length > 0;\n\n  if (isFetching) {\n    return (\n      <Card className=\"bg-transparent\">\n        <CardContent className=\"flex items-center justify-center py-10\">\n          <LoadingSpinner className=\"h-6 w-6\" />\n        </CardContent>\n      </Card>\n    );\n  }\n\n  return (\n    <Card className=\"bg-transparent\">\n      <CardHeader>\n        <CardTitle className=\"flex items-center gap-2\">\n          Introduction Page{\" \"}\n          {!isFeatureAvailable && <PlanBadge plan=\"data rooms plus\" />}\n          {isSaving && (\n            <span className=\"text-xs font-normal text-muted-foreground\">\n              Saving...\n            </span>\n          )}\n        </CardTitle>\n        <CardDescription>\n          Create an introduction page that will be shown to viewers when they\n          first access your data room. Write your message based on the premade\n          template below. You can edit, add and remove sections as you see fit.\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-6\">\n        {/* Toggle and Preview */}\n        <div className=\"flex items-center justify-between\">\n          <Label\n            htmlFor=\"introduction-toggle\"\n            className=\"flex items-center gap-2\"\n          >\n            <BookOpenIcon className=\"h-4 w-4\" />\n            Show introduction on first visit\n          </Label>\n          <div className=\"flex items-center gap-2\">\n            <TooltipProvider delayDuration={0}>\n              <Tooltip>\n                <TooltipTrigger asChild>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"icon\"\n                    onClick={() => setShowPreview(true)}\n                    disabled={!hasContent}\n                    className=\"h-8 w-8\"\n                  >\n                    <EyeIcon className=\"h-4 w-4\" />\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent>\n                  <p>Preview introduction</p>\n                </TooltipContent>\n              </Tooltip>\n            </TooltipProvider>\n            <Switch\n              id=\"introduction-toggle\"\n              checked={introductionEnabled}\n              onCheckedChange={handleToggle}\n              disabled={!isFeatureAvailable}\n            />\n          </div>\n        </div>\n\n        {/* Rich Text Editor */}\n        <div className=\"space-y-2\">\n          <RichTextEditor\n            content={introductionContent}\n            onChange={setIntroductionContent}\n            placeholder=\"Welcome to our data room! Here you'll find...\"\n            onImageUpload={handleImageUpload}\n          />\n        </div>\n\n        {/* Preview Dialog */}\n        <Dialog open={showPreview} onOpenChange={setShowPreview}>\n          <DialogContent className=\"max-h-[85vh] max-w-2xl overflow-hidden border-0 p-0 shadow-2xl sm:max-w-xl sm:rounded-2xl md:max-w-2xl\">\n            <DialogHeader className=\"border-b border-gray-100 bg-gray-50 px-4 py-6 dark:border-gray-800 dark:bg-gray-900 sm:px-6\">\n              <p className=\"mb-1 text-xs text-muted-foreground\">\n                This is a preview\n              </p>\n              <DialogTitle className=\"text-xl font-semibold\">\n                Welcome to {dataroomName}\n              </DialogTitle>\n            </DialogHeader>\n            <ScrollArea className=\"max-h-[55vh] px-4 py-5 sm:px-6\">\n              <div className=\"prose prose-sm max-w-none dark:prose-invert\">\n                {renderContent(introductionContent)}\n              </div>\n            </ScrollArea>\n            <div className=\"flex justify-end border-t border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-900 sm:px-6 sm:py-4\">\n              <Button onClick={() => setShowPreview(false)}>\n                Continue to Data Room\n              </Button>\n            </div>\n          </DialogContent>\n        </Dialog>\n      </CardContent>\n      <CardFooter className=\"flex items-center rounded-b-lg border-t bg-muted px-6 py-4\">\n        <p className=\"text-sm text-muted-foreground\">\n          This page will appear as a welcome popup when visitors first open the\n          data room. Changes are saved automatically.\n        </p>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/notification-settings.tsx",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { BadgeCheckIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { fetcher } from \"@/lib/utils\";\n\nimport PlanBadge from \"@/components/billing/plan-badge\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\n\ninterface NotificationSettingsProps {\n  dataroomId: string;\n}\n\nexport default function NotificationSettings({\n  dataroomId,\n}: NotificationSettingsProps) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { isDataroomsPlus, isTrial } = usePlan();\n\n  const { data: dataroomData, mutate: mutateDataroom } = useSWR<{\n    id: string;\n    name: string;\n    pId: string;\n    enableChangeNotifications: boolean;\n  }>(\n    dataroomId ? `/api/teams/${teamId}/datarooms/${dataroomId}` : null,\n    fetcher,\n  );\n\n  const { data: features } = useSWR<{ roomChangeNotifications: boolean }>(\n    teamInfo?.currentTeam?.id\n      ? `/api/feature-flags?teamId=${teamInfo.currentTeam.id}`\n      : null,\n    fetcher,\n  );\n\n  const handleNotificationToggle = async (checked: boolean) => {\n    if (!dataroomId || !teamId) return;\n\n    if (!isDataroomsPlus && !isTrial && !features?.roomChangeNotifications) {\n      toast.error(\"This feature is not available in your plan\");\n      return;\n    }\n\n    toast.promise(\n      fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          enableChangeNotifications: checked,\n        }),\n      }).then(async (res) => {\n        if (!res.ok) {\n          throw new Error(\"Failed to update notification settings\");\n        }\n        await mutateDataroom();\n      }),\n      {\n        loading: \"Updating notification settings...\",\n        success: \"Notification settings updated successfully\",\n        error: \"Failed to update notification settings\",\n      },\n    );\n  };\n\n  return (\n    <Card className=\"bg-transparent\">\n      <CardHeader>\n        <CardTitle>\n          Notifications{\" \"}\n          {!isDataroomsPlus && !features?.roomChangeNotifications ? (\n            <PlanBadge plan=\"data rooms plus\" />\n          ) : null}\n        </CardTitle>\n        <CardDescription>\n          {!dataroomData?.enableChangeNotifications ? \"Enable\" : \"Disable\"}{\" \"}\n          change notification for this dataroom.\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"flex items-center justify-between\">\n        <Label htmlFor=\"notification-toggle\">\n          Notify visitors when new documents are added\n        </Label>\n        <Switch\n          id=\"notification-toggle\"\n          checked={dataroomData?.enableChangeNotifications ?? false}\n          onCheckedChange={handleNotificationToggle}\n        />\n      </CardContent>\n      <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6\">\n        <p className=\"text-sm text-muted-foreground transition-colors\">\n          When enabled,{\" \"}\n          <span className=\"inline-flex items-center gap-x-1 font-bold\">\n            verified visitors <BadgeCheckIcon className=\"h-4 w-4 font-normal\" />\n          </span>{\" \"}\n          will automatically receive an email notification when new documents\n          are added to this dataroom.\n        </p>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/permission-settings.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\n\ntype DefaultPermissionStrategy =\n  | \"INHERIT_FROM_PARENT\"\n  | \"ASK_EVERY_TIME\"\n  | \"HIDDEN_BY_DEFAULT\";\n\ninterface PermissionSettingsProps {\n  dataroomId: string;\n}\n\nexport default function PermissionSettings({\n  dataroomId,\n}: PermissionSettingsProps) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: dataroomData, mutate: mutateDataroom } = useSWR<{\n    id: string;\n    name: string;\n    pId: string;\n    defaultPermissionStrategy: DefaultPermissionStrategy;\n  }>(\n    teamId && dataroomId\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}`\n      : null,\n    fetcher,\n  );\n\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const handlePermissionChange = async (value: DefaultPermissionStrategy) => {\n    if (!dataroomId || !teamId || isUpdating || !dataroomData) return;\n    setIsUpdating(true);\n\n    const optimisticData = {\n      ...dataroomData,\n      defaultPermissionStrategy: value,\n    };\n\n    const mutation = async () => {\n      const res = await fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          defaultPermissionStrategy: value,\n        }),\n      });\n\n      if (!res.ok) {\n        throw new Error(\"Failed to update permission settings\");\n      }\n\n      return res.json();\n    };\n\n    try {\n      await toast.promise(\n        mutateDataroom(mutation(), {\n          optimisticData,\n          rollbackOnError: true,\n          populateCache: true,\n          revalidate: false,\n        }),\n        {\n          loading: \"Updating permission settings...\",\n          success: \"Permission settings updated successfully\",\n          error: (err) => err.message,\n        },\n      );\n    } catch (error) {\n      console.error(error);\n    } finally {\n      setIsUpdating(false);\n    }\n  };\n\n  const currentStrategy =\n    dataroomData?.defaultPermissionStrategy ?? \"HIDDEN_BY_DEFAULT\";\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle>Default File Permissions</CardTitle>\n        <CardDescription>\n          Configure how permissions are handled for new documents and folders\n          added to this dataroom.\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-6\">\n        <RadioGroup\n          value={currentStrategy}\n          onValueChange={handlePermissionChange}\n          disabled={isUpdating || !dataroomData}\n          className=\"space-y-3\"\n        >\n          <div className=\"flex items-center space-x-3\">\n            <RadioGroupItem value=\"INHERIT_FROM_PARENT\" id=\"inherit\" />\n            <div>\n              <Label htmlFor=\"inherit\" className=\"text-sm font-medium\">\n                Inherit from parent folder\n              </Label>\n              <p className=\"text-xs text-muted-foreground\">\n                New documents and folders automatically inherit permissions from\n                their parent folder. Root-level items get view-only permissions\n                by default.\n              </p>\n            </div>\n          </div>\n\n          <div className=\"flex items-center space-x-3\">\n            <RadioGroupItem value=\"ASK_EVERY_TIME\" id=\"ask\" />\n            <div>\n              <Label htmlFor=\"ask\" className=\"text-sm font-medium\">\n                Ask every time\n              </Label>\n              <p className=\"text-xs text-muted-foreground\">\n                Show permissions modal for each document upload to manually\n                configure permissions.\n              </p>\n            </div>\n          </div>\n\n          <div className=\"flex items-center space-x-3\">\n            <RadioGroupItem value=\"HIDDEN_BY_DEFAULT\" id=\"hidden\" />\n            <div>\n              <Label htmlFor=\"hidden\" className=\"text-sm font-medium\">\n                Hidden by default\n              </Label>\n              <p className=\"text-xs text-muted-foreground\">\n                New documents and folders are hidden by default. Permissions\n                must be configured manually.\n              </p>\n            </div>\n          </div>\n        </RadioGroup>\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/settings/settings-tabs.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport {\n  BellIcon,\n  BookOpenIcon,\n  CogIcon,\n  DownloadIcon,\n  ShieldIcon,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface SettingsTabsProps {\n  dataroomId: string;\n}\n\nexport default function SettingsTabs({ dataroomId }: SettingsTabsProps) {\n  const router = useRouter();\n\n  return (\n    <nav className=\"grid space-y-1 text-sm\">\n      <Link\n        href={`/datarooms/${dataroomId}/settings`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\":\n              router.pathname === `/datarooms/[id]/settings`,\n          },\n        )}\n      >\n        <CogIcon className=\"h-4 w-4\" />\n        General\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/settings/introduction`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"introduction\"),\n          },\n        )}\n      >\n        <BookOpenIcon className=\"h-4 w-4\" />\n        Introduction\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/settings/notifications`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"notifications\"),\n          },\n        )}\n      >\n        <BellIcon className=\"h-4 w-4\" />\n        Notifications\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/settings/downloads`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"downloads\"),\n          },\n        )}\n      >\n        <DownloadIcon className=\"h-4 w-4\" />\n        Downloads\n      </Link>\n      <Link\n        href={`/datarooms/${dataroomId}/settings/file-permissions`}\n        className={cn(\n          \"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted\",\n          {\n            \"bg-muted font-medium\": router.pathname.includes(\"permissions\"),\n          },\n        )}\n      >\n        <ShieldIcon className=\"h-4 w-4\" />\n        File Permissions\n      </Link>\n    </nav>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/sortable/sortable-item.tsx",
    "content": "import React from \"react\";\n\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\nexport type ItemCategory = \"folder\" | \"document\";\n\ninterface SortableItemProps {\n  id: string;\n  category: ItemCategory;\n  children: React.ReactElement;\n}\n\nexport const SortableItem: React.FC<SortableItemProps> = ({\n  id,\n  category,\n  children,\n}) => {\n  const { attributes, listeners, setNodeRef, transform, isDragging } =\n    useSortable({\n      id: id,\n      data: {\n        category: category,\n        id: id.replace(category, \"\"),\n      },\n    });\n\n  const style = {\n    transform: CSS.Transform.toString(transform),\n    opacity: isDragging ? 0.5 : 1,\n  };\n\n  const childWithProps = React.cloneElement(children, {\n    isDragging,\n  });\n\n  return (\n    <li\n      ref={setNodeRef}\n      style={style}\n      {...attributes}\n      {...listeners}\n      className=\"cursor-move *:pointer-events-none\"\n    >\n      {childWithProps}\n    </li>\n  );\n};\n"
  },
  {
    "path": "components/datarooms/sortable/sortable-list.tsx",
    "content": "import { useCallback, useState } from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  DndContext,\n  DragEndEvent,\n  DragOverlay,\n  DragStartEvent,\n  KeyboardSensor,\n  PointerSensor,\n  closestCenter,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\";\nimport {\n  SortableContext,\n  arrayMove,\n  sortableKeyboardCoordinates,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport { CheckIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport {\n  DataroomFolderDocument,\n  DataroomFolderWithCount,\n} from \"@/lib/swr/use-dataroom\";\n\nimport DataroomDocumentCard from \"@/components/datarooms/dataroom-document-card\";\nimport { useDeleteFolderModal } from \"@/components/documents/actions/delete-folder-modal\";\nimport FolderCard from \"@/components/documents/folder-card\";\nimport { Button } from \"@/components/ui/button\";\nimport { Portal } from \"@/components/ui/portal\";\n\nimport { SortableItem } from \"./sortable-item\";\n\ntype FolderOrDocument =\n  | (DataroomFolderWithCount & { itemType: \"folder\" })\n  | (DataroomFolderDocument & { itemType: \"document\" });\n\nexport function DataroomSortableList({\n  mixedItems,\n  teamInfo,\n  dataroomId,\n  folderPathName,\n  setIsReordering,\n}: {\n  mixedItems: FolderOrDocument[] | undefined;\n  teamInfo: TeamContextType | null;\n  dataroomId: string;\n  folderPathName?: string[];\n  setIsReordering: (isReordering: boolean) => void;\n}) {\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const [items, setItems] = useState<FolderOrDocument[]>(mixedItems ?? []);\n\n  const { setDeleteModalOpen, setFolderToDelete, DeleteFolderModal } =\n    useDeleteFolderModal(teamInfo, true, dataroomId);\n\n  const handleDeleteFolder = useCallback(\n    (folderId: string) => {\n      const folderToDelete = items.find(\n        (f) => f.id === folderId && f.itemType === \"folder\",\n      );\n      if (folderToDelete && folderToDelete.itemType === \"folder\") {\n        const { itemType, ...folder } = folderToDelete;\n        setFolderToDelete(folder);\n        setDeleteModalOpen(true);\n        setItems((prevItems) =>\n          prevItems.filter((item) => item.id !== folderId),\n        );\n      }\n      setItems((prevItems) => prevItems.filter((item) => item.id !== folderId));\n    },\n    [items, setFolderToDelete, setDeleteModalOpen, setItems],\n  );\n\n  const sensors = useSensors(\n    useSensor(PointerSensor),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    }),\n  );\n\n  const handleDragStart = (event: DragStartEvent) => {\n    const { active } = event;\n    setActiveId(active.id as string);\n  };\n\n  const handleDragEnd = (event: DragEndEvent) => {\n    const { active, over } = event;\n\n    setActiveId(null);\n\n    if (!over) {\n      return;\n    }\n\n    const activeId = active.id as string;\n    const overId = over.id as string;\n\n    if (activeId !== overId) {\n      setItems((prevItems) => {\n        const oldIndex = prevItems.findIndex(\n          (item) => `${item.itemType}-${item.id}` === activeId,\n        );\n        const newIndex = prevItems.findIndex(\n          (item) => `${item.itemType}-${item.id}` === overId,\n        );\n        const newOrder = arrayMove(prevItems, oldIndex, newIndex);\n        return newOrder;\n      });\n    }\n\n    setActiveId(null);\n  };\n\n  const handleSave = async () => {\n    // if nothing changed just return\n    if (items.every((item, index) => item.id === mixedItems?.[index].id)) {\n      setIsReordering(false);\n      return;\n    }\n\n    const newOrder = items.map((item, index) => ({\n      category: item.itemType,\n      id: item.id,\n      orderIndex: index,\n    }));\n\n    try {\n      // Make API call to save the new order\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/reorder`,\n        {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify(newOrder),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to save new order\");\n      }\n\n      // Update local data using SWR's mutate\n      const baseKey = `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}`;\n      mutate(\n        `${baseKey}/folders${folderPathName ? `/${folderPathName.join(\"/\")}` : \"?root=true\"}`,\n      );\n      mutate(\n        `${baseKey}${folderPathName ? `/folders/documents/${folderPathName.join(\"/\")}` : \"/documents\"}`,\n      );\n      mutate(`${baseKey}/folders`);\n      mutate(`${baseKey}/folders?include_documents=true`);\n      setIsReordering(false);\n      toast.success(\"Folder order saved successfully\");\n    } catch (error) {\n      console.error(\"Failed to save new order:\", error);\n      toast.error(\"Failed to save order\");\n      // Optionally, show an error message to the user\n    } finally {\n      setIsReordering(false);\n    }\n  };\n\n  const renderItem = (item: FolderOrDocument) => {\n    const itemId = `${item.itemType}-${item.id}`;\n\n    return (\n      <SortableItem key={itemId} id={itemId} category={item.itemType}>\n        {item.itemType === \"folder\" ? (\n          <FolderCard\n            folder={item}\n            teamInfo={teamInfo}\n            isDataroom\n            dataroomId={dataroomId}\n            onDelete={handleDeleteFolder}\n          />\n        ) : (\n          <DataroomDocumentCard\n            document={item as DataroomFolderDocument}\n            teamInfo={teamInfo}\n            dataroomId={dataroomId}\n          />\n        )}\n      </SortableItem>\n    );\n  };\n\n  const activeItem = activeId\n    ? items.find((item) => `${item.itemType}-${item.id}` === activeId)\n    : null;\n\n  return (\n    <div className=\"rounded-lg border-2 border-dashed border-gray-400 p-2\">\n      <DndContext\n        sensors={sensors}\n        collisionDetection={closestCenter}\n        onDragEnd={handleDragEnd}\n        onDragStart={handleDragStart}\n      >\n        <SortableContext\n          items={items.map((item) => `${item.itemType}-${item.id}`)}\n          strategy={verticalListSortingStrategy}\n        >\n          <ul role=\"list\" className=\"relative space-y-4\">\n            {items.map(renderItem)}\n          </ul>\n        </SortableContext>\n        <DragOverlay>\n          {activeItem ? (\n            <div\n              style={{\n                transform: \"scale(0.7)\",\n                opacity: 1,\n                pointerEvents: \"none\",\n                boxShadow: \"0 0 10px rgba(0, 0, 0, 0.2)\",\n              }}\n            >\n              {activeItem.itemType === \"folder\" ? (\n                <FolderCard\n                  folder={activeItem}\n                  teamInfo={teamInfo}\n                  isDataroom\n                  dataroomId={dataroomId}\n                  onDelete={handleDeleteFolder}\n                />\n              ) : (\n                <DataroomDocumentCard\n                  document={activeItem}\n                  teamInfo={teamInfo}\n                  dataroomId={dataroomId}\n                />\n              )}\n            </div>\n          ) : null}\n        </DragOverlay>\n      </DndContext>\n\n      <Portal containerId=\"dataroom-reordering-action\">\n        <Button\n          onClick={handleSave}\n          variant={\"outline\"}\n          size=\"sm\"\n          className=\"gap-x-1\"\n        >\n          <CheckIcon className=\"size-4\" />\n          Save order\n        </Button>\n      </Portal>\n      <DeleteFolderModal />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/datarooms/stats-card.tsx",
    "content": "import ErrorPage from \"next/error\";\n\nimport { useDataroomStats } from \"@/lib/swr/use-dataroom-stats\";\n\nimport StatsElement from \"@/components/documents/stats-element\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function StatsCard() {\n  const { stats, loading, error } = useDataroomStats();\n\n  if (error && error.status === 404) {\n    return <ErrorPage statusCode={404} />;\n  }\n\n  if (loading) {\n    return (\n      <div className=\"grid grid-cols-1 space-y-2 border-foreground/5 sm:grid-cols-3 sm:space-x-2 sm:space-y-0 lg:grid-cols-3 lg:space-x-3\">\n        {Array.from({ length: 3 }).map((_, i) => (\n          <div\n            className=\"rounded-lg border border-foreground/5 px-4 py-6 sm:px-6 lg:px-8\"\n            key={i}\n          >\n            <Skeleton className=\"h-6 w-[80%] rounded-sm\" />\n            <Skeleton className=\"mt-4 h-8 w-9\" />\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  const statistics = [\n    {\n      name: \"Number of views\",\n      value: stats?.dataroomViews.length.toString() ?? \"0\",\n      active: true,\n    },\n    {\n      name: \"Number of documents views\",\n      value: stats?.documentViews.length.toString() ?? \"0\",\n      active: true,\n    },\n    {\n      name: \"Total time spent\",\n      value:\n        stats?.total_duration == null\n          ? \"46\"\n          : stats?.total_duration < 60000\n            ? `${Math.round(stats?.total_duration / 1000)}`\n            : `${Math.floor(stats?.total_duration / 60000)}:${\n                Math.round((stats?.total_duration % 60000) / 1000) < 10\n                  ? `0${Math.round((stats?.total_duration % 60000) / 1000)}`\n                  : Math.round((stats?.total_duration % 60000) / 1000)\n              }`,\n      unit: stats?.total_duration! < 60000 ? \"seconds\" : \"minutes\",\n      active: true,\n    },\n  ];\n\n  return stats && stats.dataroomViews.length > 0 ? (\n    <div className=\"grid grid-cols-1 space-y-2 border-foreground/5 sm:grid-cols-3 sm:space-x-2 sm:space-y-0 lg:grid-cols-3 lg:space-x-3\">\n      {statistics.map((stat, statIdx) => (\n        <StatsElement key={statIdx} stat={stat} statIdx={statIdx} />\n      ))}\n    </div>\n  ) : null;\n}\n"
  },
  {
    "path": "components/document-upload.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useMemo } from \"react\";\n\nimport { UploadIcon } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { useDropzone } from \"react-dropzone\";\nimport { toast } from \"sonner\";\n\nimport {\n  FREE_PLAN_ACCEPTED_FILE_TYPES,\n  FULL_PLAN_ACCEPTED_FILE_TYPES,\n  SUPPORTED_DOCUMENT_MIME_TYPES,\n} from \"@/lib/constants\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { bytesToSize } from \"@/lib/utils\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\nimport {\n  getFileSizeLimit,\n  getFileSizeLimits,\n} from \"@/lib/utils/get-file-size-limits\";\nimport { getPagesCount } from \"@/lib/utils/get-page-number-count\";\n\nexport default function DocumentUpload({\n  currentFile,\n  setCurrentFile,\n}: {\n  currentFile: File | null;\n  setCurrentFile: React.Dispatch<React.SetStateAction<File | null>>;\n}) {\n  const router = useRouter();\n  const { theme, systemTheme } = useTheme();\n  const isLight =\n    theme === \"light\" || (theme === \"system\" && systemTheme === \"light\");\n  const { isFree, isTrial } = usePlan();\n  const { limits } = useLimits();\n\n  // Get file size limits\n  const fileSizeLimits = useMemo(\n    () =>\n      getFileSizeLimits({\n        limits,\n        isFree,\n        isTrial,\n      }),\n    [limits, isFree, isTrial],\n  );\n\n  const { getRootProps, getInputProps } = useDropzone({\n    accept:\n      isFree && !isTrial\n        ? FREE_PLAN_ACCEPTED_FILE_TYPES\n        : FULL_PLAN_ACCEPTED_FILE_TYPES,\n    multiple: false,\n    onDropAccepted: (acceptedFiles) => {\n      if (acceptedFiles.length === 0) {\n        return;\n      }\n      const file = acceptedFiles[0];\n      const fileType = file.type;\n      const fileSizeLimitMB = getFileSizeLimit(fileType, fileSizeLimits); // in MB\n      const fileSizeLimit = fileSizeLimitMB * 1024 * 1024; // in bytes\n\n      if (file.size > fileSizeLimit) {\n        const message = `File size too big for ${fileType} (max. ${fileSizeLimitMB} MB)`;\n        if (isFree && !isTrial) {\n          toast.error(message, {\n            description: \"Upgrade to a paid plan to increase the limit\",\n            action: {\n              label: \"Upgrade\",\n              onClick: () => router.push(\"/settings/upgrade\"),\n            },\n            duration: 10000,\n          });\n        } else {\n          toast.error(message);\n        }\n        return;\n      }\n\n      if (file.type !== \"application/pdf\") {\n        setCurrentFile(file);\n        return;\n      }\n      file\n        .arrayBuffer()\n        .then((buffer) => {\n          getPagesCount(buffer).then((numPages) => {\n            if (numPages > fileSizeLimits.maxPages) {\n              toast.error(\n                `File has too many pages (max. ${fileSizeLimits.maxPages})`,\n              );\n            } else {\n              setCurrentFile(file);\n            }\n          });\n        })\n        .catch((error) => {\n          console.error(\"Error reading file:\", error);\n          toast.error(\"Failed to read the file\");\n        });\n    },\n    onDropRejected: (fileRejections) => {\n      const { errors, file } = fileRejections[0];\n      let message;\n      if (errors[0].code === \"file-too-large\") {\n        const fileSizeLimitMB = getFileSizeLimit(file.type, fileSizeLimits);\n        message = `File size too big (max. ${fileSizeLimitMB} MB)`;\n        if (isFree && !isTrial) {\n          toast.error(message, {\n            description: \"Upgrade to a paid plan to increase the limit\",\n            action: {\n              label: \"Upgrade\",\n              onClick: () => router.push(\"/settings/upgrade\"),\n            },\n            duration: 10000,\n          });\n          return;\n        }\n      } else if (errors[0].code === \"file-invalid-type\") {\n        const isSupported = SUPPORTED_DOCUMENT_MIME_TYPES.includes(file.type);\n        message = \"File type not supported\";\n        if (isFree && !isTrial && isSupported) {\n          toast.error(`${message} on free plan`, {\n            description: `Upgrade to a paid plan to upload ${file.type} files`,\n            action: {\n              label: \"Upgrade\",\n              onClick: () => router.push(\"/settings/upgrade\"),\n            },\n            duration: 10000,\n          });\n          return;\n        }\n      } else {\n        message = errors[0].message;\n      }\n      toast.error(message);\n    },\n  });\n\n  const imageBlobUrl = useMemo(\n    () => (currentFile ? URL.createObjectURL(currentFile) : \"\"),\n    [currentFile],\n  );\n\n  return (\n    <div className=\"col-span-full\">\n      <div\n        {...getRootProps()}\n        className=\"group relative block cursor-pointer font-semibold text-foreground hover:bg-gray-100 hover:text-gray-900 hover:dark:bg-gray-900 hover:dark:text-gray-500\"\n      >\n        <input {...getInputProps()} name=\"file\" className=\"sr-only\" />\n        <div className=\"flex min-h-[200px] items-center justify-center rounded-lg border border-dashed border-black/25 px-6 py-10 dark:border-white/25 md:min-w-full\">\n          {currentFile ? (\n            <div\n              className=\"pointer-events-none absolute inset-0 opacity-10 transition-opacity group-hover:opacity-5\"\n              style={{\n                backgroundImage: `url(${imageBlobUrl})`,\n                backgroundPosition: \"center\",\n                backgroundSize: \"cover\",\n              }}\n            />\n          ) : null}\n\n          <div className=\"max-w-md text-center\">\n            {currentFile ? (\n              <div className=\"flex flex-col items-center text-foreground sm:flex-row sm:space-x-2\">\n                <div>\n                  {fileIcon({\n                    fileType: currentFile.type,\n                    isLight,\n                  })}\n                </div>\n                <p className=\"max-w-md truncate\">{currentFile.name}</p>\n                <p className=\"text-gray-500\">{bytesToSize(currentFile.size)}</p>\n              </div>\n            ) : (\n              <UploadIcon\n                className=\"mx-auto h-10 w-10 text-gray-500\"\n                aria-hidden=\"true\"\n              />\n            )}\n\n            <div className=\"mt-4 flex text-sm leading-6 text-gray-500\">\n              <span className=\"mx-auto\">\n                {currentFile ? \"\" : \"Choose file to upload or drag and drop\"}\n              </span>\n            </div>\n            <p className=\"text-xs leading-5 text-gray-500\">\n              {currentFile\n                ? \"Replace file?\"\n                : isFree && !isTrial\n                  ? `Only *.pdf, *.xls, *.xlsx, *.csv, *.tsv, *.ods, *.png, *.jpeg, *.jpg`\n                  : `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.tsv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.rtf, *txt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}\n            </p>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/actions/delete-documents-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Modal } from \"@/components/ui/modal\";\n\nfunction DeleteItemsModal({\n  showDeleteItemsModal,\n  setShowDeleteItemsModal,\n  documentIds,\n  setSelectedDocuments,\n  folderIds,\n  setSelectedFolder,\n}: {\n  showDeleteItemsModal: boolean;\n  setShowDeleteItemsModal: Dispatch<SetStateAction<boolean>>;\n  documentIds: string[];\n  setSelectedDocuments: Dispatch<SetStateAction<string[]>>;\n  folderIds: string[];\n  setSelectedFolder: Dispatch<SetStateAction<string[]>>;\n}) {\n  const router = useRouter();\n  const folderPathName = router.query.name as string[] | undefined;\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n\n  const [deleting, setDeleting] = useState(false);\n  const [isValid, setIsValid] = useState(false);\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const { value } = e.target;\n    // Check if the input matches the pattern\n    if (value === \"permanently delete\") {\n      setIsValid(true);\n    } else {\n      setIsValid(false);\n    }\n  };\n  const parentFolderPath = folderPathName\n    ?.join(\"/\")\n    ?.substring(0, folderPathName?.lastIndexOf(\"/\"));\n\n  async function deleteDocumentsAndFolders(\n    documentIds: string[],\n    folderIds: string[],\n  ) {\n    return new Promise(async (resolve, reject) => {\n      setDeleting(true);\n\n      try {\n        const deleteDocumentPromises = documentIds.map((documentId) =>\n          fetch(\n            `/api/teams/${teamInfo?.currentTeam?.id}/documents/${documentId}`,\n            {\n              method: \"DELETE\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n              },\n            },\n          ).then(async (res) => {\n            if (!res.ok) {\n              const error = await res.json();\n              throw new Error(error.message || \"Failed to delete document\");\n            }\n            analytics.capture(\"Document Deleted\", {\n              team: teamInfo?.currentTeam?.id,\n              documentId,\n            });\n            return documentId;\n          }),\n        );\n\n        const deleteFolderPromises = folderIds.map((folderId) =>\n          fetch(\n            `/api/teams/${teamInfo?.currentTeam?.id}/folders/manage/${folderId}`,\n            {\n              method: \"DELETE\",\n            },\n          ).then(async (res) => {\n            if (!res.ok) {\n              const error = await res.json();\n              throw new Error(error.message || \"Failed to delete folder\");\n            }\n            analytics.capture(\"Folder Deleted\", {\n              team: teamInfo?.currentTeam?.id,\n              folderId,\n            });\n            return folderId;\n          }),\n        );\n\n        const results = await Promise.allSettled([\n          ...deleteDocumentPromises,\n          ...deleteFolderPromises,\n        ]);\n\n        const successfullyDeletedItems = results\n          .filter((result) => result.status === \"fulfilled\")\n          .map((result) => (result as PromiseFulfilledResult<string>).value);\n\n        const errors = results\n          .filter((result) => result.status === \"rejected\")\n          .map((result) => (result as PromiseRejectedResult).reason);\n        if (!errors.length) {\n          setShowDeleteItemsModal(false);\n        }\n        setSelectedDocuments((prevSelected) =>\n          prevSelected.filter((id) => !successfullyDeletedItems.includes(id)),\n        );\n\n        setSelectedFolder((prevSelected) =>\n          prevSelected.filter((id) => !successfullyDeletedItems.includes(id)),\n        );\n\n        await mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/folders?root=true`,\n        );\n        await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders`);\n        await mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/folders${parentFolderPath}`,\n        );\n\n        if (folderPathName && folderPathName.length > 0) {\n          await mutate(\n            `/api/teams/${\n              teamInfo?.currentTeam?.id\n            }/folders/documents/${folderPathName.join(\"/\")}`,\n          );\n        } else {\n          const { search, sort, page, limit } = router.query;\n          const queryParts = [];\n          if (search) queryParts.push(`query=${search}`);\n          if (sort) queryParts.push(`sort=${sort}`);\n\n          const pageNum = Number(page) || 1;\n          const limitNum = Number(limit) || 10;\n\n          const paginationParams =\n            search || sort ? `&page=${pageNum}&limit=${limitNum}` : \"\";\n\n          if (paginationParams) queryParts.push(paginationParams.substring(1));\n          const queryString =\n            queryParts.length > 0 ? `?${queryParts.join(\"&\")}` : \"\";\n\n          await mutate(\n            `/api/teams/${teamInfo?.currentTeam?.id}/documents${queryString}`,\n          );\n        }\n\n        setDeleting(false);\n\n        if (errors.length) {\n          reject(errors);\n        } else {\n          resolve(null);\n        }\n      } catch (error) {\n        setDeleting(false);\n        reject((error as Error).message);\n      } finally {\n        setShowDeleteItemsModal(false);\n      }\n    });\n  }\n\n  return (\n    <Modal\n      showModal={showDeleteItemsModal}\n      setShowModal={setShowDeleteItemsModal}\n      noBackdropBlur\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <DialogTitle className=\"text-2xl\">\n          Delete{\" \"}\n          {documentIds.length > 0 && (\n            <>\n              {documentIds.length} Document{documentIds.length > 1 && \"s\"}\n            </>\n          )}\n          {documentIds.length > 0 && folderIds.length > 0 && \" and \"}\n          {folderIds.length > 0 && (\n            <>\n              {folderIds.length} Folder{folderIds.length > 1 && \"s\"}\n            </>\n          )}\n        </DialogTitle>\n        <DialogDescription className=\"space-y-2\">\n          {documentIds.length > 0 && (\n            <p>\n              <strong>Documents Warning</strong>: This will permanently delete\n              your selected documents, all associated links and their respective\n              views.  {\" \"}\n            </p>\n          )}\n          {folderIds.length > 0 && (\n            <p>\n              <strong>Folders Warning</strong>: This will permanently delete the\n              folder and all its contents, including subfolders, documents,\n              dataroom references, and any visitor analytics.\n            </p>\n          )}\n        </DialogDescription>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          const title = `${documentIds.length > 0 ? `${documentIds.length} document${documentIds.length > 1 ? \"s\" : \"\"}` : \"\"}${\n            documentIds.length > 0 && folderIds.length > 0 ? \" and \" : \"\"\n          }${folderIds.length > 0 ? `${folderIds.length} folder${folderIds.length > 1 ? \"s\" : \"\"}` : \"\"}`;\n\n          toast.promise(deleteDocumentsAndFolders(documentIds, folderIds), {\n            loading: `Deleting ${title}...`,\n            success: `${title} deleted successfully!`,\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-muted-foreground\"\n          >\n            To confirm deletion, type{\" \"}\n            <span className=\"font-semibold text-foreground\">\n              permanently delete\n            </span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"permanently delete\"\n              required\n              autoComplete=\"off\"\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n              onInput={handleInputChange}\n            />\n          </div>\n        </div>\n\n        <Button\n          variant=\"destructive\"\n          type=\"submit\"\n          loading={deleting}\n          disabled={!isValid}\n        >\n          Confirm delete\n          {documentIds.length > 0 &&\n            ` ${documentIds.length} document${documentIds.length > 1 ? \"s\" : \"\"}`}\n          {documentIds.length > 0 && folderIds.length > 0 && \" and\"}\n          {folderIds.length > 0 &&\n            ` ${folderIds.length} folder${folderIds.length > 1 ? \"s\" : \"\"}`}\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteDocumentsAndFoldersModal({\n  documentIds,\n  setSelectedDocuments,\n  folderIds,\n  setSelectedFolder,\n}: {\n  setSelectedFolder: Dispatch<SetStateAction<string[]>>;\n  folderIds: string[];\n  documentIds: string[];\n  setSelectedDocuments: Dispatch<SetStateAction<string[]>>;\n}) {\n  const [showDeleteItemsModal, setShowDeleteItemsModal] = useState(false);\n\n  const DeleteItemsModalCallback = useCallback(() => {\n    return (\n      <DeleteItemsModal\n        showDeleteItemsModal={showDeleteItemsModal}\n        setShowDeleteItemsModal={setShowDeleteItemsModal}\n        documentIds={documentIds}\n        setSelectedDocuments={setSelectedDocuments}\n        folderIds={folderIds}\n        setSelectedFolder={setSelectedFolder}\n      />\n    );\n  }, [\n    showDeleteItemsModal,\n    setShowDeleteItemsModal,\n    documentIds,\n    setSelectedDocuments,\n    folderIds,\n    setSelectedFolder,\n  ]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteItemsModal,\n      DeleteItemsModal: DeleteItemsModalCallback,\n    }),\n    [setShowDeleteItemsModal, DeleteItemsModalCallback],\n  );\n}\n"
  },
  {
    "path": "components/documents/actions/delete-folder-modal.tsx",
    "content": "import { useCallback, useMemo, useState } from \"react\";\n\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { DataroomFolderWithCount } from \"@/lib/swr/use-dataroom\";\nimport { FolderWithCount } from \"@/lib/swr/use-documents\";\n\nimport { DeleteFolderModal } from \"../delete-folder-modal\";\n\nexport function useDeleteFolderModal(\n  teamInfo: any,\n  isDataroom?: boolean,\n  dataroomId?: string,\n) {\n  const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);\n  const [folderToDelete, setFolderToDelete] = useState<\n    FolderWithCount | DataroomFolderWithCount | null\n  >(null);\n  const parentFolderPath = folderToDelete?.path.substring(\n    0,\n    folderToDelete?.path.lastIndexOf(\"/\"),\n  );\n\n  const DeleteFolderModalCallback = useCallback(() => {\n    if (!deleteModalOpen || !folderToDelete) return null;\n\n    const handleDeleteFolder = async (folderId: string) => {\n      const endpointTargetType =\n        isDataroom && dataroomId\n          ? `datarooms/${dataroomId}/folders`\n          : \"folders\";\n\n      toast.promise(\n        fetch(\n          `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/manage/${folderId}`,\n          {\n            method: \"DELETE\",\n          },\n        ).then(async (res) => {\n          if (!res.ok) {\n            const error = await res.json();\n            throw new Error(error.message || \"Failed to delete folder\");\n          }\n          return res;\n        }),\n        {\n          loading: isDataroom ? \"Removing folder...\" : \"Deleting folder...\",\n          success: () => {\n            mutate(\n              `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}?root=true`,\n            );\n            mutate(\n              `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}`,\n            );\n            mutate(\n              `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}${parentFolderPath}`,\n            );\n            return isDataroom\n              ? \"Folder removed successfully.\"\n              : `Folder deleted successfully with ${folderToDelete?._count.documents} documents and ${folderToDelete?._count.childFolders} folders`;\n          },\n          error: (err) =>\n            err.message ||\n            (isDataroom\n              ? \"Failed to remove folder.\"\n              : \"Failed to delete folder. Move documents first.\"),\n        },\n      );\n    };\n\n    return (\n      <DeleteFolderModal\n        folderId={folderToDelete.id}\n        open={deleteModalOpen}\n        setOpen={setDeleteModalOpen}\n        folderName={folderToDelete.name}\n        documents={folderToDelete._count.documents}\n        childFolders={folderToDelete._count.childFolders}\n        isDataroom={isDataroom}\n        handleButtonClick={(event, folderId) => {\n          event.stopPropagation();\n          event.preventDefault();\n          setDeleteModalOpen(false);\n          handleDeleteFolder(folderId);\n        }}\n      />\n    );\n  }, [\n    deleteModalOpen,\n    folderToDelete,\n    teamInfo,\n    isDataroom,\n    dataroomId,\n    parentFolderPath,\n  ]);\n\n  return useMemo(\n    () => ({\n      setDeleteModalOpen,\n      setFolderToDelete,\n      DeleteFolderModal: DeleteFolderModalCallback,\n    }),\n    [DeleteFolderModalCallback],\n  );\n}\n"
  },
  {
    "path": "components/documents/add-document-modal.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { FormEvent, useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { DefaultPermissionStrategy } from \"@prisma/client\";\nimport { parsePageId } from \"notion-utils\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport {\n  DocumentData,\n  createDocument,\n  createNewDocumentVersion,\n} from \"@/lib/documents/create-document\";\nimport { putFile } from \"@/lib/files/put-file\";\nimport { useDataroomPermissions } from \"@/lib/hooks/use-dataroom-permissions\";\nimport { getNotionPageIdFromSlug } from \"@/lib/notion/utils\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { getSupportedContentType } from \"@/lib/utils/get-content-type\";\n\nimport { SetUnifiedPermissionsModal } from \"@/components/datarooms/groups/set-unified-permissions-modal\";\nimport DocumentUpload from \"@/components/document-upload\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\n\ninterface DataroomDocument {\n  id: string;\n  documentId: string;\n  dataroomId: string;\n}\n\nexport function AddDocumentModal({\n  newVersion,\n  children,\n  isDataroom,\n  dataroomId,\n  setAddDocumentModalOpen,\n  openModal,\n}: {\n  newVersion?: boolean;\n  children: React.ReactNode;\n  isDataroom?: boolean;\n  openModal?: boolean;\n  dataroomId?: string;\n  setAddDocumentModalOpen?: (isOpen: boolean) => void;\n}) {\n  const router = useRouter();\n  const analytics = useAnalytics();\n  const [uploading, setUploading] = useState<boolean>(false);\n  const [isOpen, setIsOpen] = useState<boolean | undefined>(undefined);\n  const [currentFile, setCurrentFile] = useState<File | null>(null);\n  const [notionLink, setNotionLink] = useState<string | null>(null);\n  const [webLink, setWebLink] = useState<string | null>(null);\n  const [uploadMode, setUploadMode] = useState<\"file\" | \"link\">(\"file\");\n  const [showGroupPermissions, setShowGroupPermissions] = useState(false);\n  const [uploadedFiles, setUploadedFiles] = useState<\n    {\n      documentId: string;\n      dataroomDocumentId: string;\n      fileName: string;\n    }[]\n  >([]);\n  const teamInfo = useTeam();\n  const { canAddDocuments, limits } = useLimits();\n  const { plan, isFree, isTrial, isPaused } = usePlan();\n  const { dataroom } = useDataroom();\n  const teamId = teamInfo?.currentTeam?.id as string;\n\n  const { applyPermissions } = useDataroomPermissions();\n\n  useEffect(() => {\n    if (openModal) setIsOpen(openModal);\n  }, [openModal]);\n\n  /** current folder name */\n  const currentFolderPath = router.query.name as string[] | undefined;\n\n  const addDocumentToDataroom = async ({\n    documentId,\n    folderPathName,\n  }: {\n    documentId: string;\n    folderPathName?: string;\n  }): Promise<Response | undefined> => {\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            documentId: documentId,\n            folderPathName: folderPathName,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        toast.error(message);\n        return undefined;\n      }\n\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents`,\n      );\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders/documents/${folderPathName}`,\n      );\n\n      toast.success(\"Document added to dataroom successfully! 🎉\");\n      return response;\n    } catch (error) {\n      toast.error(\"Error adding document to dataroom.\");\n      console.error(\n        \"An error occurred while adding document to the dataroom: \",\n        error,\n      );\n      return undefined;\n    }\n  };\n\n  const toastErrorMessage = () =>\n    toast.error(\n      \"Failed to apply default permissions. Update the group permissions in the group settings.\",\n    );\n\n  const applyUnifiedPermissionsToDocument = async (\n    document: any,\n    dataroomDocument: DataroomDocument & {\n      dataroom: {\n        _count: { viewerGroups: number; permissionGroups: number };\n      };\n    },\n    currentFolderPath?: string[],\n  ): Promise<void> => {\n    const hasAnyGroups =\n      dataroomDocument.dataroom._count.viewerGroups > 0 ||\n      dataroomDocument.dataroom._count.permissionGroups > 0;\n\n    if (!hasAnyGroups) return;\n\n    const strategy =\n      dataroom?.defaultPermissionStrategy ||\n      DefaultPermissionStrategy.INHERIT_FROM_PARENT;\n\n    if (strategy === DefaultPermissionStrategy.ASK_EVERY_TIME) {\n      setShowGroupPermissions(true);\n      setUploadedFiles([\n        {\n          documentId: document.id,\n          dataroomDocumentId: dataroomDocument.id,\n          fileName: document.name,\n        },\n      ]);\n    } else if (strategy === DefaultPermissionStrategy.INHERIT_FROM_PARENT) {\n      const isRootLevel = !currentFolderPath || currentFolderPath.length === 0;\n\n      try {\n        const result = await applyPermissions(\n          dataroomId!,\n          [document.id],\n          \"INHERIT_FROM_PARENT\",\n          isRootLevel ? undefined : currentFolderPath?.join(\"/\"),\n          toastErrorMessage,\n        );\n\n        if (!result.success) {\n          console.error(\"Failed to apply permissions:\", result.error);\n          toastErrorMessage();\n        }\n      } catch (error) {\n        console.error(\"Failed to apply permissions:\", error);\n        toastErrorMessage();\n      }\n    }\n    // strategy === DefaultPermissionStrategy.HIDDEN_BY_DEFAULT - do nothing, documents remain hidden\n  };\n\n  const handleFileUpload = async (\n    event: FormEvent<HTMLFormElement>,\n  ): Promise<void> => {\n    event.preventDefault();\n\n    // Check if team is paused\n    if (isPaused) {\n      toast.error(\n        \"Your subscription is paused. Resume your subscription to upload documents.\",\n        {\n          action: {\n            label: \"Go to Billing\",\n            onClick: () => router.push(\"/settings/billing\"),\n          },\n        },\n      );\n      return;\n    }\n\n    // Check if the file is chosen\n    if (!currentFile) {\n      toast.error(\"Please select a file to upload.\");\n      return; // prevent form from submitting\n    }\n\n    if (!canAddDocuments) {\n      toast.error(\n        limits?.documents\n          ? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`\n          : \"You have reached the maximum number of documents.\",\n        {\n          action: {\n            label: \"Upgrade\",\n            onClick: () => router.push(\"/settings/billing\"),\n          },\n          duration: 8000,\n        },\n      );\n      return;\n    }\n\n    try {\n      setUploading(true);\n\n      let contentType = currentFile.type;\n      let supportedFileType = getSupportedContentType(contentType);\n\n      if (\n        currentFile.name.endsWith(\".dwg\") ||\n        currentFile.name.endsWith(\".dxf\")\n      ) {\n        supportedFileType = \"cad\";\n        contentType = `image/vnd.${currentFile.name.split(\".\").pop()}`;\n      }\n\n      if (currentFile.name.endsWith(\".xlsm\")) {\n        supportedFileType = \"sheet\";\n        contentType = \"application/vnd.ms-excel.sheet.macroEnabled.12\";\n      }\n\n      if (!supportedFileType) {\n        setUploading(false);\n        toast.error(\n          \"Unsupported file format. Please upload a PDF, Powerpoint, Excel, Word or image file.\",\n        );\n        return;\n      }\n\n      const { type, data, numPages, fileSize } = await putFile({\n        file: currentFile,\n        teamId,\n      });\n\n      const documentData: DocumentData = {\n        name: currentFile.name,\n        key: data!,\n        storageType: type!,\n        contentType: contentType,\n        supportedFileType: supportedFileType,\n        fileSize: fileSize,\n      };\n      let response: Response | undefined;\n      // create a document or new version in the database\n      if (!newVersion) {\n        // create a document in the database\n        response = await createDocument({\n          documentData,\n          teamId,\n          numPages,\n          folderPathName: currentFolderPath?.join(\"/\"),\n        });\n      } else {\n        // create a new version for existing document in the database\n        const documentId = router.query.id as string;\n        response = await createNewDocumentVersion({\n          documentData,\n          documentId,\n          numPages,\n          teamId,\n        });\n      }\n\n      if (response) {\n        const document = await response.json();\n\n        if (isDataroom && dataroomId) {\n          const dataroomResponse = await addDocumentToDataroom({\n            documentId: document.id,\n            folderPathName: currentFolderPath?.join(\"/\"),\n          });\n\n          if (dataroomResponse?.ok) {\n            const dataroomDocument =\n              (await dataroomResponse.json()) as DataroomDocument & {\n                dataroom: {\n                  _count: { viewerGroups: number; permissionGroups: number };\n                };\n              };\n\n            await applyUnifiedPermissionsToDocument(\n              document,\n              dataroomDocument,\n              currentFolderPath,\n            );\n          }\n\n          analytics.capture(\"Document Added\", {\n            documentId: document.id,\n            name: document.name,\n            numPages: document.numPages,\n            path: router.asPath,\n            type: document.type,\n            teamId: teamId,\n            dataroomId: dataroomId,\n            $set: {\n              teamId: teamId,\n              teamPlan: plan,\n            },\n          });\n\n          return;\n        }\n\n        if (!newVersion) {\n          mutate(`/api/teams/${teamId}/documents`);\n          toast.success(\"Document uploaded. Redirecting to document page...\");\n\n          analytics.capture(\"Document Added\", {\n            documentId: document.id,\n            name: document.name,\n            numPages: document.numPages,\n            path: router.asPath,\n            type: document.type,\n            teamId: teamId,\n            $set: {\n              teamId: teamId,\n              teamPlan: plan,\n            },\n          });\n\n          // redirect to the document page\n          router.push(\"/documents/\" + document.id);\n        } else {\n          analytics.capture(\"Document Added\", {\n            documentId: document.id,\n            name: document.name,\n            numPages: document.numPages,\n            path: router.asPath,\n            type: document.type,\n            newVersion: true,\n            teamId: teamId,\n            $set: {\n              teamId: teamId,\n              teamPlan: plan,\n            },\n          });\n          toast.success(\"New document version uploaded.\");\n\n          // reload to the document page\n          router.reload();\n        }\n      }\n    } catch (error) {\n      setUploading(false);\n      toast.error(\"An error occurred while uploading the file.\");\n      console.error(\"An error occurred while uploading the file: \", error);\n    } finally {\n      setUploading(false);\n      setIsOpen(false);\n      setAddDocumentModalOpen && setAddDocumentModalOpen(false);\n    }\n  };\n\n  const createNotionFileName = () => {\n    // Extract Notion file name from the URL\n    const urlSegments = (notionLink as string).split(\"/\")[3];\n    // Remove the last hyphen along with the Notion ID\n    const extractName = urlSegments.replace(/-([^/-]+)$/, \"\");\n    const notionFileName = extractName.replaceAll(\"-\", \" \") || \"Notion Link\";\n\n    return notionFileName;\n  };\n\n  const handleNotionUpload = async (\n    event: FormEvent<HTMLFormElement>,\n  ): Promise<void> => {\n    event.preventDefault();\n\n    // Check if team is paused\n    if (isPaused) {\n      toast.error(\n        \"Your subscription is paused. Resume your subscription to upload documents.\",\n        {\n          action: {\n            label: \"Go to Billing\",\n            onClick: () => router.push(\"/settings/billing\"),\n          },\n        },\n      );\n      return;\n    }\n\n    if (!canAddDocuments) {\n      toast.error(\n        limits?.documents\n          ? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`\n          : \"You have reached the maximum number of documents.\",\n        {\n          action: {\n            label: \"Upgrade\",\n            onClick: () => router.push(\"/settings/billing\"),\n          },\n          duration: 8000,\n        },\n      );\n      return;\n    }\n\n    // Check if the field is empty or not\n    if (!notionLink) {\n      toast.error(\"Please enter a Notion link to proceed.\");\n      return; // prevent form from submitting\n    }\n\n    // Validate URL format with Zod\n    const urlSchema = z.string().url();\n    const urlValidation = urlSchema.safeParse(notionLink);\n\n    if (!urlValidation.success) {\n      toast.error(\"Please enter a valid URL format.\");\n      return;\n    }\n\n    // Try to validate the Notion page URL\n    let validateNotionPageId = parsePageId(notionLink);\n\n    // If parsePageId fails, try to get page ID from slug\n    if (validateNotionPageId === null) {\n      try {\n        const pageId = await getNotionPageIdFromSlug(notionLink);\n        validateNotionPageId = pageId || undefined;\n      } catch (slugError) {\n        toast.error(\"Please enter a valid Notion link to proceed.\");\n        return;\n      }\n    }\n\n    if (!validateNotionPageId) {\n      toast.error(\"Please enter a valid Notion link to proceed.\");\n      return;\n    }\n\n    try {\n      setUploading(true);\n\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/documents`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: createNotionFileName(),\n            url: notionLink,\n            numPages: 1,\n            type: \"notion\",\n            createLink: false,\n            folderPathName: currentFolderPath?.join(\"/\"),\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        toast.error(error);\n        return;\n      }\n\n      const document = await response.json();\n\n      if (isDataroom && dataroomId) {\n        const dataroomResponse = await addDocumentToDataroom({\n          documentId: document.id,\n          folderPathName: currentFolderPath?.join(\"/\"),\n        });\n\n        if (dataroomResponse?.ok) {\n          const dataroomDocument =\n            (await dataroomResponse.json()) as DataroomDocument & {\n              dataroom: {\n                _count: { viewerGroups: number; permissionGroups: number };\n              };\n            };\n\n          await applyUnifiedPermissionsToDocument(\n            document,\n            dataroomDocument,\n            currentFolderPath,\n          );\n        }\n\n        analytics.capture(\"Document Added\", {\n          documentId: document.id,\n          name: document.name,\n          numPages: document.numPages,\n          path: router.asPath,\n          type: \"notion\",\n          teamId: teamId,\n          dataroomId: dataroomId,\n          $set: {\n            teamId: teamId,\n            teamPlan: plan,\n          },\n        });\n\n        return;\n      }\n\n      if (!newVersion) {\n        toast.success(\"Notion Page processed. Redirecting to document page...\");\n\n        analytics.capture(\"Document Added\", {\n          documentId: document.id,\n          name: document.name,\n          fileSize: null,\n          path: router.asPath,\n          type: \"notion\",\n          teamId: teamId,\n          $set: {\n            teamId: teamId,\n            teamPlan: plan,\n          },\n        });\n\n        // redirect to the document page\n        router.push(\"/documents/\" + document.id);\n      }\n    } catch (error) {\n      setUploading(false);\n      toast.error(\n        \"Oops! Can't access the Notion page. Please double-check it's set to 'Public'.\",\n      );\n      console.error(\n        \"An error occurred while processing the Notion link: \",\n        error,\n      );\n    } finally {\n      setUploading(false);\n      setIsOpen(false);\n    }\n  };\n\n  const handleWebLinkUpload = async (\n    event: FormEvent<HTMLFormElement>,\n  ): Promise<void> => {\n    event.preventDefault();\n\n    // Check if user is on a free plan (not trial)\n    if (isFree && !isTrial) {\n      toast.error(\"Web links are available on Pro plan and above.\");\n      return;\n    }\n\n    if (!canAddDocuments) {\n      toast.error(\n        limits?.documents\n          ? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`\n          : \"You have reached the maximum number of documents.\",\n        {\n          action: {\n            label: \"Upgrade\",\n            onClick: () => router.push(\"/settings/billing\"),\n          },\n          duration: 8000,\n        },\n      );\n      return;\n    }\n\n    // Check if the field is empty or not\n    if (!webLink) {\n      toast.error(\"Please enter a website URL to proceed.\");\n      return;\n    }\n\n    // Validate URL format with Zod\n    const urlSchema = z.string().url();\n    const urlValidation = urlSchema.safeParse(webLink);\n\n    if (!urlValidation.success) {\n      toast.error(\"Please enter a valid URL format.\");\n      return;\n    }\n\n    try {\n      setUploading(true);\n\n      // Extract domain name from URL for the document name\n      let linkName = \"Web Link\";\n      try {\n        const url = new URL(webLink);\n        linkName = url.hostname.replace(\"www.\", \"\");\n      } catch (e) {\n        // Use default name if URL parsing fails\n      }\n\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/documents`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: linkName,\n            url: webLink,\n            numPages: 1,\n            type: \"link\",\n            createLink: false,\n            folderPathName: currentFolderPath?.join(\"/\"),\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        toast.error(error);\n        return;\n      }\n\n      const document = await response.json();\n\n      if (isDataroom && dataroomId) {\n        const dataroomResponse = await addDocumentToDataroom({\n          documentId: document.id,\n          folderPathName: currentFolderPath?.join(\"/\"),\n        });\n\n        if (dataroomResponse?.ok) {\n          const dataroomDocument =\n            (await dataroomResponse.json()) as DataroomDocument & {\n              dataroom: {\n                _count: { viewerGroups: number; permissionGroups: number };\n              };\n            };\n\n          await applyUnifiedPermissionsToDocument(\n            document,\n            dataroomDocument,\n            currentFolderPath,\n          );\n        }\n\n        analytics.capture(\"Document Added\", {\n          documentId: document.id,\n          name: document.name,\n          numPages: document.numPages,\n          path: router.asPath,\n          type: \"link\",\n          teamId: teamId,\n          dataroomId: dataroomId,\n          $set: {\n            teamId: teamId,\n            teamPlan: plan,\n          },\n        });\n\n        return;\n      }\n\n      if (!newVersion) {\n        toast.success(\"Web link added. Redirecting to document page...\");\n\n        analytics.capture(\"Document Added\", {\n          documentId: document.id,\n          name: document.name,\n          fileSize: null,\n          path: router.asPath,\n          type: \"link\",\n          teamId: teamId,\n          $set: {\n            teamId: teamId,\n            teamPlan: plan,\n          },\n        });\n\n        // redirect to the document page\n        router.push(\"/documents/\" + document.id);\n      }\n    } catch (error) {\n      setUploading(false);\n      toast.error(\"An error occurred while processing the web link.\");\n      console.error(\"An error occurred while processing the web link: \", error);\n    } finally {\n      setUploading(false);\n      setIsOpen(false);\n    }\n  };\n\n  const clearModelStates = () => {\n    currentFile !== null && setCurrentFile(null);\n    notionLink !== null && setNotionLink(null);\n    webLink !== null && setWebLink(null);\n    setUploadMode(\"file\");\n    setIsOpen(!isOpen);\n    setAddDocumentModalOpen && setAddDocumentModalOpen(!isOpen);\n  };\n\n  if (!canAddDocuments && children) {\n    if (newVersion) {\n      return (\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.Pro}\n          trigger={\"limit_upload_document_version\"}\n        >\n          {children}\n        </UpgradePlanModal>\n      );\n    }\n    return (\n      <UpgradePlanModal\n        clickedPlan={PlanEnum.Pro}\n        trigger={\"limit_upload_documents\"}\n      >\n        <Button>Upgrade to Add Documents</Button>\n      </UpgradePlanModal>\n    );\n  }\n\n  return (\n    <>\n      <Dialog open={isOpen} onOpenChange={clearModelStates}>\n        <DialogTrigger asChild>{children}</DialogTrigger>\n        <DialogContent\n          className=\"border-none bg-transparent text-foreground shadow-none\"\n          isDocumentDialog\n        >\n          <DialogTitle className=\"sr-only\">Add Document</DialogTitle>\n          <DialogDescription className=\"sr-only\">\n            An overlayed modal that can be clicked to upload a document\n          </DialogDescription>\n          <Tabs defaultValue=\"document\">\n            {!newVersion ? (\n              <TabsList className=\"grid w-full grid-cols-2\">\n                <TabsTrigger value=\"document\">Document</TabsTrigger>\n                <TabsTrigger value=\"notion\">Notion Page</TabsTrigger>\n              </TabsList>\n            ) : (\n              <TabsList className=\"grid w-full grid-cols-1\">\n                <TabsTrigger value=\"document\">Document</TabsTrigger>\n              </TabsList>\n            )}\n            <TabsContent value=\"document\">\n              <Card>\n                <CardHeader className=\"space-y-3\">\n                  <CardTitle>\n                    {newVersion ? `Upload a new version` : `Share a document`}\n                  </CardTitle>\n                  <CardDescription>\n                    {newVersion ? (\n                      `After you upload a new version, the existing links will remain unchanged.`\n                    ) : (\n                      <span>\n                        After you upload the document, create a shareable link.{\" \"}\n                        {isFree && !isTrial ? (\n                          <>\n                            Upload larger files and more{\" \"}\n                            <Link\n                              href=\"https://www.papermark.com/help/article/document-types\"\n                              target=\"_blank\"\n                              className=\"underline underline-offset-4 transition-all hover:text-muted-foreground/80 hover:dark:text-muted-foreground/80\"\n                            >\n                              file types\n                            </Link>{\" \"}\n                            with a higher plan.\n                          </>\n                        ) : null}\n                      </span>\n                    )}\n                  </CardDescription>\n                </CardHeader>\n                <CardContent className=\"space-y-2\">\n                  {uploadMode === \"file\" ? (\n                    <form\n                      encType=\"multipart/form-data\"\n                      onSubmit={handleFileUpload}\n                      className=\"flex flex-col space-y-4\"\n                    >\n                      <div className=\"space-y-1\">\n                        <div className=\"grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6\">\n                          <DocumentUpload\n                            currentFile={currentFile}\n                            setCurrentFile={setCurrentFile}\n                          />\n                        </div>\n                      </div>\n\n                      <div className=\"flex justify-center\">\n                        <Button\n                          type=\"submit\"\n                          className=\"w-full lg:w-1/2\"\n                          disabled={uploading || !currentFile}\n                          loading={uploading}\n                        >\n                          {uploading ? \"Uploading...\" : \"Upload Document\"}\n                        </Button>\n                      </div>\n\n                      {!newVersion && (\n                        <div className=\"flex justify-center\">\n                          <p className=\"text-sm text-muted-foreground\">\n                            Want to{\" \"}\n                            <button\n                              type=\"button\"\n                              className=\"underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80\"\n                              onClick={(e) => {\n                                e.stopPropagation();\n                                document\n                                  .getElementById(\"upload-multi-files-zone\")\n                                  ?.click();\n                                clearModelStates();\n                              }}\n                            >\n                              upload multiple files\n                            </button>{\" \"}\n                            or{\" \"}\n                            {isFree && !isTrial ? (\n                              <UpgradePlanModal\n                                clickedPlan={PlanEnum.Pro}\n                                trigger={\"add_web_link_document\"}\n                              >\n                                <button\n                                  type=\"button\"\n                                  className=\"inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80\"\n                                >\n                                  share link as a document\n                                </button>\n                              </UpgradePlanModal>\n                            ) : (\n                              <button\n                                type=\"button\"\n                                className=\"inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80\"\n                                onClick={(e) => {\n                                  e.preventDefault();\n                                  setUploadMode(\"link\");\n                                }}\n                              >\n                                share link as a document\n                              </button>\n                            )}\n                            ?\n                          </p>\n                        </div>\n                      )}\n                    </form>\n                  ) : (\n                    <form\n                      encType=\"multipart/form-data\"\n                      onSubmit={handleWebLinkUpload}\n                      className=\"flex flex-col space-y-4\"\n                    >\n                      <div className=\"space-y-1\">\n                        <Label htmlFor=\"web-link\">Website URL</Label>\n                        <div className=\"mt-2\">\n                          <input\n                            type=\"text\"\n                            name=\"web-link\"\n                            id=\"web-link\"\n                            placeholder=\"https://example.com\"\n                            className=\"flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6\"\n                            value={webLink || \"\"}\n                            onChange={(e) => setWebLink(e.target.value)}\n                          />\n                        </div>\n                        {/* <small className=\"text-xs text-muted-foreground\">\n                          The page will be captured and converted to a document format.\n                        </small> */}\n                      </div>\n\n                      <div className=\"flex justify-center\">\n                        <Button\n                          type=\"submit\"\n                          className=\"w-full lg:w-1/2\"\n                          disabled={uploading || !webLink}\n                          loading={uploading}\n                        >\n                          {uploading ? \"Saving...\" : \"Save Web Link\"}\n                        </Button>\n                      </div>\n\n                      <div className=\"flex justify-center\">\n                        <button\n                          type=\"button\"\n                          className=\"flex items-center gap-2 text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80\"\n                          onClick={(e) => {\n                            e.preventDefault();\n                            setUploadMode(\"file\");\n                          }}\n                        >\n                          <svg\n                            className=\"h-4 w-4\"\n                            fill=\"none\"\n                            stroke=\"currentColor\"\n                            viewBox=\"0 0 24 24\"\n                          >\n                            <path\n                              strokeLinecap=\"round\"\n                              strokeLinejoin=\"round\"\n                              strokeWidth={2}\n                              d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"\n                            />\n                          </svg>\n                          Back to file upload\n                        </button>\n                      </div>\n                    </form>\n                  )}\n                </CardContent>\n              </Card>\n            </TabsContent>\n            {!newVersion && (\n              <TabsContent value=\"notion\">\n                <Card>\n                  <CardHeader className=\"space-y-3\">\n                    <CardTitle>Share a Notion Page</CardTitle>\n                    <CardDescription>\n                      After you submit the Notion link, a shareable link will be\n                      generated and copied to your clipboard. Just like with a\n                      PDF document.\n                    </CardDescription>\n                  </CardHeader>\n                  <CardContent className=\"space-y-2\">\n                    <form\n                      encType=\"multipart/form-data\"\n                      onSubmit={handleNotionUpload}\n                      className=\"flex flex-col\"\n                    >\n                      <div className=\"space-y-1 pb-8\">\n                        <Label htmlFor=\"notion-link\">Notion Page Link</Label>\n                        <div className=\"mt-2\">\n                          <input\n                            type=\"text\"\n                            name=\"notion-link\"\n                            id=\"notion-link\"\n                            placeholder=\"notion.site/...\"\n                            className=\"flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6\"\n                            value={notionLink || \"\"}\n                            onChange={(e) => setNotionLink(e.target.value)}\n                          />\n                        </div>\n                        <small className=\"text-xs text-muted-foreground\">\n                          Your Notion page needs to be shared publicly.\n                        </small>\n                      </div>\n                      <div className=\"flex justify-center\">\n                        <Button\n                          type=\"submit\"\n                          className=\"w-full lg:w-1/2\"\n                          disabled={uploading || !notionLink}\n                          loading={uploading}\n                        >\n                          {uploading ? \"Saving...\" : \"Save Notion Link\"}\n                        </Button>\n                      </div>\n                    </form>\n                  </CardContent>\n                </Card>\n              </TabsContent>\n            )}\n          </Tabs>\n        </DialogContent>\n      </Dialog>\n\n      {showGroupPermissions && dataroomId && (\n        <SetUnifiedPermissionsModal\n          open={showGroupPermissions}\n          setOpen={setShowGroupPermissions}\n          dataroomId={dataroomId}\n          uploadedFiles={uploadedFiles}\n          onComplete={() => {\n            setShowGroupPermissions(false);\n            setAddDocumentModalOpen?.(false);\n            setUploadedFiles([]);\n          }}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/add-document-to-dataroom-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nimport useDataroomsSimple from \"@/lib/swr/use-datarooms-simple\";\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"../ui/select\";\n\nexport type TSelectedDataroom = { id: string; name: string } | null;\n\nexport function AddToDataroomModal({\n  open,\n  setOpen,\n  documentId,\n  documentName,\n  dataroomId,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  documentId?: string;\n  documentName?: string;\n  dataroomId?: string;\n}) {\n  const [selectedDataroom, setSelectedDataroom] = useState<string | null>(null);\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { datarooms } = useDataroomsSimple();\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!selectedDataroom) return;\n\n    setLoading(true);\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/documents/${documentId}/add-to-dataroom`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            dataroomId: selectedDataroom,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message);\n        return;\n      }\n\n      toast.success(\"Document added to dataroom successfully!\");\n    } catch (error) {\n      console.error(\"Error adding document to dataroom\", error);\n      toast.error(\"Failed to add document to dataroom. Try again.\");\n    } finally {\n      setLoading(false);\n      setOpen(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"max-w-[90vw] sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>\n            <span className=\"font-bold\">{documentName}</span>\n          </DialogTitle>\n          <DialogDescription>\n            Add your document to a dataroom.\n          </DialogDescription>\n        </DialogHeader>\n        <Select onValueChange={(value) => setSelectedDataroom(value)}>\n          <SelectTrigger className=\"w-[380px] max-w-full [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:truncate [&>span]:text-ellipsis [&>span]:whitespace-nowrap\">\n            <SelectValue placeholder=\"Select a dataroom\" />\n          </SelectTrigger>\n          <SelectContent className=\"w-[380px] max-w-[90vw]\">\n            {datarooms?.map((dataroom) => (\n              <SelectItem\n                key={dataroom.id}\n                value={dataroom.id}\n                disabled={dataroom.id === dataroomId}\n                className=\"break-words\"\n              >\n                <span className=\"line-clamp-1 break-words\">\n                  {dataroom.name}\n                  {dataroom.id === dataroomId ? \" (current)\" : \"\"}\n                </span>\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <form>\n          <div className=\"mb-2\"></div>\n\n          <DialogFooter>\n            <Button\n              onClick={handleSubmit}\n              className=\"flex h-9 w-full gap-1\"\n              loading={loading}\n              disabled={!selectedDataroom}\n            >\n              {!selectedDataroom ? (\n                \"Select a dataroom\"\n              ) : (\n                <span className=\"flex w-full max-w-[350px] items-center justify-center truncate\">\n                  Add to\n                  <span className=\"ml-1 line-clamp-1 truncate font-medium\">\n                    {\n                      datarooms?.filter((d) => d.id === selectedDataroom)[0]\n                        .name\n                    }\n                  </span>\n                </span>\n              )}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/documents/add-folder-to-dataroom-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nimport useDataroomsSimple from \"@/lib/swr/use-datarooms-simple\";\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"../ui/select\";\n\nexport type TSelectedDataroom = { id: string; name: string } | null;\n\nexport function AddFolderToDataroomModal({\n  open,\n  setOpen,\n  folderId,\n  folderName,\n  dataroomId,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  folderId?: string;\n  folderName?: string;\n  dataroomId?: string;\n}) {\n  const router = useRouter();\n  const [selectedDataroom, setSelectedDataroom] = useState<string | null>(null);\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { datarooms } = useDataroomsSimple();\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!selectedDataroom) return;\n\n    setLoading(true);\n    try {\n      const response = await fetch(\n        !!dataroomId\n          ? `/api/teams/${teamId}/datarooms/${dataroomId}/folders/manage/${folderId}/dataroom-to-dataroom`\n          : `/api/teams/${teamId}/folders/manage/${folderId}/add-to-dataroom`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            dataroomId: selectedDataroom,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message);\n        return;\n      }\n      dataroomId &&\n        mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/folders`);\n\n      const dataroomName = datarooms?.find(\n        (d) => d.id === selectedDataroom,\n      )?.name;\n\n      toast.success(`Folder added successfully!`, {\n        description: `${folderName?.trim()} → ${dataroomName}`,\n        action: {\n          label: \"Open Dataroom\",\n          onClick: () =>\n            router.push(`/datarooms/${selectedDataroom}/documents`),\n        },\n        duration: 10000,\n      });\n    } catch (error) {\n      console.error(\"Error adding folder to dataroom\", error);\n      toast.error(\"Failed to add folder to dataroom. Try again.\");\n    } finally {\n      setLoading(false);\n      setOpen(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>\n            <span className=\"font-bold\">{folderName}</span>\n          </DialogTitle>\n          <DialogDescription>Add your folder to a dataroom.</DialogDescription>\n        </DialogHeader>\n        <Select onValueChange={(value) => setSelectedDataroom(value)}>\n          <SelectTrigger className=\"w-[380px] max-w-full [&>span]:truncate [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap\">\n            <SelectValue placeholder=\"Select a dataroom\" />\n          </SelectTrigger>\n          <SelectContent className=\"w-[380px] max-w-[90vw]\">\n            {datarooms?.map((dataroom) => (\n              <SelectItem\n                key={dataroom.id}\n                value={dataroom.id}\n                disabled={dataroom.id === dataroomId}\n                className=\"break-words\"\n              >\n                <span className=\"break-words line-clamp-1\">\n                  {dataroom.name}\n                  {dataroom.id === dataroomId ? \" (current)\" : \"\"}\n                </span>\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n        <form>\n          <div className=\"mb-2\"></div>\n\n          <DialogFooter>\n            <Button\n              onClick={handleSubmit}\n              className=\"flex h-9 w-full gap-1\"\n              loading={loading}\n              disabled={!selectedDataroom}\n            >\n              {!selectedDataroom ? (\n                \"Select a dataroom\"\n              ) : (\n                <span className=\"flex items-center justify-center w-full max-w-[350px] truncate\">\n                  Add to\n                  <span className=\"font-medium truncate line-clamp-1 ml-1\">\n                    {\n                      datarooms?.filter((d) => d.id === selectedDataroom)[0]\n                        .name\n                    }\n                  </span>\n                </span>\n              )}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/documents/alert.tsx",
    "content": "import { AlertCircleIcon } from \"lucide-react\";\n\nimport {\n  Alert,\n  AlertClose,\n  AlertDescription,\n  AlertTitle,\n} from \"@/components/ui/alert\";\n\ninterface AlertProps {\n  id: string;\n  variant: \"default\" | \"destructive\";\n  title: string;\n  description: React.ReactNode;\n  onClose?: () => void;\n}\n\nconst AlertBanner: React.FC<AlertProps> = ({\n  id,\n  variant,\n  title,\n  description,\n  onClose,\n}) => {\n  return (\n    <Alert id={id} variant={variant}>\n      <AlertCircleIcon className=\"h-4 w-4\" />\n      <AlertTitle>{title}</AlertTitle>\n      <AlertDescription>{description}</AlertDescription>\n      <AlertClose onClick={onClose} />\n    </Alert>\n  );\n};\n\nexport default AlertBanner;\n"
  },
  {
    "path": "components/documents/annotations/annotation-form.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \"react-hook-form\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\n\nimport { uploadImage } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n  Form,\n  FormControl,\n  FormDescription,\n  FormField,\n  FormItem,\n  FormLabel,\n  FormMessage,\n} from \"@/components/ui/form-hook\";\nimport { Input } from \"@/components/ui/input\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport { RichTextEditor } from \"@/components/ui/rich-text-editor\";\nimport { Switch } from \"@/components/ui/switch\";\n\nconst formSchema = z.object({\n  title: z\n    .string()\n    .min(1, \"Title is required\")\n    .max(100, \"Title must be less than 100 characters\"),\n  content: z.any().optional(),\n  pages: z.array(z.number()).min(1, \"At least one page must be selected\"),\n  isVisible: z.boolean(),\n});\n\ntype FormValues = z.infer<typeof formSchema>;\n\ninterface AnnotationFormProps {\n  documentId: string;\n  teamId: string;\n  numPages: number;\n  annotation?: any;\n  onSuccess: () => void;\n}\n\nexport function AnnotationForm({\n  documentId,\n  teamId,\n  numPages,\n  annotation,\n  onSuccess,\n}: AnnotationFormProps) {\n  const [isLoading, setIsLoading] = useState(false);\n  const [editorContent, setEditorContent] = useState(\n    annotation?.content || { type: \"doc\", content: [] },\n  );\n\n  const form = useForm<FormValues>({\n    resolver: zodResolver(formSchema),\n    defaultValues: {\n      title: annotation?.title || \"\",\n      content: annotation?.content || null,\n      pages: annotation?.pages || [],\n      isVisible:\n        annotation?.isVisible !== undefined ? annotation.isVisible : true,\n    },\n  });\n\n  const pageOptions = Array.from({ length: numPages }, (_, i) => i + 1);\n\n  const handleImageUpload = async (file: File): Promise<string> => {\n    try {\n      // Upload the image using the existing uploadImage utility\n      const imageUrl = await uploadImage(file, \"assets\");\n\n      // Don't save to database here - let the form submission handle it\n      // Images will be embedded in the rich text content and parsed when saving\n\n      return imageUrl;\n    } catch (error) {\n      console.error(\"Failed to upload image:\", error);\n      throw new Error(\"Failed to upload image\");\n    }\n  };\n\n  const onSubmit = async (values: FormValues) => {\n    setIsLoading(true);\n    try {\n      const url = annotation\n        ? `/api/teams/${teamId}/documents/${documentId}/annotations/${annotation.id}`\n        : `/api/teams/${teamId}/documents/${documentId}/annotations`;\n\n      const method = annotation ? \"PUT\" : \"POST\";\n\n      const response = await fetch(url, {\n        method,\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          ...values,\n          content: editorContent,\n        }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        throw new Error(errorData.error || \"Failed to save annotation\");\n      }\n\n      toast.success(\n        annotation\n          ? \"Annotation updated successfully\"\n          : \"Annotation created successfully\",\n      );\n      onSuccess();\n    } catch (error) {\n      console.error(\"Error saving annotation:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to save annotation\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <Form {...form}>\n      <form onSubmit={form.handleSubmit(onSubmit)} className=\"space-y-6\">\n        <FormField\n          control={form.control}\n          name=\"title\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>Title</FormLabel>\n              <FormControl>\n                <Input placeholder=\"Enter annotation title\" {...field} />\n              </FormControl>\n              <FormDescription>\n                A brief title for this annotation that viewers will see.\n              </FormDescription>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <FormField\n          control={form.control}\n          name=\"pages\"\n          render={({ field }) => (\n            <FormItem>\n              <FormLabel>Pages</FormLabel>\n              <FormDescription>\n                Select which pages this annotation should appear on.\n              </FormDescription>\n              <div className=\"mt-2 grid grid-cols-10 gap-2\">\n                {pageOptions.map((page) => (\n                  <div key={page} className=\"flex items-center space-x-2\">\n                    <Checkbox\n                      id={`page-${page}`}\n                      checked={field.value.includes(page)}\n                      onCheckedChange={(checked) => {\n                        if (checked) {\n                          field.onChange([...field.value, page]);\n                        } else {\n                          field.onChange(field.value.filter((p) => p !== page));\n                        }\n                      }}\n                    />\n                    <label\n                      htmlFor={`page-${page}`}\n                      className=\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n                    >\n                      {page}\n                    </label>\n                  </div>\n                ))}\n              </div>\n              <FormMessage />\n            </FormItem>\n          )}\n        />\n\n        <div className=\"space-y-2\">\n          <FormLabel>Content</FormLabel>\n          <FormDescription>\n            Add rich text content and images to help explain this part of the\n            document.\n          </FormDescription>\n          <RichTextEditor\n            content={editorContent}\n            onChange={setEditorContent}\n            placeholder=\"Add your annotation content here...\"\n            onImageUpload={handleImageUpload}\n          />\n        </div>\n\n        <FormField\n          control={form.control}\n          name=\"isVisible\"\n          render={({ field }) => (\n            <FormItem className=\"flex flex-row items-center justify-between rounded-lg border p-4\">\n              <div className=\"space-y-0.5\">\n                <FormLabel className=\"text-base\">Visible to viewers</FormLabel>\n                <FormDescription>\n                  When enabled, viewers will be able to see this annotation when\n                  viewing the document.\n                </FormDescription>\n              </div>\n              <FormControl>\n                <Switch\n                  checked={field.value}\n                  onCheckedChange={field.onChange}\n                />\n              </FormControl>\n            </FormItem>\n          )}\n        />\n\n        <div className=\"flex justify-end space-x-2\">\n          <Button type=\"submit\" disabled={isLoading}>\n            {isLoading && <LoadingSpinner className=\"mr-2 h-4 w-4\" />}\n            {annotation ? \"Update Annotation\" : \"Create Annotation\"}\n          </Button>\n        </div>\n      </form>\n    </Form>\n  );\n}\n"
  },
  {
    "path": "components/documents/annotations/annotation-sheet.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { Edit2, Eye, EyeOff, Plus, Trash2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useAnnotations } from \"@/lib/swr/use-annotations\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n  SheetTrigger,\n} from \"@/components/ui/sheet\";\n\nimport { AnnotationForm } from \"./annotation-form\";\n\ninterface AnnotationSheetProps {\n  documentId: string;\n  teamId: string;\n  numPages?: number;\n  trigger?: React.ReactNode;\n}\n\nexport function AnnotationSheet({\n  documentId,\n  teamId,\n  numPages = 1,\n  trigger,\n}: AnnotationSheetProps) {\n  const { annotations, mutate } = useAnnotations(documentId, teamId);\n  const [isSheetOpen, setIsSheetOpen] = useState(false);\n  const [isCreateOpen, setIsCreateOpen] = useState(false);\n  const [editingAnnotation, setEditingAnnotation] = useState<any>(null);\n\n  const handleDelete = async (annotationId: string) => {\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,\n        {\n          method: \"DELETE\",\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to delete annotation\");\n      }\n\n      toast.success(\"Annotation deleted successfully\");\n      mutate();\n    } catch (error) {\n      console.error(\"Error deleting annotation:\", error);\n      toast.error(\"Failed to delete annotation\");\n    }\n  };\n\n  const handleToggleVisibility = async (\n    annotationId: string,\n    isVisible: boolean,\n  ) => {\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,\n        {\n          method: \"PUT\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            isVisible: !isVisible,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to update annotation\");\n      }\n\n      toast.success(\n        `Annotation ${!isVisible ? \"shown\" : \"hidden\"} successfully`,\n      );\n      mutate();\n    } catch (error) {\n      console.error(\"Error updating annotation:\", error);\n      toast.error(\"Failed to update annotation\");\n    }\n  };\n\n  const handleFormSuccess = () => {\n    setIsCreateOpen(false);\n    setEditingAnnotation(null);\n    mutate();\n  };\n\n  return (\n    <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>\n      <SheetTrigger asChild>\n        {trigger || (\n          <Button variant=\"outline\" size=\"sm\">\n            Add Annotations\n          </Button>\n        )}\n      </SheetTrigger>\n      <SheetContent className=\"w-[600px] sm:max-w-[600px]\">\n        <SheetHeader>\n          <SheetTitle>Document Annotations</SheetTitle>\n          <SheetDescription>\n            Manage annotations that viewers can see when viewing this document.\n          </SheetDescription>\n        </SheetHeader>\n\n        <div className=\"mt-6 space-y-4\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"text-sm text-muted-foreground\">\n              {annotations?.length || 0} annotation\n              {annotations?.length !== 1 ? \"s\" : \"\"}\n            </div>\n            <Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>\n              <DialogTrigger asChild>\n                <Button>\n                  <Plus className=\"mr-2 h-4 w-4\" />\n                  Add Annotation\n                </Button>\n              </DialogTrigger>\n              <DialogContent className=\"max-h-[90vh] max-w-4xl overflow-y-auto\">\n                <DialogHeader>\n                  <DialogTitle>Create Annotation</DialogTitle>\n                  <DialogDescription>\n                    Add a new annotation that viewers can see when viewing this\n                    document.\n                  </DialogDescription>\n                </DialogHeader>\n                <AnnotationForm\n                  documentId={documentId}\n                  teamId={teamId}\n                  numPages={numPages}\n                  onSuccess={handleFormSuccess}\n                />\n              </DialogContent>\n            </Dialog>\n          </div>\n\n          <ScrollArea className=\"h-[calc(100vh-200px)]\">\n            {annotations && annotations.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center py-12 text-center\">\n                <div className=\"mb-4 rounded-full bg-muted p-3\">\n                  <Plus className=\"h-6 w-6 text-muted-foreground\" />\n                </div>\n                <h3 className=\"mb-2 text-lg font-medium\">No annotations yet</h3>\n                <p className=\"mb-4 max-w-sm text-sm text-muted-foreground\">\n                  Create your first annotation to help viewers understand your\n                  document better.\n                </p>\n                <Button onClick={() => setIsCreateOpen(true)}>\n                  <Plus className=\"mr-2 h-4 w-4\" />\n                  Create First Annotation\n                </Button>\n              </div>\n            ) : (\n              <div className=\"space-y-3\">\n                {annotations?.map((annotation) => (\n                  <div\n                    key={annotation.id}\n                    className=\"space-y-3 rounded-lg border p-4\"\n                  >\n                    <div className=\"flex items-start justify-between\">\n                      <div className=\"flex-1 space-y-1\">\n                        <div className=\"flex items-center gap-2\">\n                          <h4 className=\"font-medium\">{annotation.title}</h4>\n                          {!annotation.isVisible && (\n                            <Badge variant=\"secondary\">Hidden</Badge>\n                          )}\n                        </div>\n                        <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                          <span>Pages: {annotation.pages.join(\", \")}</span>\n                          <span>•</span>\n                          <span>\n                            Created by{\" \"}\n                            {annotation.createdBy?.name ||\n                              annotation.createdBy?.email}\n                          </span>\n                          <span>•</span>\n                          <span>\n                            {new Date(\n                              annotation.createdAt,\n                            ).toLocaleDateString()}\n                          </span>\n                        </div>\n                      </div>\n                      <div className=\"flex items-center gap-1\">\n                        <Button\n                          variant=\"ghost\"\n                          size=\"sm\"\n                          onClick={() =>\n                            handleToggleVisibility(\n                              annotation.id,\n                              annotation.isVisible,\n                            )\n                          }\n                        >\n                          {annotation.isVisible ? (\n                            <Eye className=\"h-4 w-4\" />\n                          ) : (\n                            <EyeOff className=\"h-4 w-4\" />\n                          )}\n                        </Button>\n                        <Dialog\n                          open={editingAnnotation?.id === annotation.id}\n                          onOpenChange={(open) =>\n                            setEditingAnnotation(open ? annotation : null)\n                          }\n                        >\n                          <DialogTrigger asChild>\n                            <Button variant=\"ghost\" size=\"sm\">\n                              <Edit2 className=\"h-4 w-4\" />\n                            </Button>\n                          </DialogTrigger>\n                          <DialogContent className=\"max-h-[90vh] max-w-4xl overflow-y-auto\">\n                            <DialogHeader>\n                              <DialogTitle>Edit Annotation</DialogTitle>\n                              <DialogDescription>\n                                Update this annotation.\n                              </DialogDescription>\n                            </DialogHeader>\n                            <AnnotationForm\n                              documentId={documentId}\n                              teamId={teamId}\n                              numPages={numPages}\n                              annotation={annotation}\n                              onSuccess={handleFormSuccess}\n                            />\n                          </DialogContent>\n                        </Dialog>\n                        <AlertDialog>\n                          <AlertDialogTrigger asChild>\n                            <Button variant=\"ghost\" size=\"sm\">\n                              <Trash2 className=\"h-4 w-4\" />\n                            </Button>\n                          </AlertDialogTrigger>\n                          <AlertDialogContent>\n                            <AlertDialogHeader>\n                              <AlertDialogTitle>\n                                Delete Annotation\n                              </AlertDialogTitle>\n                              <AlertDialogDescription>\n                                Are you sure you want to delete this annotation?\n                                This action cannot be undone.\n                              </AlertDialogDescription>\n                            </AlertDialogHeader>\n                            <AlertDialogFooter>\n                              <AlertDialogCancel>Cancel</AlertDialogCancel>\n                              <AlertDialogAction\n                                onClick={() => handleDelete(annotation.id)}\n                                className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n                              >\n                                Delete\n                              </AlertDialogAction>\n                            </AlertDialogFooter>\n                          </AlertDialogContent>\n                        </AlertDialog>\n                      </div>\n                    </div>\n\n                    {/* Preview of annotation content */}\n                    {annotation.content && (\n                      <div className=\"rounded bg-muted/50 p-3 text-sm text-muted-foreground\">\n                        <div className=\"line-clamp-3\">\n                          {/* Simple text preview */}\n                          {typeof annotation.content === \"object\" &&\n                          annotation.content.content\n                            ? annotation.content.content\n                                .filter(\n                                  (node: any) => node.type === \"paragraph\",\n                                )\n                                .map((node: any) =>\n                                  node.content\n                                    ?.filter(\n                                      (textNode: any) =>\n                                        textNode.type === \"text\",\n                                    )\n                                    .map((textNode: any) => textNode.text)\n                                    .join(\" \"),\n                                )\n                                .join(\" \")\n                            : \"No content\"}\n                        </div>\n                        {annotation.images && annotation.images.length > 0 && (\n                          <div className=\"mt-2 flex gap-1\">\n                            {annotation.images.slice(0, 3).map((image) => (\n                              <img\n                                key={image.id}\n                                src={image.url}\n                                alt={image.filename}\n                                className=\"h-8 w-8 rounded border object-cover\"\n                              />\n                            ))}\n                            {annotation.images.length > 3 && (\n                              <div className=\"flex h-8 w-8 items-center justify-center rounded border bg-muted text-xs\">\n                                +{annotation.images.length - 3}\n                              </div>\n                            )}\n                          </div>\n                        )}\n                      </div>\n                    )}\n                  </div>\n                ))}\n              </div>\n            )}\n          </ScrollArea>\n        </div>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "components/documents/breadcrumb.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport React, { useMemo } from \"react\";\n\nimport { useFolderWithParents } from \"@/lib/swr/use-folders\";\n\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"../ui/breadcrumb\";\n\nfunction BreadcrumbComponentBase({ name }: { name: string[] }) {\n  const { folders: folderNames } = useFolderWithParents({ name });\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem key={\"root\"}>\n          <BreadcrumbLink asChild>\n            <Link href=\"/documents\">Documents</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        {folderNames &&\n          folderNames.map((item, index: number, array) => {\n            return (\n              <React.Fragment key={index}>\n                <BreadcrumbSeparator />\n                {index === array.length - 1 ? (\n                  <BreadcrumbItem>\n                    <BreadcrumbPage className=\"capitalize\">\n                      {item.name}\n                    </BreadcrumbPage>\n                  </BreadcrumbItem>\n                ) : (\n                  <BreadcrumbItem>\n                    <BreadcrumbLink asChild>\n                      <Link\n                        href={`/documents/tree${item.path}`}\n                        className=\"capitalize\"\n                      >\n                        {item.name}\n                      </Link>\n                    </BreadcrumbLink>\n                  </BreadcrumbItem>\n                )}\n              </React.Fragment>\n            );\n          })}\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n}\n\nconst BreadcrumbComponent = () => {\n  const router = useRouter();\n  const name = router.query.name as string[];\n\n  // Use useMemo to memoize the base component with the current name value.\n  // This way, BreadcrumbComponentBase is only re-rendered when name changes.\n  const MemoizedBreadcrumbComponent = useMemo(() => {\n    return <BreadcrumbComponentBase name={name} />;\n  }, [name]);\n\n  return MemoizedBreadcrumbComponent;\n};\n\nexport { BreadcrumbComponent };\n"
  },
  {
    "path": "components/documents/delete-folder-modal.tsx",
    "content": "import { useState } from \"react\";\n\nimport { FileIcon, Folder } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Modal } from \"@/components/ui/modal\";\nimport { DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\n\n\nexport type TSelectedDataroom = { id: string; name: string } | null;\n\nexport function DeleteFolderModal({\n  open,\n  setOpen,\n  folderName,\n  folderId,\n  documents,\n  childFolders,\n  isDataroom,\n  handleButtonClick,\n}: {\n  open: boolean;\n  folderId: string;\n  folderName?: string;\n  documents: number;\n  childFolders: number;\n  isDataroom?: boolean;\n  handleButtonClick?: (e: React.FormEvent, folderId: string) => void;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n}) {\n  const [inputValue, setInputValue] = useState(\"\");\n  const requiredText = `confirm ${isDataroom ? \"remove\" : \"delete\"} folder`;\n  const isInputValid = inputValue === requiredText;\n\n  return (\n    <Modal showModal={open} setShowModal={setOpen} noBackdropBlur>\n      <div\n        className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\"\n        onDragStart={(e) => e.preventDefault()}\n      >\n        <DialogTitle>\n          {isDataroom ? \"Remove Folder\" : \"Delete Folder\"}\n        </DialogTitle>\n        <DialogDescription>\n          {isDataroom\n            ? \"This will remove the folder and its contents from this dataroom. The original documents will remain in your workspace.\"\n            : \"This will permanently delete the folder and all its contents, including subfolders, documents, dataroom references, and any visitor analytics.\"}\n          <div className=\"mt-3 text-sm font-medium text-foreground\">\n            {folderName}\n          </div>\n          <div className=\"mt-3 flex items-center gap-5\">\n            <span className=\"flex items-center gap-1 text-xs font-medium text-destructive\">\n              <FileIcon size={15} /> {documents}{\" \"}\n              {documents > 1 ? \"documents\" : \"document\"}\n            </span>\n            <span className=\"flex items-center gap-1 text-xs font-medium text-destructive\">\n              <Folder size={15} /> {childFolders}{\" \"}\n              {childFolders > 1 ? \"folders\" : \"folder\"}\n            </span>\n          </div>\n        </DialogDescription>\n      </div>\n\n      <form\n        onSubmit={async (e: React.FormEvent) => {\n          e.preventDefault();\n          handleButtonClick?.(e, folderId);\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-muted-foreground\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold text-foreground\">\n              {requiredText}\n            </span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              value={inputValue}\n              onChange={(e) => setInputValue(e.target.value)}\n              pattern={requiredText}\n              required\n              autoComplete=\"off\"\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n            />\n          </div>\n        </div>\n        <Button variant=\"destructive\" type=\"submit\" disabled={!isInputValid}>\n          Confirm {isDataroom ? \"remove\" : \"delete\"} folder\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "components/documents/document-card.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport {\n  BetweenHorizontalStartIcon,\n  ChevronRight,\n  EyeIcon,\n  EyeOffIcon,\n  FileIcon,\n  FolderIcon,\n  FolderInputIcon,\n  MoreVertical,\n  ServerIcon,\n  TrashIcon,\n} from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport useDataroomsSimple from \"@/lib/swr/use-datarooms-simple\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { DocumentWithLinksAndLinkCountAndViewCount } from \"@/lib/types\";\nimport { cn, getBreadcrumbPath, nFormatter, timeAgo } from \"@/lib/utils\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\nimport { useCopyToClipboard } from \"@/lib/utils/use-copy-to-clipboard\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { DataroomTrialModal } from \"@/components/datarooms/dataroom-trial-modal\";\nimport { AddToDataroomModal } from \"@/components/documents/add-document-to-dataroom-modal\";\nimport { DocumentPreviewModal } from \"@/components/documents/document-preview-modal\";\nimport { MoveToFolderModal } from \"@/components/documents/move-folder-modal\";\nimport BarChart from \"@/components/shared/icons/bar-chart\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\ntype DocumentsCardProps = {\n  document: DocumentWithLinksAndLinkCountAndViewCount;\n  teamInfo: TeamContextType | null;\n  isDragging?: boolean;\n  isSelected?: boolean;\n  isHovered?: boolean;\n};\nexport default function DocumentsCard({\n  document: prismaDocument,\n  teamInfo,\n  isDragging,\n  isSelected,\n  isHovered,\n}: DocumentsCardProps) {\n  const router = useRouter();\n  const queryParams = router.query;\n  const searchQuery = queryParams[\"search\"];\n  const sortQuery = queryParams[\"sort\"];\n  const { theme, systemTheme } = useTheme();\n  const isLight =\n    theme === \"light\" || (theme === \"system\" && systemTheme === \"light\");\n\n  const { isCopied, copyToClipboard } = useCopyToClipboard({});\n  const [isFirstClick, setIsFirstClick] = useState<boolean>(false);\n  const [menuOpen, setMenuOpen] = useState<boolean>(false);\n  const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);\n  const [addDataroomOpen, setAddDataroomOpen] = useState<boolean>(false);\n  const [trialModalOpen, setTrialModalOpen] = useState<boolean>(false);\n  const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);\n  const [previewOpen, setPreviewOpen] = useState<boolean>(false);\n\n  const { datarooms } = useDataroomsSimple();\n\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n  const { canAddDocuments } = useLimits();\n\n  /** current folder name */\n  const currentFolderPath = router.query.name as string[] | undefined;\n\n  function handleCopyToClipboard(id: string) {\n    copyToClipboard(\n      `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${id}`,\n      \"Link copied to clipboard.\",\n    );\n  }\n\n  // https://github.com/radix-ui/primitives/issues/1241#issuecomment-1888232392\n  useEffect(() => {\n    if (!moveFolderOpen || !addDataroomOpen) {\n      setTimeout(() => {\n        document.body.style.pointerEvents = \"\";\n      });\n    }\n  }, [moveFolderOpen, addDataroomOpen]);\n\n  useEffect(() => {\n    function handleClickOutside(event: { target: any }) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {\n        setMenuOpen(false);\n        setIsFirstClick(false);\n      }\n    }\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, []);\n\n  const handleButtonClick = (event: any, documentId: string) => {\n    event.stopPropagation();\n    event.preventDefault();\n\n    if (isFirstClick) {\n      handleDeleteDocument(documentId);\n      setIsFirstClick(false);\n      setMenuOpen(false); // Close the dropdown after deleting\n    } else {\n      setIsFirstClick(true);\n    }\n  };\n\n  const handleDeleteDocument = async (documentId: string) => {\n    // Prevent the first click from deleting the document\n    if (!isFirstClick) {\n      setIsFirstClick(true);\n      return;\n    }\n\n    const page = Number(queryParams[\"page\"]) || 1;\n    const pageSize = Number(queryParams[\"limit\"]) || 10;\n\n    const queryParts = [];\n    if (searchQuery) queryParts.push(`query=${searchQuery}`);\n    if (sortQuery) queryParts.push(`sort=${sortQuery}`);\n\n    const paginationParams =\n      searchQuery || sortQuery ? `&page=${page}&limit=${pageSize}` : \"\";\n    if (paginationParams) queryParts.push(paginationParams.substring(1));\n    const queryString = queryParts.length > 0 ? `?${queryParts.join(\"&\")}` : \"\";\n\n    const endpoint = currentFolderPath\n      ? `/folders/documents/${currentFolderPath.join(\"/\")}`\n      : `/documents${queryString}`;\n\n    toast.promise(\n      fetch(`/api/teams/${teamInfo?.currentTeam?.id}/documents/${documentId}`, {\n        method: \"DELETE\",\n      }).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to delete document\");\n        }\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}${endpoint}`,\n          (currentData: any) => {\n            if (!currentData) return currentData;\n\n            if (Array.isArray(currentData)) {\n              return currentData.filter(\n                (doc: DocumentWithLinksAndLinkCountAndViewCount) =>\n                  doc.id !== documentId,\n              );\n            } else if (currentData.documents) {\n              return {\n                ...currentData,\n                documents: currentData.documents.filter(\n                  (doc: DocumentWithLinksAndLinkCountAndViewCount) =>\n                    doc.id !== documentId,\n                ),\n              };\n            }\n            return currentData;\n          },\n          {\n            revalidate: false,\n          },\n        );\n      }),\n      {\n        loading: \"Deleting document...\",\n        success: \"Document deleted successfully.\",\n        error: (err) => err.message || \"Failed to delete document. Try again.\",\n      },\n    );\n  };\n\n  const handleMenuStateChange = (open: boolean) => {\n    // If the document is selected, don't open the dropdown\n    if (isSelected) return;\n\n    if (isFirstClick) {\n      setMenuOpen(true); // Keep the dropdown open on the first click\n      return;\n    }\n\n    // If the menu is closed, reset the isFirstClick state\n    if (!open) {\n      setIsFirstClick(false);\n      setMenuOpen(false); // Ensure the dropdown is closed\n    } else {\n      setMenuOpen(true); // Open the dropdown\n    }\n  };\n\n  const handleDuplicateDocument = async (event: any) => {\n    event.stopPropagation();\n    event.preventDefault();\n\n    toast.promise(\n      fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/documents/${prismaDocument.id}/duplicate`,\n        { method: \"POST\" },\n      ).then(() => {\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`);\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/folders/documents/${currentFolderPath?.join(\"/\")}`,\n        );\n      }),\n      {\n        loading: \"Duplicating document...\",\n        success: \"Document duplicated successfully.\",\n        error: \"Failed to duplicate document. Try again.\",\n      },\n    );\n  };\n\n  const handleHideDocument = async (event: any) => {\n    event.stopPropagation();\n    event.preventDefault();\n\n    const page = Number(queryParams[\"page\"]) || 1;\n    const pageSize = Number(queryParams[\"limit\"]) || 10;\n\n    const queryParts = [];\n    if (searchQuery) queryParts.push(`query=${searchQuery}`);\n    if (sortQuery) queryParts.push(`sort=${sortQuery}`);\n\n    const paginationParams =\n      searchQuery || sortQuery ? `&page=${page}&limit=${pageSize}` : \"\";\n    if (paginationParams) queryParts.push(paginationParams.substring(1));\n    const queryString = queryParts.length > 0 ? `?${queryParts.join(\"&\")}` : \"\";\n\n    const endpoint = currentFolderPath\n      ? `/folders/documents/${currentFolderPath.join(\"/\")}`\n      : `/documents${queryString}`;\n\n    toast.promise(\n      fetch(`/api/teams/${teamInfo?.currentTeam?.id}/documents/hide`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          documentIds: [prismaDocument.id],\n          hidden: true,\n        }),\n      }).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to hide document\");\n        }\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}${endpoint}`,\n          (currentData: any) => {\n            if (!currentData) return currentData;\n\n            if (Array.isArray(currentData)) {\n              return currentData.filter(\n                (doc: DocumentWithLinksAndLinkCountAndViewCount) =>\n                  doc.id !== prismaDocument.id,\n              );\n            } else if (currentData.documents) {\n              return {\n                ...currentData,\n                documents: currentData.documents.filter(\n                  (doc: DocumentWithLinksAndLinkCountAndViewCount) =>\n                    doc.id !== prismaDocument.id,\n                ),\n              };\n            }\n            return currentData;\n          },\n          {\n            revalidate: false,\n          },\n        );\n        setMenuOpen(false);\n      }),\n      {\n        loading: \"Hiding document from All Documents...\",\n        success: \"Document hidden from All Documents.\",\n        error: (err) =>\n          err.message || \"Failed to hide document. Try again.\",\n      },\n    );\n  };\n\n  return (\n    <>\n      <div\n        className={cn(\n          \"group/row relative flex items-center justify-between gap-x-2 rounded-lg border-0 bg-white p-3 ring-1 ring-gray-200 transition-all hover:bg-secondary hover:ring-gray-300 dark:bg-secondary dark:ring-gray-700 hover:dark:ring-gray-500 sm:p-4\",\n          isHovered && \"bg-secondary ring-gray-300 dark:ring-gray-500\",\n        )}\n      >\n        <div className=\"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\">\n          {!isSelected && !isHovered ? (\n            <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n              {fileIcon({\n                fileType: prismaDocument.type ?? \"\",\n                className: \"h-8 w-8\",\n                isLight,\n              })}\n            </div>\n          ) : (\n            <div className=\"mx-0.5 w-8 sm:mx-1\"></div>\n          )}\n\n          <div className=\"flex-col\">\n            <div className=\"flex items-center\">\n              <h2 className=\"min-w-0 max-w-[250px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md\">\n                <Link\n                  href={`/documents/${prismaDocument.id}`}\n                  className=\"w-full truncate\"\n                >\n                  <span>{prismaDocument.name}</span>\n                  <span className=\"absolute inset-0\" />\n                </Link>\n              </h2>\n              {prismaDocument._count.datarooms > 0 && (\n                <div className=\"z-20\">\n                  <BadgeTooltip\n                    content={`In ${prismaDocument._count.datarooms} dataroom${prismaDocument._count.datarooms > 1 ? \"s\" : \"\"}`}\n                    key=\"dataroom\"\n                  >\n                    <ServerIcon className=\"ml-2 h-4 w-4 text-[#fb7a00] hover:text-[#fb7a00]/90\" />\n                  </BadgeTooltip>\n                </div>\n              )}\n            </div>\n            <div className=\"mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground\">\n              <p className=\"truncate\">{timeAgo(prismaDocument.createdAt)}</p>\n              <p>•</p>\n              <p className=\"truncate\">\n                {prismaDocument._count.links}{\" \"}\n                {prismaDocument._count.links === 1 ? \"Link\" : \"Links\"}\n              </p>\n              {prismaDocument._count.versions > 1 ? (\n                <>\n                  <p>•</p>\n                  <p className=\"truncate\">{`${prismaDocument._count.versions} Versions`}</p>\n                </>\n              ) : null}\n            </div>\n            {searchQuery || sortQuery ? (\n              <div className=\"relative z-10 mt-1 flex flex-wrap items-center space-x-1 text-xs leading-5 text-muted-foreground\">\n                {getBreadcrumbPath(prismaDocument.folderList).map(\n                  (segment, index) => (\n                    <p\n                      className=\"inset-2 flex items-center gap-x-1 truncate\"\n                      key={segment.pathLink}\n                    >\n                      {index !== 0 && <ChevronRight className=\"h-3 w-3\" />}\n                      <FolderIcon className=\"h-3 w-3\" />\n                      <Link\n                        href={segment.pathLink}\n                        className=\"relative z-10 hover:underline\"\n                      >\n                        {segment.name}\n                      </Link>\n                    </p>\n                  ),\n                )}\n                <p className=\"inset-2 flex items-center gap-x-1 truncate\">\n                  <ChevronRight className=\"h-3 w-3\" />\n                  <FileIcon className=\"h-3 w-3\" />\n                  <Link\n                    href={`/documents/${prismaDocument.id}`}\n                    className=\"relative z-10 hover:underline\"\n                  >\n                    {prismaDocument.name}\n                  </Link>\n                </p>\n              </div>\n            ) : null}\n          </div>\n        </div>\n\n        <div className=\"flex flex-row space-x-2\">\n          <Link\n            onClick={(e) => {\n              e.stopPropagation();\n            }}\n            href={`/documents/${prismaDocument.id}`}\n            className=\"z-20 flex items-center space-x-1 rounded-md bg-gray-200 px-1.5 py-0.5 transition-all duration-75 hover:scale-105 active:scale-100 dark:bg-gray-700 sm:px-2\"\n          >\n            <BarChart className=\"h-3 w-3 text-muted-foreground sm:h-4 sm:w-4\" />\n            <p className=\"whitespace-nowrap text-xs text-muted-foreground sm:text-sm\">\n              {nFormatter(prismaDocument._count.views)}\n              <span className=\"ml-1 hidden sm:inline-block\">views</span>\n            </p>\n          </Link>\n\n          <DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>\n            <DropdownMenuTrigger asChild>\n              <Button\n                // size=\"icon\"\n                variant=\"outline\"\n                className=\"z-20 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n              >\n                <span className=\"sr-only\">Open menu</span>\n                <MoreVertical className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" ref={dropdownRef}>\n              <DropdownMenuLabel>Actions</DropdownMenuLabel>\n              <DropdownMenuItem\n                onClick={() => {\n                  setPreviewOpen(true);\n                  setMenuOpen(false);\n                }}\n              >\n                <EyeIcon className=\"mr-2 h-4 w-4\" />\n                Quick preview\n              </DropdownMenuItem>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem onClick={() => setMoveFolderOpen(true)}>\n                <FolderInputIcon className=\"mr-2 h-4 w-4\" />\n                Move to folder\n              </DropdownMenuItem>\n              {/* INFO: Duplicate document is disabled for now */}\n              {/* <DropdownMenuItem\n                onClick={(e) => handleDuplicateDocument(e)}\n                disabled={!canAddDocuments}\n              >\n                <Layers2Icon className=\"mr-2 h-4 w-4\" />\n                Duplicate document\n              </DropdownMenuItem> */}\n              {datarooms && datarooms.length !== 0 && (\n                <DropdownMenuItem onClick={() => setAddDataroomOpen(true)}>\n                  <BetweenHorizontalStartIcon className=\"mr-2 h-4 w-4\" />\n                  Add to dataroom\n                </DropdownMenuItem>\n              )}\n              <DropdownMenuItem onClick={handleHideDocument}>\n                <EyeOffIcon className=\"mr-2 h-4 w-4\" />\n                Hide from All Documents\n              </DropdownMenuItem>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem\n                onClick={(event) => handleButtonClick(event, prismaDocument.id)}\n                className=\"text-destructive duration-200 focus:bg-destructive focus:text-destructive-foreground\"\n              >\n                {isFirstClick ? (\n                  \"Really delete?\"\n                ) : (\n                  <>\n                    <TrashIcon className=\"mr-2 h-4 w-4\" /> Delete document\n                  </>\n                )}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      </div>\n      {moveFolderOpen ? (\n        <MoveToFolderModal\n          open={moveFolderOpen}\n          setOpen={setMoveFolderOpen}\n          documentIds={[prismaDocument.id]}\n          itemName={prismaDocument.name}\n          folderParentId={prismaDocument.folderId!}\n        />\n      ) : null}\n\n      {addDataroomOpen ? (\n        <AddToDataroomModal\n          open={addDataroomOpen}\n          setOpen={setAddDataroomOpen}\n          documentId={prismaDocument.id}\n          documentName={prismaDocument.name}\n        />\n      ) : null}\n\n      {trialModalOpen ? (\n        <DataroomTrialModal\n          openModal={trialModalOpen}\n          setOpenModal={setTrialModalOpen}\n        />\n      ) : null}\n      {planModalOpen ? (\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.DataRooms}\n          trigger=\"datarooms\"\n          open={planModalOpen}\n          setOpen={setPlanModalOpen}\n        />\n      ) : null}\n\n      <DocumentPreviewModal\n        documentId={prismaDocument.id}\n        isOpen={previewOpen}\n        onClose={() => setPreviewOpen(false)}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/document-header.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { DocumentAIDialog } from \"@/ee/features/ai/components/document-ai-dialog\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { Document, DocumentVersion } from \"@prisma/client\";\nimport {\n  ArrowRightIcon,\n  BetweenHorizontalStartIcon,\n  ChevronRight,\n  CloudDownloadIcon,\n  DownloadIcon,\n  FileDownIcon,\n  FolderIcon,\n  MoonIcon,\n  ServerIcon,\n  SheetIcon,\n  SunIcon,\n  TrashIcon,\n  ViewIcon,\n} from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useDataroomsSimple from \"@/lib/swr/use-datarooms-simple\";\nimport { useTeamAI } from \"@/lib/swr/use-team-ai\";\nimport {\n  DocumentWithLinksAndLinkCountAndViewCount,\n  DocumentWithVersion,\n} from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { supportsAdvancedExcelMode } from \"@/lib/utils/get-content-type\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\n\nimport FileUp from \"@/components/shared/icons/file-up\";\nimport MoreVertical from \"@/components/shared/icons/more-vertical\";\nimport PapermarkSparkle from \"@/components/shared/icons/papermark-sparkle\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport PlanBadge from \"../billing/plan-badge\";\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport AdvancedSheet from \"../shared/icons/advanced-sheet\";\nimport PortraitLandscape from \"../shared/icons/portrait-landscape\";\nimport LoadingSpinner from \"../ui/loading-spinner\";\nimport { ButtonTooltip } from \"../ui/tooltip\";\nimport { AddDocumentModal } from \"./add-document-modal\";\nimport { AddToDataroomModal } from \"./add-document-to-dataroom-modal\";\nimport AlertBanner from \"./alert\";\nimport { ExportVisitsModal } from \"./export-visits-modal\";\n\nexport default function DocumentHeader({\n  prismaDocument,\n  primaryVersion,\n  teamId,\n  actions,\n}: {\n  prismaDocument: DocumentWithVersion;\n  primaryVersion: DocumentVersion;\n  teamId: string;\n  actions?: React.ReactNode[];\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { datarooms } = useDataroomsSimple();\n  const { theme, systemTheme } = useTheme();\n  const isLight =\n    theme === \"light\" || (theme === \"system\" && systemTheme === \"light\");\n  const { isPro, isFree, isTrial, isBusiness, isDatarooms } = usePlan();\n  const { canUseAI, isAIEnabled } = useTeamAI();\n  const [isEditingName, setIsEditingName] = useState<boolean>(false);\n  const [menuOpen, setMenuOpen] = useState<boolean>(false);\n  const [isFirstClick, setIsFirstClick] = useState<boolean>(false);\n  const [orientationLoading, setOrientationLoading] = useState<boolean>(false);\n  const [addDataRoomOpen, setAddDataRoomOpen] = useState<boolean>(false);\n  const [addDocumentVersion, setAddDocumentVersion] = useState<boolean>(false);\n  const [openAddDocModal, setOpenAddDocModal] = useState<boolean>(false);\n  const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);\n  const [planModalTrigger, setPlanModalTrigger] = useState<string>(\"\");\n  const [selectedPlan, setSelectedPlan] = useState<PlanEnum>(PlanEnum.Pro);\n  const [exportModalOpen, setExportModalOpen] = useState<boolean>(false);\n  const [aiDialogOpen, setAiDialogOpen] = useState<boolean>(false);\n  const nameRef = useRef<HTMLHeadingElement>(null);\n  const enterPressedRef = useRef<boolean>(false);\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n\n  const actionRows: React.ReactNode[][] = [];\n\n  if (actions) {\n    for (let i = 0; i < actions.length; i += 3) {\n      actionRows.push(actions.slice(i, i + 3));\n    }\n  }\n\n  // Check if document is in any datarooms\n  const dataroomCount = prismaDocument.datarooms?.length || 0;\n\n  const handleUpgradeClick = (plan: PlanEnum, trigger: string) => {\n    setSelectedPlan(plan);\n    setPlanModalTrigger(trigger);\n    setPlanModalOpen(true);\n  };\n\n  const handleCloseAlert = (id: string) => {\n    const alert = document.getElementById(id);\n    if (alert) {\n      alert.style.display = \"none\";\n    }\n  };\n\n  const currentTime = new Date();\n  const formattedTime =\n    currentTime.getFullYear() +\n    \"-\" +\n    String(currentTime.getMonth() + 1).padStart(2, \"0\") +\n    \"-\" +\n    String(currentTime.getDate()).padStart(2, \"0\") +\n    \"_\" +\n    String(currentTime.getHours()).padStart(2, \"0\") +\n    \"-\" +\n    String(currentTime.getMinutes()).padStart(2, \"0\");\n  \"-\" + String(currentTime.getSeconds()).padStart(2, \"0\");\n\n  // https://github.com/radix-ui/primitives/issues/1241#issuecomment-1888232392\n  useEffect(() => {\n    if (!addDataRoomOpen || !addDocumentVersion) {\n      setTimeout(() => {\n        document.body.style.pointerEvents = \"\";\n      });\n    }\n  }, [addDataRoomOpen, addDocumentVersion]);\n\n  const handleNameSubmit = async () => {\n    if (enterPressedRef.current) {\n      enterPressedRef.current = false;\n      return;\n    }\n    if (nameRef.current && isEditingName) {\n      const newName = nameRef.current.innerText;\n\n      if (newName !== prismaDocument!.name) {\n        const response = await fetch(\n          `/api/teams/${teamId}/documents/${prismaDocument!.id}/update-name`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              name: newName,\n            }),\n          },\n        );\n\n        if (response.ok) {\n          const { message } = await response.json();\n          toast.success(message);\n        } else {\n          const { message } = await response.json();\n          toast.error(message);\n        }\n      }\n      setIsEditingName(false);\n    }\n  };\n\n  const preventEnterAndSubmit = (\n    event: React.KeyboardEvent<HTMLHeadingElement>,\n  ) => {\n    if (event.key === \"Enter\") {\n      event.preventDefault(); // Prevent the default line break\n      setIsEditingName(true);\n      enterPressedRef.current = true;\n      handleNameSubmit(); // Handle the submit\n      if (nameRef.current) {\n        nameRef.current.blur(); // Remove focus from the h2 element\n      }\n    }\n  };\n\n  const [enablingAI, setEnablingAI] = useState<boolean>(false);\n\n  // Enable AI agents and automatically index the document\n  const enableAIAgents = async () => {\n    if (!canUseAI) {\n      toast.error(\n        \"AI agents are not available. Please enable them in team settings first.\",\n      );\n      return;\n    }\n\n    setEnablingAI(true);\n\n    try {\n      // Step 1: Enable AI agents on the document\n      const enableResponse = await fetch(\n        `/api/teams/${teamId}/documents/${prismaDocument.id}`,\n        {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ agentsEnabled: true }),\n        },\n      );\n\n      if (!enableResponse.ok) {\n        throw new Error(\"Failed to enable AI agents\");\n      }\n\n      // Step 2: Index the document automatically\n      const indexResponse = await fetch(\n        `/api/ai/store/teams/${teamId}/documents/${prismaDocument.id}`,\n        {\n          method: \"POST\",\n        },\n      );\n\n      if (!indexResponse.ok) {\n        // If indexing fails, still keep AI enabled but show warning\n        let errorMessage =\n          \"AI enabled, but document indexing failed. You can re-index from settings.\";\n        try {\n          const error = await indexResponse.json();\n          if (error.error) {\n            errorMessage = error.error;\n          }\n        } catch {\n          // JSON parsing failed, try to get raw text\n          try {\n            const text = await indexResponse.text();\n            if (text) {\n              errorMessage = text;\n            }\n          } catch {\n            // Ignore text parsing errors, use default message\n          }\n        }\n        toast.warning(errorMessage);\n      } else {\n        toast.success(\"AI agents enabled and document indexed successfully\");\n      }\n\n      // Refresh document data\n      mutate(`/api/teams/${teamId}/documents/${prismaDocument.id}`);\n    } catch (error) {\n      console.error(\"Error enabling AI agents:\", error);\n      toast.error(\"Failed to enable AI agents. Please try again.\");\n    } finally {\n      setEnablingAI(false);\n    }\n  };\n\n  const changeDocumentOrientation = async () => {\n    setOrientationLoading(true);\n    try {\n      const response = await fetch(\n        \"/api/teams/\" +\n          teamId +\n          \"/documents/\" +\n          prismaDocument.id +\n          \"/change-orientation\",\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            versionId: primaryVersion.id,\n            isVertical: primaryVersion.isVertical ? false : true,\n          }),\n        },\n      );\n\n      if (response.ok) {\n        const { message } = await response.json();\n        toast.success(message);\n\n        mutate(`/api/teams/${teamId}/documents/${prismaDocument.id}`);\n      } else {\n        const { message } = await response.json();\n        toast.error(message);\n      }\n    } catch (error) {\n      console.error(\"Error:\", error);\n      toast.error(\"An error occurred. Please try again.\");\n    } finally {\n      setOrientationLoading(false);\n    }\n  };\n\n  const toggleAdvancedExcel = async (document: Document, enabled: boolean) => {\n    toast.promise(\n      fetch(`/api/teams/${teamId}/documents/${document.id}/advanced-mode`, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ enabled }),\n      }).then(async (response) => {\n        if (!response.ok) {\n          const { message } = await response.json();\n          throw new Error(message);\n        }\n        const { message } = await response.json();\n        mutate(`/api/teams/${teamId}/documents/${document.id}`);\n        if (enabled) {\n          handleCloseAlert(\"enable-advanced-excel-alert\");\n        }\n        return message;\n      }),\n      {\n        loading: enabled\n          ? \"Enabling advanced Excel mode...\"\n          : \"Disabling advanced Excel mode...\",\n        success: (message) => message,\n        error: (error) =>\n          error.message ||\n          (enabled\n            ? \"Failed to enable advanced Excel mode\"\n            : \"Failed to disable advanced Excel mode\"),\n      },\n    );\n  };\n\n  // export method to fetch the visits data and convert to csv.\n  const exportVisitCounts = (document: Document) => {\n    if (isFree) {\n      toast.error(\"This feature is not available for your plan\");\n      return;\n    }\n    setExportModalOpen(true);\n  };\n\n  // Make a document download only or viewable\n  const toggleDownloadOnly = async () => {\n    toast.promise(\n      fetch(\n        `/api/teams/${teamId}/documents/${prismaDocument.id}/toggle-download-only`,\n        {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            downloadOnly: !prismaDocument.downloadOnly,\n          }),\n        },\n      ).then(() => {\n        mutate(`/api/teams/${teamId}/documents/${prismaDocument.id}`);\n      }),\n      {\n        loading: \"Updating document...\",\n        success: `Document is now ${\n          !prismaDocument.downloadOnly ? \"download only\" : \"viewable\"\n        }`,\n        error: \"Failed to update document\",\n      },\n    );\n  };\n\n  // Toggle dark mode for Notion documents\n  const toggleNotionDarkMode = async (darkMode: boolean) => {\n    if (prismaDocument.type !== \"notion\") {\n      toast.error(\"This feature is not available for your document type\");\n      return;\n    }\n\n    toast.promise(\n      fetch(\n        `/api/teams/${teamId}/documents/${prismaDocument.id}/toggle-dark-mode`,\n        {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            darkMode: darkMode,\n          }),\n        },\n      ).then(() => {\n        mutate(`/api/teams/${teamId}/documents/${prismaDocument.id}`);\n      }),\n      {\n        loading: \"Updating Notion theme...\",\n        success: `Notion theme changed to ${darkMode ? \"dark\" : \"light\"} mode`,\n        error: \"Failed to update Notion theme\",\n      },\n    );\n  };\n\n  useEffect(() => {\n    function handleClickOutside(event: { target: any }) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {\n        setMenuOpen(false);\n        setIsFirstClick(false);\n      }\n    }\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, []);\n\n  const handleDeleteDocument = async (documentId: string) => {\n    // Prevent the first click from deleting the document\n    if (!isFirstClick) {\n      setIsFirstClick(true);\n      return;\n    }\n\n    toast.promise(\n      fetch(`/api/teams/${teamId}/documents/${documentId}`, {\n        method: \"DELETE\",\n      }).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to delete document\");\n        }\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`, null, {\n          populateCache: (_, docs) => {\n            return docs.filter(\n              (doc: DocumentWithLinksAndLinkCountAndViewCount) =>\n                doc.id !== documentId,\n            );\n          },\n          revalidate: false,\n        });\n        setIsFirstClick(false);\n        setMenuOpen(false);\n        router.push(\"/documents\");\n      }),\n      {\n        loading: \"Deleting document...\",\n        success: \"Document deleted successfully.\",\n        error: (err) => err.message || \"Failed to delete document. Try again.\",\n      },\n    );\n  };\n\n  const handleMenuStateChange = (open: boolean) => {\n    if (isFirstClick) {\n      setMenuOpen(true); // Keep the dropdown open on the first click\n      return;\n    }\n\n    // If the menu is closed, reset the isFirstClick state\n    if (!open) {\n      setIsFirstClick(false);\n      setMenuOpen(false); // Ensure the dropdown is closed\n    } else {\n      setMenuOpen(true); // Open the dropdown\n    }\n  };\n\n  const handleButtonClick = (event: any, documentId: string) => {\n    event.stopPropagation();\n    event.preventDefault();\n\n    if (isFirstClick) {\n      handleDeleteDocument(documentId);\n      setIsFirstClick(false);\n      setMenuOpen(false); // Close the dropdown after deleting\n    } else {\n      setIsFirstClick(true);\n    }\n  };\n\n  const downloadDocument = async (documentVersion: DocumentVersion) => {\n    if (documentVersion.type === \"notion\") {\n      toast.error(\"Notion documents cannot be downloaded.\");\n      return;\n    }\n    toast.promise(\n      (async () => {\n        const downloadUrl = await getFile({\n          type: documentVersion.storageType,\n          data: documentVersion.originalFile ?? documentVersion.file,\n          isDownload: true,\n        });\n\n        // Fetch the file from the S3 URL and create blob\n        const response = await fetch(downloadUrl);\n        const blob = await response.blob();\n        const url = window.URL.createObjectURL(blob);\n        const a = document.createElement(\"a\");\n        a.href = url;\n        a.download = prismaDocument.name;\n        document.body.appendChild(a);\n        a.click();\n        window.URL.revokeObjectURL(url);\n        document.body.removeChild(a);\n      })(),\n      {\n        loading: \"Downloading document...\",\n        success: \"Document downloaded successfully\",\n        error: \"Failed to download document\",\n      },\n    );\n  };\n\n  return (\n    <header className=\"flex flex-col gap-y-4\">\n      <div className=\"flex items-center justify-between gap-x-8\">\n        <div className=\"flex items-center space-x-2\">\n          {fileIcon({\n            fileType: prismaDocument.type ?? \"\",\n            className: \"size-7 sm:size-8\",\n            isLight,\n          })}\n\n          <div className=\"mt-1 flex flex-col lg:mt-0\">\n            <h2\n              className=\"rounded-md border border-transparent px-1 py-0.5 text-lg font-semibold tracking-tight text-foreground duration-200 hover:cursor-text hover:border hover:border-border focus-visible:text-lg lg:px-3 lg:py-1 lg:text-xl lg:focus-visible:text-xl xl:text-2xl\"\n              ref={nameRef}\n              contentEditable={true}\n              onFocus={() => setIsEditingName(true)}\n              onBlur={handleNameSubmit}\n              onKeyDown={preventEnterAndSubmit}\n              title=\"Click to edit\"\n              dangerouslySetInnerHTML={{ __html: prismaDocument.name }}\n            />\n            {isEditingName && (\n              <span className=\"mt-1 text-xs text-muted-foreground\">\n                {`Press <Enter> to save the name.`}\n              </span>\n            )}\n          </div>\n\n          {prismaDocument.type === \"sheet\" &&\n            prismaDocument.advancedExcelEnabled && (\n              <ButtonTooltip content=\"Advanced Excel mode\">\n                <span className=\"mt-1 text-xs\">\n                  <AdvancedSheet className=\"h-6 w-6\" />\n                </span>\n              </ButtonTooltip>\n            )}\n\n          {prismaDocument.downloadOnly && (\n            <ButtonTooltip content=\"Download only\">\n              <span className=\"text-xs\">\n                <CloudDownloadIcon className=\"h-6 w-6\" />\n                <span className=\"sr-only\">This document is download only</span>\n              </span>\n            </ButtonTooltip>\n          )}\n        </div>\n\n        <div className=\"flex items-center gap-x-4 md:gap-x-2\">\n          {primaryVersion.type !== \"notion\" &&\n            primaryVersion.type !== \"link\" &&\n            primaryVersion.type !== \"sheet\" &&\n            primaryVersion.type !== \"zip\" &&\n            primaryVersion.type !== \"video\" &&\n            (!orientationLoading ? (\n              <ButtonTooltip content=\"Change orientation\">\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"hidden size-8 sm:flex lg:size-9\"\n                  onClick={changeDocumentOrientation}\n                  title={`Change document orientation to ${primaryVersion.isVertical ? \"landscape\" : \"portrait\"}`}\n                >\n                  <PortraitLandscape\n                    className={cn(\n                      \"h-6 w-6\",\n                      !primaryVersion.isVertical && \"-rotate-90 transform\",\n                    )}\n                  />\n                </Button>\n              </ButtonTooltip>\n            ) : (\n              <div className=\"hidden md:flex\">\n                <LoadingSpinner className=\"h-6 w-6\" />\n              </div>\n            ))}\n\n          {primaryVersion.type !== \"notion\" &&\n            primaryVersion.type !== \"link\" && (\n              <AddDocumentModal\n                newVersion\n                openModal={openAddDocModal}\n                setAddDocumentModalOpen={setOpenAddDocModal}\n              >\n                <ButtonTooltip content=\"Upload new version\">\n                  <Button\n                    variant=\"outline\"\n                    size=\"icon\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      setOpenAddDocModal(true);\n                    }}\n                    className=\"hidden size-8 md:flex lg:size-9\"\n                  >\n                    <FileUp className=\"h-6 w-6\" />\n                  </Button>\n                </ButtonTooltip>\n              </AddDocumentModal>\n            )}\n\n          {/* AI Agents Button */}\n          {isAIEnabled &&\n            prismaDocument.type !== \"notion\" &&\n            primaryVersion.type !== \"link\" &&\n            prismaDocument.type !== \"zip\" &&\n            primaryVersion.type !== \"video\" &&\n            (prismaDocument.agentsEnabled ? (\n              <ButtonTooltip content=\"AI Agents Settings\">\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"hidden size-8 md:flex lg:size-9\"\n                  onClick={() => setAiDialogOpen(true)}\n                >\n                  <PapermarkSparkle className=\"h-5 w-5 text-emerald-500\" />\n                </Button>\n              </ButtonTooltip>\n            ) : (\n              <ButtonTooltip content=\"Enable AI Agents\">\n                <Button\n                  variant=\"outline\"\n                  size=\"icon\"\n                  className=\"hidden size-8 md:flex lg:size-9\"\n                  onClick={enableAIAgents}\n                  disabled={enablingAI}\n                >\n                  {enablingAI ? (\n                    <LoadingSpinner className=\"h-5 w-5\" />\n                  ) : (\n                    <PapermarkSparkle className=\"h-5 w-5\" />\n                  )}\n                </Button>\n              </ButtonTooltip>\n            ))}\n\n          <div className=\"flex items-center gap-x-1\">\n            {actionRows.map((row, i) => (\n              <ul\n                key={i.toString()}\n                className=\"flex flex-wrap items-center justify-end gap-x-2 md:flex-nowrap md:gap-x-1\"\n              >\n                {row.map((action, i) => (\n                  <li key={i}>{action}</li>\n                ))}\n              </ul>\n            ))}\n          </div>\n\n          <DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>\n            <DropdownMenuTrigger asChild>\n              <Button\n                variant=\"outline\"\n                className=\"h-8 w-8 bg-transparent p-0 lg:h-9 lg:w-9\"\n              >\n                <span className=\"sr-only\">Open menu</span>\n                <MoreVertical className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent\n              align=\"end\"\n              className=\"w-[240px]\"\n              ref={dropdownRef}\n            >\n              <DropdownMenuLabel>Actions</DropdownMenuLabel>\n              <DropdownMenuGroup className=\"block md:hidden\">\n                {prismaDocument.type !== \"notion\" &&\n                  primaryVersion.type !== \"video\" && (\n                    <DropdownMenuItem>\n                      <AddDocumentModal\n                        newVersion\n                        setAddDocumentModalOpen={setAddDocumentVersion}\n                      >\n                        <button\n                          title=\"Add a new version\"\n                          className=\"flex items-center\"\n                          onClick={(e) => {\n                            e.stopPropagation();\n                            setAddDocumentVersion(true);\n                          }}\n                        >\n                          <FileUp className=\"mr-2 h-4 w-4\" /> Add new version\n                        </button>\n                      </AddDocumentModal>\n                    </DropdownMenuItem>\n                  )}\n\n                <DropdownMenuSeparator />\n              </DropdownMenuGroup>\n              {prismaDocument.type === \"sheet\" &&\n                supportsAdvancedExcelMode(primaryVersion.contentType) &&\n                (isPro || isBusiness || isDatarooms || isTrial) && (\n                  <DropdownMenuItem\n                    onClick={() =>\n                      toggleAdvancedExcel(\n                        prismaDocument,\n                        !prismaDocument.advancedExcelEnabled,\n                      )\n                    }\n                  >\n                    <SheetIcon className=\"mr-2 h-4 w-4\" />\n                    {prismaDocument.advancedExcelEnabled\n                      ? \"Disable Advanced Mode\"\n                      : \"Enable Advanced Mode\"}\n                  </DropdownMenuItem>\n                )}\n              {datarooms && datarooms.length !== 0 && (\n                <DropdownMenuItem onClick={() => setAddDataRoomOpen(true)}>\n                  <BetweenHorizontalStartIcon className=\"mr-2 h-4 w-4\" />\n                  Add to dataroom\n                </DropdownMenuItem>\n              )}\n\n              {/* AI Agents - only show when team has AI enabled */}\n              {isAIEnabled &&\n                prismaDocument.type !== \"notion\" &&\n                primaryVersion.type !== \"link\" &&\n                prismaDocument.type !== \"zip\" &&\n                primaryVersion.type !== \"video\" &&\n                (prismaDocument.agentsEnabled ? (\n                  <DropdownMenuItem\n                    onClick={() => {\n                      setAiDialogOpen(true);\n                      setMenuOpen(false);\n                    }}\n                  >\n                    <PapermarkSparkle className=\"mr-2 h-4 w-4 text-emerald-500\" />\n                    AI Agents Settings\n                  </DropdownMenuItem>\n                ) : (\n                  <DropdownMenuItem\n                    onClick={() => {\n                      enableAIAgents();\n                      setMenuOpen(false);\n                    }}\n                    disabled={enablingAI}\n                  >\n                    <PapermarkSparkle className=\"mr-2 h-4 w-4\" />\n                    {enablingAI ? \"Enabling AI...\" : \"Enable AI Agents\"}\n                  </DropdownMenuItem>\n                ))}\n\n              {primaryVersion.type !== \"notion\" &&\n                primaryVersion.type !== \"link\" &&\n                primaryVersion.type !== \"zip\" &&\n                primaryVersion.type !== \"map\" &&\n                primaryVersion.type !== \"email\" && (\n                  <DropdownMenuItem\n                    onClick={() =>\n                      isFree\n                        ? handleUpgradeClick(\n                            PlanEnum.Business,\n                            \"download-only-document\",\n                          )\n                        : toggleDownloadOnly()\n                    }\n                  >\n                    {prismaDocument.downloadOnly ? (\n                      <>\n                        <ViewIcon className=\"mr-2 h-4 w-4\" />\n                        Set viewable\n                      </>\n                    ) : (\n                      <>\n                        <CloudDownloadIcon className=\"mr-2 h-4 w-4\" />\n                        Set download only{\" \"}\n                        {isFree && <PlanBadge className=\"ml-2\" plan=\"pro\" />}\n                      </>\n                    )}\n                  </DropdownMenuItem>\n                )}\n\n              {prismaDocument.type === \"notion\" && (\n                <>\n                  {primaryVersion.file.includes(\"mode=dark\") ? (\n                    <DropdownMenuItem\n                      onClick={() => toggleNotionDarkMode(false)}\n                    >\n                      <MoonIcon className=\"mr-2 h-4 w-4\" />\n                      Disable dark mode\n                    </DropdownMenuItem>\n                  ) : (\n                    <DropdownMenuItem\n                      onClick={() => toggleNotionDarkMode(true)}\n                    >\n                      <SunIcon className=\"mr-2 h-4 w-4\" />\n                      Enable dark mode\n                    </DropdownMenuItem>\n                  )}\n                </>\n              )}\n\n              <DropdownMenuSeparator />\n\n              {/* Export views in CSV */}\n              <DropdownMenuItem\n                onClick={() =>\n                  isFree\n                    ? handleUpgradeClick(PlanEnum.Pro, \"export-document-visits\")\n                    : exportVisitCounts(prismaDocument)\n                }\n              >\n                <FileDownIcon className=\"mr-2 h-4 w-4\" />\n                Export views{\" \"}\n                {isFree && <PlanBadge className=\"ml-2\" plan=\"pro\" />}\n              </DropdownMenuItem>\n\n              {/* Download latest version */}\n              {primaryVersion.type !== \"notion\" &&\n                primaryVersion.type !== \"link\" &&\n                primaryVersion.type !== \"video\" && (\n                  <DropdownMenuItem\n                    onClick={() => downloadDocument(primaryVersion)}\n                  >\n                    <DownloadIcon className=\"mr-2 h-4 w-4\" />\n                    Download latest version\n                  </DropdownMenuItem>\n                )}\n\n              <DropdownMenuSeparator />\n\n              <DropdownMenuItem\n                className=\"text-destructive focus:bg-destructive focus:text-destructive-foreground\"\n                onClick={(event) => handleButtonClick(event, prismaDocument.id)}\n              >\n                <TrashIcon className=\"mr-2 h-4 w-4\" />\n                {isFirstClick ? \"Really delete?\" : \"Delete document\"}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      </div>\n\n      {/* Datarooms collapsible section */}\n      {dataroomCount > 0 && (\n        <div className=\"mb-2\">\n          <Collapsible className=\"w-full\">\n            <CollapsibleTrigger className=\"flex w-full items-center text-sm font-medium\">\n              <div className=\"flex items-center space-x-2 [&[data-state=open]>svg.chevron]:rotate-180\">\n                <ChevronRight className=\"h-4 w-4 transition-transform duration-200\" />\n                <ServerIcon className=\"h-4 w-4 text-[#fb7a00]\" />\n                <span>\n                  In {dataroomCount} dataroom{dataroomCount > 1 ? \"s\" : \"\"}\n                </span>\n              </div>\n            </CollapsibleTrigger>\n            <CollapsibleContent className=\"pl-6 pt-2\">\n              <ul className=\"space-y-1\">\n                {prismaDocument.datarooms?.map((item) => (\n                  <li\n                    key={item.dataroom.id}\n                    className=\"flex items-center space-x-2 text-sm\"\n                  >\n                    <ArrowRightIcon className=\"h-3.5 w-3.5\" />\n                    <Link\n                      href={`/datarooms/${item.dataroom.id}/documents`}\n                      className=\"hover:underline\"\n                    >\n                      {item.dataroom.name}\n                    </Link>\n                    {item.folder ? (\n                      <Link\n                        href={`/datarooms/${item.dataroom.id}/documents/${item.folder.path}`}\n                        className=\"flex flex-row items-center space-x-2 hover:underline\"\n                        title={`Folder: ${item.folder.name}`}\n                      >\n                        <ArrowRightIcon className=\"h-3.5 w-3.5\" />\n                        <FolderIcon className=\"mr-1 h-4 w-4\" />\n                        <span className=\"ml-1 truncate\">\n                          {item.folder.name}\n                        </span>\n                      </Link>\n                    ) : (\n                      <Link\n                        href={`/datarooms/${item.dataroom.id}/documents`}\n                        className=\"flex flex-row items-center space-x-2 hover:underline\"\n                        title=\"Home\"\n                      >\n                        <ArrowRightIcon className=\"h-3.5 w-3.5\" />\n                        <FolderIcon className=\"mr-1 h-4 w-4\" />\n                        <span className=\"ml-1 truncate\">Home</span>\n                      </Link>\n                    )}\n                  </li>\n                ))}\n              </ul>\n            </CollapsibleContent>\n          </Collapsible>\n        </div>\n      )}\n\n      {isFree && prismaDocument.hasPageLinks && (\n        <AlertBanner\n          id=\"in-document-links-alert\"\n          variant=\"destructive\"\n          title=\"In-document links detected\"\n          description={\n            <>\n              External in-document links are not available on the free plan.{\" \"}\n              <span\n                className=\"cursor-pointer underline underline-offset-4 hover:text-destructive/80\"\n                onClick={() =>\n                  handleUpgradeClick(PlanEnum.Pro, \"in-document-links\")\n                }\n              >\n                Upgrade\n              </span>{\" \"}\n              to a higher plan to use this feature.\n            </>\n          }\n          onClose={() => handleCloseAlert(\"in-document-links-alert\")}\n        />\n      )}\n\n      {prismaDocument.type === \"sheet\" &&\n        supportsAdvancedExcelMode(primaryVersion.contentType) &&\n        isFree &&\n        !isTrial && (\n          <AlertBanner\n            id=\"advanced-excel-alert\"\n            variant=\"default\"\n            title=\"Advanced Excel mode\"\n            description={\n              <>\n                You can turn on advanced excel mode by{\" \"}\n                <span\n                  className=\"hover:text-primary/ 80 cursor-pointer underline underline-offset-4\"\n                  onClick={() =>\n                    handleUpgradeClick(PlanEnum.Pro, \"advanced-excel-mode\")\n                  }\n                >\n                  upgrading\n                </span>{\" \"}\n                to Pro plan to preserve the file formatting. This uses the\n                Microsoft Office viewer.\n              </>\n            }\n            onClose={() => handleCloseAlert(\"advanced-excel-alert\")}\n          />\n        )}\n\n      {prismaDocument.type === \"sheet\" &&\n        !prismaDocument.advancedExcelEnabled &&\n        supportsAdvancedExcelMode(primaryVersion.contentType) &&\n        (isPro || isBusiness || isDatarooms || isTrial) && (\n          <AlertBanner\n            id=\"enable-advanced-excel-alert\"\n            variant=\"default\"\n            title=\"Advanced Excel mode\"\n            description={\n              <>\n                You can{\" \"}\n                <span\n                  className=\"cursor-pointer underline underline-offset-4 hover:text-primary/80\"\n                  onClick={() => setMenuOpen(true)}\n                >\n                  turn on\n                </span>{\" \"}\n                advanced excel mode to improve the file formatting.\n                <br /> The advanced mode uses Microsoft viewer.\n              </>\n            }\n            onClose={() => handleCloseAlert(\"enable-advanced-excel-alert\")}\n          />\n        )}\n\n      {addDataRoomOpen ? (\n        <AddToDataroomModal\n          open={addDataRoomOpen}\n          setOpen={setAddDataRoomOpen}\n          documentId={prismaDocument.id}\n          documentName={prismaDocument.name}\n        />\n      ) : null}\n\n      {planModalOpen ? (\n        <UpgradePlanModal\n          clickedPlan={selectedPlan}\n          trigger={planModalTrigger}\n          open={planModalOpen}\n          setOpen={setPlanModalOpen}\n        />\n      ) : null}\n\n      {exportModalOpen && (\n        <ExportVisitsModal\n          document={prismaDocument}\n          teamId={teamId}\n          onClose={() => setExportModalOpen(false)}\n        />\n      )}\n\n      {/* AI Agents Dialog */}\n      <DocumentAIDialog\n        open={aiDialogOpen}\n        onOpenChange={setAiDialogOpen}\n        documentId={prismaDocument.id}\n        teamId={teamId}\n        agentsEnabled={prismaDocument.agentsEnabled}\n        vectorStoreFileId={primaryVersion.vectorStoreFileId}\n      />\n    </header>\n  );\n}\n"
  },
  {
    "path": "components/documents/document-preview-button.tsx",
    "content": "import { useState } from \"react\";\n\nimport { EyeIcon, EyeOffIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ButtonTooltip } from \"@/components/ui/tooltip\";\n\nimport { DocumentPreviewModal } from \"./document-preview-modal\";\n\ninterface DocumentPreviewButtonProps {\n  documentId: string;\n  primaryVersion?: {\n    hasPages?: boolean;\n    type?: string | null;\n    numPages?: number | null;\n  };\n  advancedExcelEnabled?: boolean;\n  isProcessing?: boolean;\n  variant?: \"ghost\" | \"outline\" | \"default\";\n  size?: \"sm\" | \"default\" | \"lg\" | \"icon\";\n  children?: React.ReactNode;\n  className?: string;\n  showTooltip?: boolean;\n}\n\nexport function DocumentPreviewButton({\n  documentId,\n  primaryVersion,\n  advancedExcelEnabled = false,\n  isProcessing = false,\n  variant = \"ghost\",\n  size = \"icon\",\n  children,\n  className,\n  showTooltip = true,\n}: DocumentPreviewButtonProps) {\n  const [isPreviewOpen, setIsPreviewOpen] = useState(false);\n\n  // Check if document type supports preview\n  const supportsPreview = () => {\n    if (!primaryVersion) return false;\n\n    // Support documents with pages (PDFs, docs, slides, etc.)\n    if (primaryVersion.hasPages) return true;\n\n    // Support image documents\n    if (primaryVersion.type === \"image\") return true;\n\n    // Support Excel sheets with advanced mode enabled\n    if (primaryVersion.type === \"sheet\" && advancedExcelEnabled) return true;\n\n    return false;\n  };\n\n  // Don't render if document type doesn't support preview\n  if (!supportsPreview()) {\n    return null;\n  }\n\n  const handlePreviewClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n    e.stopPropagation();\n    e.preventDefault();\n\n    if (isProcessing) return;\n    setIsPreviewOpen(true);\n  };\n\n  // Stop propagation on the container to prevent parent click handlers from firing\n  const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {\n    e.stopPropagation();\n  };\n\n  const button = (\n    <Button\n      variant={variant}\n      size={size}\n      onClick={handlePreviewClick}\n      disabled={isProcessing}\n      className={className}\n    >\n      {children || (\n        <>\n          <EyeIcon className=\"h-4 w-4\" />\n          {size !== \"icon\" && <span className=\"ml-1\">Preview</span>}\n        </>\n      )}\n    </Button>\n  );\n\n  const wrappedButton = showTooltip ? (\n    <ButtonTooltip\n      content={\n        isProcessing\n          ? \"Document is still processing\"\n          : \"Quick preview of document\"\n      }\n    >\n      {button}\n    </ButtonTooltip>\n  ) : (\n    button\n  );\n\n  return (\n    <div onClick={handleContainerClick} onMouseDown={handleContainerClick}>\n      {wrappedButton}\n      <DocumentPreviewModal\n        documentId={documentId}\n        isOpen={isPreviewOpen}\n        onClose={() => setIsPreviewOpen(false)}\n      />\n    </div>\n  );\n}\n\n// Helper function to check if document is processing\nexport function isDocumentProcessing(primaryVersion?: {\n  hasPages?: boolean;\n  type?: string | null;\n  numPages?: number | null;\n}) {\n  if (!primaryVersion) return false;\n\n  // Check if document type should have pages but doesn't\n  const shouldHavePages = [\"pdf\", \"docs\", \"slides\", \"cad\"].includes(\n    primaryVersion.type || \"\",\n  );\n\n  return shouldHavePages && !primaryVersion.hasPages;\n}\n"
  },
  {
    "path": "components/documents/document-preview-modal.tsx",
    "content": "import { useEffect } from \"react\";\n\nimport { XIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useDocumentPreview } from \"@/lib/swr/use-document-preview\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\n\nimport { PreviewViewer } from \"./preview-viewers/preview-viewer\";\n\ninterface DocumentPreviewModalProps {\n  documentId: string;\n  isOpen: boolean;\n  onClose: () => void;\n}\n\nexport function DocumentPreviewModal({\n  documentId,\n  isOpen,\n  onClose,\n}: DocumentPreviewModalProps) {\n  const {\n    document: documentData,\n    loading,\n    error,\n  } = useDocumentPreview(documentId, isOpen);\n\n  const handleClose = () => {\n    onClose();\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Escape\") {\n      handleClose();\n    }\n  };\n\n  const handleContentClick = (e: React.MouseEvent) => {\n    // Prevent clicks from propagating to underlying elements\n    e.stopPropagation();\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={handleClose}>\n      <DialogContent\n        className=\"h-[99vh] w-[90%] rounded-lg bg-gray-900 p-0 data-[state=open]:slide-in-from-bottom-0 md:w-[80vw] md:max-w-[80vw]\"\n        onKeyDown={handleKeyDown}\n        onClick={handleContentClick}\n        isPreviewDialog\n      >\n        {/* Header with close button */}\n        <div className=\"absolute right-4 top-4 z-50\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={handleClose}\n            className=\"h-8 w-8 rounded-full bg-black/20 text-white hover:bg-black/40\"\n          >\n            <XIcon className=\"h-4 w-4\" />\n          </Button>\n        </div>\n\n        {/* Loading state */}\n        {loading && (\n          <div className=\"flex h-full w-full items-center justify-center\">\n            <div className=\"text-center\">\n              <LoadingSpinner className=\"mx-auto h-8 w-8 text-white\" />\n              <p className=\"mt-2 text-sm text-gray-400\">Loading preview...</p>\n            </div>\n          </div>\n        )}\n\n        {/* Error state */}\n        {error && (\n          <div className=\"flex h-full w-full items-center justify-center\">\n            <div className=\"text-center\">\n              <p className=\"text-red-400\">\n                {(error as Error).message || \"Failed to load document preview\"}\n              </p>\n            </div>\n          </div>\n        )}\n\n        {/* Document preview */}\n        {documentData && !loading && !error && (\n          <PreviewViewer documentData={documentData} onClose={handleClose} />\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/documents/document-stats-placeholder.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\n\nimport StatsCard from \"./stats-card\";\nimport StatsChartDummy from \"./stats-chart-dummy\";\n\ninterface DocumentStatsPlaceholderProps {\n  numPages: number;\n  onCreateLink: () => void;\n}\n\nexport default function DocumentStatsPlaceholder({\n  numPages,\n  onCreateLink,\n}: DocumentStatsPlaceholderProps) {\n  // Mock stats data for placeholder\n  const mockStatsData = {\n    stats: undefined,\n    loading: false,\n    error: null,\n  };\n\n  return (\n    <>\n      <div className=\"flex items-center justify-end space-x-2\">\n        <Switch disabled={true} id=\"toggle-stats-placeholder\" checked={false} />\n        <Label\n          htmlFor=\"toggle-stats-placeholder\"\n          className=\"text-muted-foreground\"\n        >\n          Exclude internal views\n        </Label>\n      </div>\n\n      {/* Stats Chart Placeholder */}\n      <StatsChartDummy totalPagesMax={numPages} />\n\n      {/* Stats Card Placeholder */}\n      <div className=\"grid grid-cols-1 space-y-2 border-foreground/5 sm:grid-cols-3 sm:space-x-2 sm:space-y-0 lg:grid-cols-3 lg:space-x-3\">\n        {[\n          { name: \"Number of views\", value: \"0\", active: false },\n          { name: \"Average view completion\", value: \"%\", active: false },\n          {\n            name: \"Total average view duration\",\n            value: \"0\",\n            unit: \"seconds\",\n            active: false,\n          },\n        ].map((stat, statIdx) => (\n          <div\n            key={statIdx}\n            className=\"rounded-lg border border-foreground/5 px-4 py-6 sm:px-6 lg:px-8\"\n          >\n            <dt className=\"text-sm font-medium text-gray-500\">{stat.name}</dt>\n            <dd className=\"mt-1 flex items-baseline justify-between md:block lg:flex\">\n              <div className=\"flex items-baseline text-2xl font-semibold text-gray-400\">\n                {stat.value}\n                {stat.unit && (\n                  <span className=\"ml-2 text-sm font-medium text-gray-300\">\n                    {stat.unit}\n                  </span>\n                )}\n              </div>\n            </dd>\n          </div>\n        ))}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/documents-list.tsx",
    "content": "import { memo, useCallback, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  DndContext,\n  DragEndEvent,\n  DragOverEvent,\n  DragOverlay,\n  DragStartEvent,\n  MeasuringStrategy,\n  MouseSensor,\n  PointerSensor,\n  TouchSensor,\n  UniqueIdentifier,\n  useSensor,\n  useSensors,\n} from \"@dnd-kit/core\";\nimport {\n  EyeOffIcon,\n  FileIcon,\n  FolderIcon,\n  FolderInputIcon,\n  Trash2Icon,\n  XIcon,\n} from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { moveDocumentToFolder } from \"@/lib/documents/move-documents\";\nimport { moveFolderToFolder } from \"@/lib/documents/move-folder\";\nimport { DataroomFolderWithCount } from \"@/lib/swr/use-dataroom\";\nimport { FolderWithCount, FolderWithCountAndPath } from \"@/lib/swr/use-documents\";\nimport { DocumentWithLinksAndLinkCountAndViewCount } from \"@/lib/types\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { UploadNotificationDrawer } from \"@/components/upload-notification\";\nimport UploadZone, {\n  RejectedFile,\n  UploadState,\n} from \"@/components/upload-zone\";\n\nimport { itemsMessage } from \"../datarooms/folders/utils\";\nimport { Button } from \"../ui/button\";\nimport { Checkbox } from \"../ui/checkbox\";\nimport { Portal } from \"../ui/portal\";\nimport { ButtonTooltip } from \"../ui/tooltip\";\nimport { useDeleteDocumentsAndFoldersModal } from \"./actions/delete-documents-modal\";\nimport { useDeleteFolderModal } from \"./actions/delete-folder-modal\";\nimport DocumentCard from \"./document-card\";\nimport { DraggableItem } from \"./drag-and-drop/draggable-item\";\nimport { DroppableFolder } from \"./drag-and-drop/droppable-folder\";\nimport { EmptyDocuments } from \"./empty-document\";\nimport FolderCard from \"./folder-card\";\nimport { MoveToFolderModal, TSelectedFolder } from \"./move-folder-modal\";\n\nexport type Upload = {\n  fileName: string;\n  progress: number;\n  documentId?: string;\n};\n\nexport type File = {\n  fileName: string;\n  message: string;\n};\n\nexport function DocumentsList({\n  folders,\n  documents,\n  teamInfo,\n  folderPathName,\n  loading,\n  foldersLoading,\n}: {\n  folders: FolderWithCount[] | FolderWithCountAndPath[] | undefined;\n  documents: DocumentWithLinksAndLinkCountAndViewCount[] | undefined;\n  teamInfo: TeamContextType | null;\n  folderPathName?: string[];\n  loading: boolean;\n  foldersLoading: boolean;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  const [uploads, setUploads] = useState<UploadState[]>([]);\n  const [rejectedFiles, setRejectedFiles] = useState<RejectedFile[]>([]);\n  const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);\n  const [selectedFolders, setSelectedFolders] = useState<string[]>([]);\n\n  const [showDrawer, setShowDrawer] = useState<boolean>(false);\n  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });\n  const [draggedDocument, setDraggedDocument] =\n    useState<DocumentWithLinksAndLinkCountAndViewCount | null>(null);\n\n  //forFolder\n  const [draggedFolder, setDraggedFolder] = useState<\n    FolderWithCount | DataroomFolderWithCount | null\n  >(null);\n  const [isDragging, setIsDragging] = useState<boolean>(false);\n  const [isOverFolder, setIsOverFolder] = useState<boolean>(false);\n  const [parentFolderId, setParentFolderId] = useState<string>(\"\");\n  const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);\n\n  const { setDeleteModalOpen, setFolderToDelete, DeleteFolderModal } =\n    useDeleteFolderModal(teamInfo);\n\n  const handleDeleteFolder = useCallback(\n    (folderId: string) => {\n      const folderToDelete = folders?.find((f) => f.id === folderId);\n      if (folderToDelete) {\n        setFolderToDelete(folderToDelete);\n        setDeleteModalOpen(true);\n        setSelectedFolders((prev) => prev.filter((id) => id !== folderId));\n      }\n    },\n    [folders, setFolderToDelete, setDeleteModalOpen, setSelectedFolders],\n  );\n\n  const { setShowDeleteItemsModal, DeleteItemsModal } =\n    useDeleteDocumentsAndFoldersModal({\n      documentIds: selectedDocuments,\n      setSelectedDocuments,\n      folderIds: selectedFolders,\n      setSelectedFolder: setSelectedFolders,\n    });\n\n  const totalSelectedItem = [...selectedDocuments, ...selectedFolders].length;\n\n  const sensors = useSensors(\n    useSensor(MouseSensor),\n    useSensor(TouchSensor),\n    useSensor(PointerSensor, {\n      activationConstraint: {\n        distance: 10,\n      },\n    }),\n  );\n\n  const selectedDocumentsLength = useMemo(\n    () => selectedDocuments && selectedDocuments.length,\n    [selectedDocuments],\n  );\n\n  const selectedFoldersLength = useMemo(\n    () => selectedFolders && selectedFolders.length,\n    [selectedFolders],\n  );\n\n  const handleSelect = useCallback(\n    (id: string, type: \"document\" | \"folder\") => {\n      if (type === \"folder\") {\n        setSelectedFolders((prev) =>\n          prev.includes(id)\n            ? prev.filter((docId) => docId !== id)\n            : [...prev, id],\n        );\n      } else {\n        setSelectedDocuments((prev) =>\n          prev.includes(id)\n            ? prev.filter((docId) => docId !== id)\n            : [...prev, id],\n        );\n      }\n    },\n    [],\n  );\n\n  const handleDragForType = useCallback(\n    (\n      itemId: string,\n      items: { id: string }[] | undefined,\n      setDraggedItem: (item: any) => void,\n      selectedItems: string[],\n      setSelectedItems: (items: string[]) => void,\n    ) => {\n      if (!items) return;\n\n      const draggedItem = items.find((item) => item.id === itemId) ?? null;\n      setDraggedItem(draggedItem);\n\n      const itemIndex = items.findIndex((item) => item.id === itemId);\n      const isSelected = selectedItems.includes(itemId);\n\n      let yOffset = 0;\n      if (isSelected) {\n        const firstSelectedIndex = items.findIndex((item) =>\n          selectedItems.includes(item.id),\n        );\n        yOffset = (itemIndex - firstSelectedIndex) * 80; // Adjust based on actual height\n      }\n\n      setDragOffset({ x: 0, y: yOffset });\n\n      if (!isSelected) {\n        setSelectedItems([...selectedItems, itemId]);\n      }\n    },\n    [],\n  );\n\n  const handleDragStart = useCallback(\n    (event: DragStartEvent) => {\n      setIsDragging(true);\n      setParentFolderId(event.active.data.current?.parentFolderId);\n\n      const { type } = event.active.data.current ?? {};\n      const itemId = event.active.id as string;\n\n      if (type === \"document\") {\n        handleDragForType(\n          itemId,\n          documents,\n          setDraggedDocument,\n          selectedDocuments,\n          setSelectedDocuments,\n        );\n      }\n\n      if (type === \"folder\") {\n        handleDragForType(\n          itemId,\n          folders,\n          setDraggedFolder,\n          selectedFolders,\n          setSelectedFolders,\n        );\n      }\n    },\n    [handleDragForType, documents, folders, selectedDocuments, selectedFolders],\n  );\n\n  const handleDragOver = (event: DragOverEvent) => {\n    const { over } = event;\n\n    if (!over) return;\n\n    const overType = over.data.current?.type;\n    if (overType === \"folder\") {\n      setIsOverFolder(true);\n    } else {\n      setIsOverFolder(false);\n    }\n  };\n\n  const moveDocumentsAndFolders = async ({\n    documentsToMove,\n    foldersToMove,\n    overId,\n    folderPathName,\n    teamId,\n    selectedFolderPath,\n  }: {\n    documentsToMove: string[];\n    foldersToMove: string[];\n    overId: UniqueIdentifier;\n    folderPathName: string[] | undefined;\n    teamId: string;\n    selectedFolderPath: string;\n  }) => {\n    return new Promise(async (resolve, reject) => {\n      try {\n        if (documentsToMove && documentsToMove.length > 0) {\n          await moveDocumentToFolder({\n            documentIds: documentsToMove,\n            folderId: overId.toString(),\n            folderPathName,\n            teamId: teamId,\n            folderIds: foldersToMove,\n          });\n        }\n        if (foldersToMove && foldersToMove.length > 0) {\n          await moveFolderToFolder({\n            folderIds: foldersToMove,\n            folderPathName: folderPathName ? folderPathName : undefined,\n            teamId: teamId,\n            selectedFolder: overId.toString(),\n            selectedFolderPath: selectedFolderPath,\n          });\n        }\n\n        resolve(\"Successfully moved documents and folders.\");\n      } catch (error) {\n        reject(\n          error instanceof Error\n            ? error.message\n            : \"Failed to move documents and folders.\",\n        );\n      }\n    });\n  };\n\n  const handleDragEnd = async (event: DragEndEvent) => {\n    setIsDragging(false);\n    const { active, over } = event;\n    setDraggedDocument(null);\n    setDraggedFolder(null);\n\n    if (!over) return;\n    const activeId = active.id;\n    const overId = over.id;\n    if (selectedFolders.includes(overId.toString())) {\n      return toast.error(\n        \"Can not move folder and documents into selected folders\",\n      );\n    }\n    const isActiveADocument = active.data.current?.type === \"document\";\n    const isActiveAFolder = active.data.current?.type === \"folder\";\n    const isOverAFolder = over.data.current?.type === \"folder\";\n    if (activeId === overId) return;\n    if (isActiveADocument && !isOverAFolder) return;\n    if (isActiveAFolder && !isOverAFolder) return;\n\n    // Move the document(s) to the new folder\n    const documentsToMove =\n      selectedDocumentsLength > 0 ? selectedDocuments : [];\n    // Move the folder(s) to the new folder\n    const foldersToMove = selectedFoldersLength > 0 ? selectedFolders : [];\n\n    toast.promise(\n      moveDocumentsAndFolders({\n        documentsToMove: documentsToMove,\n        foldersToMove: foldersToMove,\n        overId: overId,\n        folderPathName: folderPathName,\n        teamId: teamInfo?.currentTeam?.id!,\n        selectedFolderPath: over.data.current?.path,\n      }),\n      {\n        loading: itemsMessage(documentsToMove, foldersToMove, \"Moving\"),\n        success: () =>\n          itemsMessage(documentsToMove, foldersToMove, \"Successfully moved\"),\n        error: (err) => err,\n      },\n    );\n\n    setSelectedDocuments([]);\n    setSelectedFolders([]);\n    setIsOverFolder(false);\n  };\n\n  const handleCloseDrawer = () => {\n    setShowDrawer(false);\n  };\n\n  const resetSelection = () => {\n    setSelectedDocuments([]);\n    setSelectedFolders([]);\n  };\n\n  const [isHiding, setIsHiding] = useState(false);\n\n  const handleBulkHide = useCallback(async () => {\n    if (selectedDocuments.length === 0 && selectedFolders.length === 0) return;\n\n    setIsHiding(true);\n\n    try {\n      const promises: Promise<Response>[] = [];\n\n      // Hide documents\n      if (selectedDocuments.length > 0) {\n        promises.push(\n          fetch(`/api/teams/${teamInfo?.currentTeam?.id}/documents/hide`, {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              documentIds: selectedDocuments,\n              hidden: true,\n            }),\n          }),\n        );\n      }\n\n      // Hide folders (cascades to children)\n      if (selectedFolders.length > 0) {\n        promises.push(\n          fetch(`/api/teams/${teamInfo?.currentTeam?.id}/folders/hide`, {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              folderIds: selectedFolders,\n              hidden: true,\n            }),\n          }),\n        );\n      }\n\n      const results = await Promise.all(promises);\n\n      // Check for errors\n      for (const res of results) {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to hide items\");\n        }\n      }\n\n      // Revalidate data\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders?root=true`);\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders`);\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`);\n\n      if (folderPathName && folderPathName.length > 0) {\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/folders/${folderPathName.join(\"/\")}`,\n        );\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/folders/documents/${folderPathName.join(\"/\")}`,\n        );\n      }\n\n      // Reset selection\n      setSelectedDocuments([]);\n      setSelectedFolders([]);\n\n      toast.success(\n        `Successfully hidden ${selectedDocuments.length > 0 ? `${selectedDocuments.length} document${selectedDocuments.length > 1 ? \"s\" : \"\"}` : \"\"}${selectedDocuments.length > 0 && selectedFolders.length > 0 ? \" and \" : \"\"}${selectedFolders.length > 0 ? `${selectedFolders.length} folder${selectedFolders.length > 1 ? \"s\" : \"\"}` : \"\"} from All Documents`,\n      );\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to hide items\",\n      );\n    } finally {\n      setIsHiding(false);\n    }\n  }, [selectedDocuments, selectedFolders, teamInfo?.currentTeam?.id, folderPathName]);\n\n  const HeaderContent = memo(() => {\n    if (selectedDocumentsLength > 0 || selectedFoldersLength > 0) {\n      const totalItems = (documents?.length || 0) + (folders?.length || 0);\n      const isAllSelected = totalItems === totalSelectedItem;\n\n      const handleSelectAll = () => {\n        if (isAllSelected) {\n          setSelectedDocuments([]);\n          setSelectedFolders([]);\n        } else {\n          const allDocumentIds = documents?.map((doc) => doc.id) || [];\n          const allFolderIds = folders?.map((folder) => folder.id) || [];\n          setSelectedDocuments(allDocumentIds);\n          setSelectedFolders(allFolderIds);\n        }\n      };\n\n      return (\n        <div className=\"mb-2 flex items-center gap-x-1 rounded-3xl bg-gray-100 text-sm text-foreground dark:bg-gray-800\">\n          <div className=\"ml-5 flex h-8 w-8 items-center justify-center rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\">\n            <ButtonTooltip\n              content={isAllSelected ? \"Deselect all\" : \"Select all\"}\n            >\n              <Checkbox\n                id=\"select-all\"\n                checked={isAllSelected}\n                onCheckedChange={handleSelectAll}\n                className=\"h-5 w-5\"\n                aria-label={isAllSelected ? \"Deselect all\" : \"Select all\"}\n              />\n            </ButtonTooltip>\n          </div>\n          <ButtonTooltip content=\"Clear selection\">\n            <Button\n              onClick={resetSelection}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <XIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n          {selectedDocumentsLength ? (\n            <div className=\"mr-2 tabular-nums\">\n              {selectedDocumentsLength} document\n              {selectedDocumentsLength > 1 ? \"s\" : \"\"} selected\n            </div>\n          ) : null}\n          {selectedFoldersLength ? (\n            <div className=\"mr-2 tabular-nums\">\n              {selectedFoldersLength} folder\n              {selectedFoldersLength > 1 ? \"s\" : \"\"} selected\n            </div>\n          ) : null}\n          <ButtonTooltip content=\"Move\">\n            <Button\n              onClick={() => setMoveFolderOpen(true)}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <FolderInputIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n          <ButtonTooltip content=\"Hide from All Documents\">\n            <Button\n              onClick={handleBulkHide}\n              disabled={isHiding}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <EyeOffIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n          <ButtonTooltip content=\"Delete\">\n            <Button\n              onClick={() => setShowDeleteItemsModal(true)}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-destructive hover:text-destructive-foreground\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <Trash2Icon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n        </div>\n      );\n    } else {\n      return (\n        <div className=\"mb-2 flex items-center gap-x-2 pt-5\">\n          {folders && folders.length > 0 && (\n            <p className=\"flex items-center gap-x-1 text-sm text-gray-400\">\n              <FolderIcon className=\"h-5 w-5\" />\n              <span>\n                {folders.length} folder{folders.length > 1 ? \"s\" : \"\"}\n              </span>\n            </p>\n          )}\n          {documents && documents.length > 0 && (\n            <p className=\"flex items-center gap-x-1 text-sm text-gray-400\">\n              <FileIcon className=\"h-5 w-5\" />\n              <span>\n                {documents.length} document{documents.length > 1 ? \"s\" : \"\"}\n              </span>\n            </p>\n          )}\n        </div>\n      );\n    }\n  });\n  HeaderContent.displayName = \"HeaderContent\";\n\n  return (\n    <>\n      <UploadZone\n        folderPathName={folderPathName?.join(\"/\")}\n        onUploadStart={(newUploads) => {\n          setUploads((prevUploads) => [...prevUploads, ...newUploads]);\n          setShowDrawer(true);\n        }}\n        onUploadProgress={(index, progress, documentId) => {\n          setUploads((prevUploads) => {\n            const recentBatchStartIndex = prevUploads.length - index - 1;\n            if (\n              recentBatchStartIndex < 0 ||\n              recentBatchStartIndex >= prevUploads.length\n            ) {\n              return prevUploads;\n            }\n            return prevUploads.map((upload, i) =>\n              i === recentBatchStartIndex\n                ? { ...upload, progress, documentId }\n                : upload,\n            );\n          });\n        }}\n        onUploadRejected={(rejected) => {\n          setRejectedFiles((prevRejected) => [...prevRejected, ...rejected]);\n          setShowDrawer(true);\n        }}\n        setUploads={setUploads}\n        setRejectedFiles={setRejectedFiles}\n      >\n        {isMobile ? (\n          <div className=\"space-y-4\">\n            {/* Folders list */}\n            <ul role=\"list\" className=\"space-y-4\">\n              {folders && !foldersLoading\n                ? folders.map((folder) => {\n                    return (\n                      <li key={folder.id}>\n                        <FolderCard\n                          key={folder.id}\n                          folder={folder}\n                          teamInfo={teamInfo}\n                          isSelected={selectedFolders.includes(folder.id)}\n                          isDragging={\n                            isDragging && selectedFolders.includes(folder.id)\n                          }\n                          onDelete={handleDeleteFolder}\n                        />\n                      </li>\n                    );\n                  })\n                : Array.from({ length: 3 }).map((_, i) => (\n                    <li\n                      key={i}\n                      className=\"relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6\"\n                    >\n                      <Skeleton key={i} className=\"h-9 w-9\" />\n                      <div>\n                        <Skeleton key={i} className=\"h-4 w-32\" />\n                        <Skeleton key={i + 1} className=\"mt-2 h-3 w-12\" />\n                      </div>\n                      <Skeleton\n                        key={i + 1}\n                        className=\"absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform\"\n                      />\n                    </li>\n                  ))}\n            </ul>\n\n            {/* Documents list */}\n            <ul role=\"list\" className=\"space-y-4\">\n              {documents && !loading\n                ? documents.map((document) => {\n                    return (\n                      <li key={document.id}>\n                        <DocumentCard\n                          key={document.id}\n                          document={document}\n                          teamInfo={teamInfo}\n                          isDragging={\n                            isDragging &&\n                            selectedDocuments.includes(document.id)\n                          }\n                        />\n                      </li>\n                    );\n                  })\n                : Array.from({ length: 3 }).map((_, i) => (\n                    <li\n                      key={i}\n                      className=\"relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6\"\n                    >\n                      <Skeleton key={i} className=\"h-9 w-9\" />\n                      <div>\n                        <Skeleton key={i} className=\"h-4 w-32\" />\n                        <Skeleton key={i + 1} className=\"mt-2 h-3 w-12\" />\n                      </div>\n                      <Skeleton\n                        key={i + 1}\n                        className=\"absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform\"\n                      />\n                    </li>\n                  ))}\n            </ul>\n\n            <Portal containerId={\"documents-header-count\"}>\n              <HeaderContent />\n            </Portal>\n\n            {documents && documents.length === 0 && !loading && (\n              <div className=\"flex items-center justify-center\">\n                <EmptyDocuments />\n              </div>\n            )}\n          </div>\n        ) : (\n          <>\n            <DndContext\n              sensors={sensors}\n              onDragStart={handleDragStart}\n              onDragOver={handleDragOver}\n              onDragEnd={handleDragEnd}\n              onDragCancel={() => setIsOverFolder(false)}\n              measuring={{\n                droppable: {\n                  strategy: MeasuringStrategy.Always,\n                },\n              }}\n            >\n              <div className=\"space-y-4\">\n                {/* Folders list */}\n                <ul role=\"list\" className=\"space-y-4\">\n                  {folders && !foldersLoading\n                    ? folders.map((folder) => {\n                        return (\n                          <li key={folder.id}>\n                            <DroppableFolder\n                              key={folder.id}\n                              id={folder.id}\n                              disabledFolder={selectedFolders}\n                              path={folder.path}\n                            >\n                              <DraggableItem\n                                key={folder.id}\n                                id={folder.id}\n                                isSelected={selectedFolders.includes(folder.id)}\n                                onSelect={(id, type) => {\n                                  handleSelect(id, type);\n                                }}\n                                isDraggingSelected={isDragging}\n                                type=\"folder\"\n                              >\n                                <FolderCard\n                                  key={folder.id}\n                                  folder={folder}\n                                  teamInfo={teamInfo}\n                                  isSelected={selectedFolders.includes(\n                                    folder.id,\n                                  )}\n                                  isDragging={\n                                    isDragging &&\n                                    selectedFolders.includes(folder.id)\n                                  }\n                                  onDelete={handleDeleteFolder}\n                                />\n                              </DraggableItem>\n                            </DroppableFolder>\n                          </li>\n                        );\n                      })\n                    : Array.from({ length: 3 }).map((_, i) => (\n                        <li\n                          key={i}\n                          className=\"relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6\"\n                        >\n                          <Skeleton key={i} className=\"h-9 w-9\" />\n                          <div>\n                            <Skeleton key={i} className=\"h-4 w-32\" />\n                            <Skeleton key={i + 1} className=\"mt-2 h-3 w-12\" />\n                          </div>\n                          <Skeleton\n                            key={i + 1}\n                            className=\"absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform\"\n                          />\n                        </li>\n                      ))}\n                </ul>\n\n                {/* Documents list */}\n                <ul role=\"list\" className=\"space-y-4\">\n                  {documents && !loading\n                    ? documents.map((document) => {\n                        return (\n                          <li key={document.id}>\n                            <DraggableItem\n                              key={document.id}\n                              id={document.id}\n                              isSelected={selectedDocuments.includes(\n                                document.id,\n                              )}\n                              isDraggingSelected={isDragging}\n                              type=\"document\"\n                              onSelect={(id, type) => {\n                                handleSelect(id, type);\n                              }}\n                            >\n                              <DocumentCard\n                                key={document.id}\n                                document={document}\n                                teamInfo={teamInfo}\n                                isDragging={\n                                  isDragging &&\n                                  selectedDocuments.includes(document.id)\n                                }\n                              />\n                            </DraggableItem>\n                          </li>\n                        );\n                      })\n                    : Array.from({ length: 3 }).map((_, i) => (\n                        <li\n                          key={i}\n                          className=\"relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6\"\n                        >\n                          <Skeleton key={i} className=\"h-9 w-9\" />\n                          <div>\n                            <Skeleton key={i} className=\"h-4 w-32\" />\n                            <Skeleton key={i + 1} className=\"mt-2 h-3 w-12\" />\n                          </div>\n                          <Skeleton\n                            key={i + 1}\n                            className=\"absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform\"\n                          />\n                        </li>\n                      ))}\n                </ul>\n\n                <Portal>\n                  <DragOverlay className=\"cursor-default\">\n                    <motion.div\n                      initial={{ scale: 1, opacity: 1 }}\n                      animate={{ scale: 0.9, opacity: 0.95 }}\n                      exit={{ scale: 1, opacity: 1 }}\n                      transition={{ duration: 0.2 }}\n                      className=\"relative\"\n                      style={{ transform: `translateY(${dragOffset.y}px)` }}\n                    >\n                      {draggedDocument ? (\n                        <DocumentCard\n                          document={draggedDocument}\n                          teamInfo={teamInfo}\n                        />\n                      ) : null}\n                      {draggedFolder ? (\n                        <FolderCard\n                          folder={draggedFolder}\n                          teamInfo={teamInfo}\n                          onDelete={handleDeleteFolder}\n                        />\n                      ) : null}\n                      {totalSelectedItem > 1 ? (\n                        <div className=\"absolute -right-4 -top-4 rounded-full border border-border bg-foreground px-4 py-2\">\n                          <span className=\"text-sm font-semibold text-background\">\n                            {totalSelectedItem}\n                          </span>\n                        </div>\n                      ) : null}\n                    </motion.div>\n                  </DragOverlay>\n                </Portal>\n\n                <Portal containerId={\"documents-header-count\"}>\n                  <HeaderContent />\n                </Portal>\n\n                {documents && documents.length === 0 && !loading && (\n                  <div className=\"flex items-center justify-center\">\n                    <EmptyDocuments />\n                  </div>\n                )}\n              </div>\n            </DndContext>\n            {moveFolderOpen ? (\n              <MoveToFolderModal\n                open={moveFolderOpen}\n                setOpen={setMoveFolderOpen}\n                setSelectedDocuments={setSelectedDocuments}\n                documentIds={selectedDocuments}\n                folderIds={selectedFolders}\n                folderParentId={parentFolderId}\n                setSelectedFoldersId={setSelectedFolders}\n              />\n            ) : null}\n          </>\n        )}\n      </UploadZone>\n      {showDrawer ? (\n        <UploadNotificationDrawer\n          open={showDrawer}\n          onOpenChange={setShowDrawer}\n          uploads={uploads}\n          handleCloseDrawer={handleCloseDrawer}\n          setUploads={setUploads}\n          rejectedFiles={rejectedFiles}\n          setRejectedFiles={setRejectedFiles}\n        />\n      ) : null}\n      <DeleteFolderModal />\n      <DeleteItemsModal />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/drag-and-drop/draggable-item.tsx",
    "content": "import React, { useCallback, useState } from \"react\";\n\nimport { useDraggable } from \"@dnd-kit/core\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Checkbox } from \"@/components/ui/checkbox\";\n\ninterface DraggableItemProps {\n  id: string;\n  isSelected: boolean;\n  onSelect: (id: string, type: \"document\" | \"folder\") => void;\n  isDraggingSelected: boolean;\n  children: React.ReactElement;\n  type: \"document\" | \"folder\";\n}\n\nexport function DraggableItem({\n  id,\n  isSelected,\n  onSelect,\n  isDraggingSelected,\n  children,\n  type,\n}: DraggableItemProps) {\n  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n    id: id,\n    data: {\n      type: type,\n      id: id,\n      name:\n        type === \"folder\"\n          ? children.props.folder.name\n          : children.props.document.name,\n      contentType:\n        type === \"folder\"\n          ? children.props.folder.type\n          : children.props.document.type,\n      parentFolderId:\n        type === \"folder\"\n          ? children.props.folder.parentId\n          : children.props.document.folderId,\n    },\n  });\n\n  const [isHovered, setIsHovered] = useState(false);\n\n  const handleClick = useCallback(() => {\n    onSelect(id, type);\n  }, [id, onSelect, type]);\n\n  const style = {\n    opacity: isSelected && isDraggingSelected ? 0.5 : 1,\n    // transform: CSS.Transform.toString(transform),\n  };\n\n  const childWithProps = React.cloneElement(children, {\n    isDragging,\n    isSelected,\n    isHovered,\n  });\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      {...attributes}\n      {...listeners}\n      // onClick={handleClick}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n      className={cn(\n        \"group relative transition-all duration-100\",\n        isSelected ? \"rounded-lg ring-2 ring-black dark:ring-gray-100\" : \"\",\n      )}\n    >\n      {/* <div className=\"absolute left-2 top-3 z-50 hidden h-14 w-14 items-center justify-center bg-secondary group-hover:flex\"> */}\n      <div\n        className={cn(\n          \"absolute left-4 top-6 z-[49] hidden items-center justify-center group-hover:flex sm:left-6 sm:top-7\",\n          isSelected ? \"flex\" : \"\",\n        )}\n      >\n        <Checkbox\n          className=\"h-6 w-6\"\n          checked={isSelected}\n          onCheckedChange={handleClick}\n        />\n      </div>\n      {childWithProps}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/drag-and-drop/droppable-folder.tsx",
    "content": "import React from \"react\";\n\nimport { useDroppable } from \"@dnd-kit/core\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface DroppableFolderProps {\n  id: string;\n  disabledFolder: string[];\n  children: React.ReactElement;\n  path: string;\n}\n\nexport function DroppableFolder({\n  id,\n  disabledFolder,\n  children,\n  path,\n}: DroppableFolderProps) {\n  const { isOver, setNodeRef } = useDroppable({\n    id: id,\n    data: { type: \"folder\", id, path },\n  });\n\n  const childWithProps = React.cloneElement(children, {\n    isOver,\n  });\n\n  return (\n    <div\n      ref={setNodeRef}\n      className={cn(\n        isOver &&\n          !disabledFolder.includes(id) &&\n          \"rounded-lg ring-2 ring-black dark:ring-gray-100\",\n      )}\n    >\n      {childWithProps}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/empty-document.tsx",
    "content": "import { FilePlusIcon, PlusIcon, UploadIcon } from \"lucide-react\";\n\nimport { Button } from \"../ui/button\";\nimport { AddDocumentModal } from \"./add-document-modal\";\n\nexport function EmptyDocuments({\n  isDataroom = false,\n}: {\n  isDataroom?: boolean;\n}) {\n  if (isDataroom) {\n    return (\n      <div className=\"flex w-full items-center justify-center py-8\">\n        <div className=\"flex min-h-[300px] w-full max-w-2xl items-center justify-center rounded-lg border border-dashed border-black/25 dark:border-white/25\">\n          <div className=\"text-center\">\n            <UploadIcon\n              className=\"mx-auto h-10 w-10 text-gray-500\"\n              aria-hidden=\"true\"\n            />\n            <h3 className=\"mt-4 text-sm font-semibold leading-6 text-gray-500\">\n              Drag and Drop for Bulk Upload\n            </h3>\n            <p className=\"mt-1 text-xs leading-5 text-gray-500\">\n              No documents. Get started by uploading document.\n            </p>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"text-center\">\n      <FilePlusIcon\n        className=\"mx-auto h-12 w-12 text-muted-foreground\"\n        strokeWidth={1}\n      />\n      <h3 className=\"mt-2 text-sm font-medium text-foreground\">\n        No documents here\n      </h3>\n      <p className=\"mt-1 text-sm text-muted-foreground\">\n        Get started by uploading a new document.\n      </p>\n      {/* <div className=\"mt-6\">\n        <AddDocumentModal>\n          <Button\n            className=\"w-full flex gap-x-3 items-center justify-center px-3\"\n            title=\"Add New Document\"\n          >\n            <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n            <span>Add Document</span>\n          </Button>\n        </AddDocumentModal>\n      </div> */}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/export-visits-modal.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { Document } from \"@prisma/client\";\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport { ExportJob } from \"@/lib/redis-job-store\";\n\nimport { Button } from \"../ui/button\";\n\ninterface ExportStatus {\n  status: string;\n  progress?: string;\n  exportId?: string;\n  startTime?: number;\n}\n\ninterface ExportVisitsModalProps {\n  document: Document;\n  teamId: string;\n  onClose: () => void;\n}\n\nexport function ExportVisitsModal({\n  document,\n  teamId,\n  onClose,\n}: ExportVisitsModalProps) {\n  const { data: session } = useSession();\n  const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);\n  const [showModal, setShowModal] = useState(false);\n  const [viewCount, setViewCount] = useState<number | null>(null);\n  const [existingExports, setExistingExports] = useState<ExportJob[]>([]);\n  const [showNewExport, setShowNewExport] = useState(false);\n  const [loading, setLoading] = useState(true);\n  const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);\n  const exportStartedRef = useRef<boolean>(false);\n\n  // Cleanup interval on component unmount\n  useEffect(() => {\n    return () => {\n      if (pollIntervalRef.current) {\n        clearInterval(pollIntervalRef.current);\n      }\n    };\n  }, []);\n\n  // Fetch existing exports when modal opens\n  useEffect(() => {\n    const fetchExistingExports = async () => {\n      try {\n        setLoading(true);\n        setShowModal(true);\n\n        const response = await fetch(\n          `/api/teams/${teamId}/documents/${document.id}/export-visits`,\n          { method: \"GET\" },\n        );\n\n        if (response.ok) {\n          const exports = await response.json();\n          setExistingExports(exports);\n        } else {\n          console.error(\"Failed to fetch existing exports\");\n        }\n      } catch (error) {\n        console.error(\"Error fetching existing exports:\", error);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    fetchExistingExports();\n  }, [teamId, document.id]);\n\n  const startNewExport = useCallback(async () => {\n    // Prevent double triggering\n    if (exportStartedRef.current) {\n      console.warn(\"Export already started, skipping duplicate request\");\n      return;\n    }\n    exportStartedRef.current = true;\n    setShowNewExport(true);\n\n    try {\n      // Get view count first\n      try {\n        const viewCountResponse = await fetch(\n          `/api/teams/${teamId}/documents/${document.id}/views-count`,\n          { method: \"GET\" },\n        );\n\n        if (viewCountResponse.ok) {\n          const viewData = await viewCountResponse.json();\n          setViewCount(viewData.count || 0);\n        }\n      } catch (error) {\n        console.error(\"Error fetching view count:\", error);\n        // Continue with export even if view count fails\n      }\n\n      // Trigger the background export job\n      const response = await fetch(\n        `/api/teams/${teamId}/documents/${document.id}/export-visits`,\n        { method: \"POST\" },\n      );\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n      const data = await response.json();\n\n      if (data.exportId) {\n        const startTime = Date.now();\n        setExportStatus({\n          status: \"PROCESSING\",\n          exportId: data.exportId,\n          startTime,\n        });\n\n        // Clear any existing interval\n        if (pollIntervalRef.current) {\n          clearInterval(pollIntervalRef.current);\n        }\n\n        // Start polling\n        pollIntervalRef.current = setInterval(async () => {\n          try {\n            const statusResponse = await fetch(\n              `/api/teams/${teamId}/export-jobs/${data.exportId}`,\n              { method: \"GET\" },\n            );\n\n            if (statusResponse.ok) {\n              const statusData = await statusResponse.json();\n\n              // Update progress\n              setExportStatus((prev) => ({\n                ...prev!,\n                progress: \"Preparing export...\",\n              }));\n\n              if (statusData.status === \"COMPLETED\" && statusData.isReady) {\n                if (pollIntervalRef.current) {\n                  clearInterval(pollIntervalRef.current);\n                  pollIntervalRef.current = null;\n                }\n\n                // Create a direct link to the API endpoint (it will redirect to blob)\n                const downloadUrl = `/api/teams/${teamId}/export-jobs/${data.exportId}?download=true`;\n                try {\n                  const link = window.document.createElement(\"a\");\n                  link.href = downloadUrl;\n                  link.setAttribute(\n                    \"download\",\n                    `${statusData.resourceName || document.name}_visits_${new Date().toISOString().split(\"T\")[0]}.csv`,\n                  );\n                  link.rel = \"noopener noreferrer\";\n                  link.style.display = \"none\";\n\n                  window.document.body.appendChild(link);\n                  link.click();\n                  window.document.body.removeChild(link);\n                } catch (error) {\n                  // Fallback: open in new tab if programmatic download fails\n                  window.open(downloadUrl, \"_blank\");\n                  console.error(\"Download failed, opened in new tab:\", error);\n                }\n\n                handleClose();\n                toast.success(\"Export successfully downloaded\");\n              } else if (statusData.status === \"FAILED\") {\n                if (pollIntervalRef.current) {\n                  clearInterval(pollIntervalRef.current);\n                  pollIntervalRef.current = null;\n                }\n                handleClose();\n                toast.error(\n                  `Export failed: ${statusData.error || \"Unknown error\"}`,\n                );\n              }\n            }\n          } catch (error) {\n            console.error(\"Error polling export status:\", error);\n          }\n        }, 5000); // Poll every 5 seconds\n\n        // Clear interval after 10 minutes to prevent indefinite polling\n        setTimeout(() => {\n          if (pollIntervalRef.current) {\n            clearInterval(pollIntervalRef.current);\n            pollIntervalRef.current = null;\n          }\n          handleClose();\n        }, 600000);\n      }\n    } catch (error) {\n      console.error(\"Error:\", error);\n      toast.error(\n        \"An error occurred while starting the export. Please try again.\",\n      );\n      handleClose();\n    }\n  }, [document.id, teamId, document.name]);\n\n  // Send export via email\n  const sendExportEmail = async () => {\n    if (!exportStatus?.exportId || !session?.user?.email) return;\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/export-jobs/${exportStatus.exportId}/send-email`,\n        { method: \"POST\" },\n      );\n\n      if (response.ok) {\n        toast.success(\"Export will be sent to your email when ready\");\n        handleClose();\n      } else {\n        toast.error(\"Failed to setup email notification\");\n      }\n    } catch (error) {\n      console.error(\"Error sending email:\", error);\n      toast.error(\"Failed to setup email notification\");\n    }\n  };\n\n  // Cancel export\n  const cancelExport = async () => {\n    if (!exportStatus?.exportId) return;\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,\n        { method: \"PATCH\" },\n      );\n\n      if (response.ok) {\n        toast.success(\"Export cancelled successfully\");\n        handleClose();\n      } else {\n        const errorData = await response.json();\n        toast.error(errorData.error || \"Failed to cancel export\");\n      }\n    } catch (error) {\n      console.error(\"Error cancelling export:\", error);\n      toast.error(\"Failed to cancel export\");\n    }\n  };\n\n  // Download existing export\n  const downloadExport = async (exportId: string, resourceName: string) => {\n    try {\n      const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;\n      const link = window.document.createElement(\"a\");\n      link.href = downloadUrl;\n      link.setAttribute(\n        \"download\",\n        `${resourceName || document.name}_visits_${new Date().toISOString().split(\"T\")[0]}.csv`,\n      );\n      link.rel = \"noopener noreferrer\";\n      link.style.display = \"none\";\n\n      window.document.body.appendChild(link);\n      link.click();\n      window.document.body.removeChild(link);\n    } catch (error) {\n      console.error(\"Download failed:\", error);\n      toast.error(\"Failed to download export\");\n    }\n  };\n\n  // Format date for display\n  const formatDate = (dateString: string) => {\n    return new Date(dateString).toLocaleString();\n  };\n\n  // Get status badge color\n  const getStatusColor = (status: string) => {\n    switch (status) {\n      case \"COMPLETED\":\n        return \"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300\";\n      case \"PROCESSING\":\n        return \"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300\";\n      case \"PENDING\":\n        return \"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300\";\n      case \"FAILED\":\n        return \"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300\";\n      default:\n        return \"bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300\";\n    }\n  };\n\n  // Handle close - cleanup and call parent onClose\n  const handleClose = () => {\n    if (pollIntervalRef.current) {\n      clearInterval(pollIntervalRef.current);\n      pollIntervalRef.current = null;\n    }\n    setShowModal(false);\n    setExportStatus(null);\n    setShowNewExport(false);\n    setExistingExports([]); // Reset existing exports\n    setViewCount(null); // Reset view count\n    setLoading(true); // Reset loading state\n    exportStartedRef.current = false; // Reset for potential reuse\n    onClose();\n  };\n\n  // Don't render anything if not visible and no modal to show\n  if (!showModal) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50\">\n      <div className=\"mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800\">\n        <div className=\"mb-4 flex items-center justify-between\">\n          <h3 className=\"text-lg font-semibold\">Export Views</h3>\n          <button\n            onClick={handleClose}\n            className=\"text-gray-400 hover:text-gray-600\"\n          >\n            <svg\n              className=\"h-5 w-5\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth={2}\n                d=\"M6 18L18 6M6 6l12 12\"\n              />\n            </svg>\n          </button>\n        </div>\n\n        {loading ? (\n          <div className=\"flex items-center justify-center py-8\">\n            <div className=\"h-8 w-8 animate-spin rounded-full border-b-2 border-primary\"></div>\n          </div>\n        ) : showNewExport ? (\n          // Show export progress\n          <div className=\"space-y-4\">\n            <div className=\"flex items-center space-x-2\">\n              <div className=\"h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground\"></div>\n              <span className=\"text-sm text-gray-600\">\n                {exportStatus?.progress || \"Processing export...\"}\n              </span>\n            </div>\n\n            {viewCount !== null && (\n              <div className=\"text-sm text-gray-600\">\n                Found {viewCount} view{viewCount !== 1 ? \"s\" : \"\"} to export\n              </div>\n            )}\n\n            {viewCount !== null && viewCount > 10 && session?.user?.email && (\n              <div className=\"rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900\">\n                <p className=\"mb-2 font-medium text-muted-foreground\">\n                  Large export detected ({viewCount} views)\n                </p>\n                <p className=\"mb-3 text-muted-foreground\">\n                  This export may take several minutes. We recommend getting it\n                  emailed to you when ready.\n                </p>\n                <button\n                  onClick={sendExportEmail}\n                  className=\"w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80\"\n                >\n                  Email to {session.user.email}\n                </button>\n              </div>\n            )}\n\n            {(!viewCount || viewCount <= 10) && (\n              <div className=\"text-sm text-gray-500\">\n                Your export will be ready shortly...\n              </div>\n            )}\n\n            {/* Cancel button - only show if export is in progress */}\n            {exportStatus?.exportId && (\n              <div className=\"flex gap-2 px-3\">\n                <Button\n                  onClick={cancelExport}\n                  variant=\"outline\"\n                  size=\"sm\"\n                  className=\"flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950\"\n                >\n                  Cancel Export\n                </Button>\n              </div>\n            )}\n          </div>\n        ) : (\n          // Show existing exports and new export option\n          <div className=\"space-y-4\">\n            <div className=\"text-sm text-gray-600\">\n              Exports for: {document.name}\n            </div>\n\n            {existingExports.length > 0 ? (\n              <div className=\"space-y-3\">\n                <h4 className=\"text-sm font-medium text-gray-900 dark:text-gray-100\">\n                  Recent Exports\n                </h4>\n                <div className=\"max-h-48 space-y-2 overflow-y-auto\">\n                  {existingExports.map((exportJob) => (\n                    <div\n                      key={exportJob.id}\n                      className=\"flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700\"\n                    >\n                      <div className=\"flex-1\">\n                        <div className=\"flex items-center gap-2\">\n                          <span\n                            className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}\n                          >\n                            {exportJob.status}\n                          </span>\n                          <span className=\"text-xs text-gray-500\">\n                            {formatDate(exportJob.createdAt)}\n                          </span>\n                        </div>\n                        {exportJob.error && (\n                          <p className=\"mt-1 text-xs text-red-600\">\n                            {exportJob.error}\n                          </p>\n                        )}\n                      </div>\n                      {exportJob.status === \"COMPLETED\" && exportJob.result && (\n                        <button\n                          onClick={() =>\n                            downloadExport(\n                              exportJob.id,\n                              exportJob.resourceName || document.name,\n                            )\n                          }\n                          className=\"ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80\"\n                        >\n                          Download\n                        </button>\n                      )}\n                    </div>\n                  ))}\n                </div>\n              </div>\n            ) : (\n              <div className=\"text-center text-sm text-gray-500\">\n                No previous exports found\n              </div>\n            )}\n\n            <div className=\"border-t border-gray-200 pt-4 dark:border-gray-700\">\n              <button\n                onClick={startNewExport}\n                className=\"w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80\"\n              >\n                Start New Export\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/file-process-status-bar.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport useSWRImmutable from \"swr/immutable\";\n\nimport { Progress } from \"@/components/ui/progress\";\n\nimport { cn, fetcher } from \"@/lib/utils\";\nimport { useDocumentProgressStatus } from \"@/lib/utils/use-progress-status\";\n\nconst QUEUED_MESSAGES = [\n  \"Converting document...\",\n  \"Optimizing for viewing...\",\n  \"Preparing preview...\",\n  \"Almost ready...\",\n];\n\nexport default function FileProcessStatusBar({\n  documentVersionId,\n  className,\n  mutateDocument,\n  onProcessingChange,\n}: {\n  documentVersionId: string;\n  className?: string;\n  mutateDocument: () => void;\n  onProcessingChange?: (processing: boolean) => void;\n}) {\n  const [messageIndex, setMessageIndex] = useState(0);\n  const { data } = useSWRImmutable<{ publicAccessToken: string }>(\n    `/api/progress-token?documentVersionId=${documentVersionId}`,\n    fetcher,\n  );\n\n  const { status: progressStatus, error: progressError } =\n    useDocumentProgressStatus(documentVersionId, data?.publicAccessToken);\n\n  // Update processing state whenever status changes\n  useEffect(() => {\n    if (onProcessingChange) {\n      onProcessingChange(\n        progressStatus.state === \"QUEUED\" ||\n          progressStatus.state === \"EXECUTING\",\n      );\n    }\n  }, [progressStatus.state, onProcessingChange]);\n\n  // Cycle through messages when queued or executing\n  useEffect(() => {\n    let interval: NodeJS.Timeout;\n\n    if (progressStatus.state === \"QUEUED\") {\n      interval = setInterval(() => {\n        setMessageIndex((current) => (current + 1) % QUEUED_MESSAGES.length);\n      }, 5000); // Change message every 5 seconds\n    }\n\n    return () => {\n      if (interval) clearInterval(interval);\n    };\n  }, [progressStatus.state]);\n\n  if (progressStatus.state === \"QUEUED\" && !progressError) {\n    return (\n      <Progress\n        value={0}\n        text={QUEUED_MESSAGES[messageIndex]}\n        className={cn(\n          \"w-full rounded-none text-[8px] font-semibold\",\n          className,\n        )}\n      />\n    );\n  }\n\n  if (\n    progressError ||\n    [\"FAILED\", \"CRASHED\", \"CANCELED\", \"SYSTEM_FAILURE\"].includes(\n      progressStatus.state,\n    )\n  ) {\n    return (\n      <Progress\n        value={0}\n        text={\n          progressError?.message ||\n          progressStatus.text ||\n          \"Error processing document\"\n        }\n        error={true}\n        className={cn(\n          \"w-full rounded-none text-[8px] font-semibold\",\n          className,\n        )}\n      />\n    );\n  }\n\n  if (progressStatus.state === \"COMPLETED\") {\n    mutateDocument();\n    return null;\n  }\n\n  // For EXECUTING state\n  return (\n    <Progress\n      value={progressStatus.progress || 0}\n      text={progressStatus.text || \"Processing document...\"}\n      className={cn(\"w-full rounded-none text-[8px] font-semibold\", className)}\n    />\n  );\n}\n"
  },
  {
    "path": "components/documents/filters/sort-button.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport {\n  ArrowDownAZ,\n  ArrowDownWideNarrowIcon,\n  CalendarArrowDownIcon,\n  CheckIcon,\n  ClockArrowDownIcon,\n  Eye,\n  Link,\n  XCircleIcon,\n} from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport default function SortButton() {\n  const router = useRouter();\n  const [sortBy, setSortBy] = useState<string | null>(null);\n\n  useEffect(() => {\n    const { sort } = router.query;\n    if (\n      sort &&\n      [\"name\", \"createdAt\", \"views\", \"lastViewed\", \"links\"].includes(\n        sort as string,\n      )\n    ) {\n      setSortBy(sort as string);\n    } else {\n      setSortBy(null);\n    }\n  }, [router.query]);\n\n  useEffect(() => {\n    const currentQuery = { ...router.query };\n\n    if (sortBy === null) {\n      delete currentQuery.sort;\n      delete currentQuery.page;\n      delete currentQuery.limit;\n    } else {\n      currentQuery.sort = sortBy;\n    }\n\n    router.push(\n      {\n        pathname: router.pathname,\n        query: currentQuery,\n      },\n      undefined,\n      { shallow: true },\n    );\n  }, [sortBy]);\n\n  const resetSort = () => setSortBy(null);\n\n  const getSortLabel = () => {\n    switch (sortBy) {\n      case \"createdAt\":\n        return \"Date Added\";\n      case \"lastViewed\":\n        return \"Recently Viewed\";\n      case \"views\":\n        return \"Number of Views\";\n      case \"name\":\n        return \"Name\";\n      case \"links\":\n        return \"Number of Links\";\n      default:\n        return \"\";\n    }\n  };\n\n  return (\n    <div className=\"relative inline-block\">\n      <DropdownMenu>\n        <DropdownMenuTrigger asChild>\n          <Button\n            size={sortBy === null ? \"icon\" : \"default\"}\n            variant={\"outline\"}\n            title=\"Sort documents\"\n            className={cn(\n              \"space-x-2\",\n              sortBy !== null && \"border-2 border-foreground\",\n            )}\n          >\n            <ArrowDownWideNarrowIcon className=\"h-4 w-4\" />\n            {sortBy !== null && (\n              <span className=\"font-medium\">{getSortLabel()}</span>\n            )}\n            <span className=\"sr-only\">Sort documents</span>\n          </Button>\n        </DropdownMenuTrigger>\n        <DropdownMenuContent className=\"w-48\" align=\"end\">\n          <DropdownMenuLabel>Sort by</DropdownMenuLabel>\n          <DropdownMenuItem\n            onClick={() => setSortBy(\"name\")}\n            disabled={sortBy === \"name\"}\n          >\n            <ArrowDownAZ className=\"mr-2 h-4 w-4\" />\n            Name\n            {sortBy === \"name\" && <CheckIcon className=\"ml-auto h-4 w-4\" />}\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setSortBy(\"createdAt\")}\n            disabled={sortBy === \"createdAt\"}\n          >\n            <CalendarArrowDownIcon className=\"mr-2 h-4 w-4\" />\n            Date Added\n            {sortBy === \"createdAt\" && (\n              <CheckIcon className=\"ml-auto h-4 w-4\" />\n            )}\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setSortBy(\"lastViewed\")}\n            disabled={sortBy === \"lastViewed\"}\n          >\n            <ClockArrowDownIcon className=\"mr-2 h-4 w-4\" />\n            Recently Viewed\n            {sortBy === \"lastViewed\" && (\n              <CheckIcon className=\"ml-auto h-4 w-4\" />\n            )}\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setSortBy(\"views\")}\n            disabled={sortBy === \"views\"}\n          >\n            <Eye className=\"mr-2 h-4 w-4\" />\n            Number of Views\n            {sortBy === \"views\" && <CheckIcon className=\"ml-auto h-4 w-4\" />}\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onClick={() => setSortBy(\"links\")}\n            disabled={sortBy === \"links\"}\n          >\n            <Link className=\"mr-2 h-4 w-4\" />\n            Number of Links\n            {sortBy === \"links\" && <CheckIcon className=\"ml-auto h-4 w-4\" />}\n          </DropdownMenuItem>\n          {sortBy !== null && (\n            <DropdownMenuItem\n              onClick={resetSort}\n              className=\"text-destructive focus:bg-destructive/80\"\n            >\n              <XCircleIcon className=\"mr-2 h-4 w-4\" />\n              Reset Sort\n            </DropdownMenuItem>\n          )}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/folder-card.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  BetweenHorizontalStartIcon,\n  ChevronRight,\n  ClipboardCopyIcon,\n  CopyIcon,\n  EyeOffIcon,\n  FolderIcon,\n  FolderInputIcon,\n  FolderPenIcon,\n  MoreVertical,\n  PackagePlusIcon,\n  TrashIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { getFolderColorClasses, getFolderIcon } from \"@/lib/constants/folder-constants\";\nimport { DataroomFolderWithCount } from \"@/lib/swr/use-dataroom\";\nimport { FolderWithCount, FolderWithCountAndPath } from \"@/lib/swr/use-documents\";\nimport { getBreadcrumbPath, timeAgo } from \"@/lib/utils\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  useHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { MoveToDataroomFolderModal } from \"../datarooms/move-dataroom-folder-modal\";\nimport { EditFolderModal } from \"../folders/edit-folder-modal\";\nimport { AddFolderToDataroomModal } from \"./add-folder-to-dataroom-modal\";\nimport { MoveToFolderModal } from \"./move-folder-modal\";\n\ntype FolderCardProps = {\n  folder: FolderWithCount | FolderWithCountAndPath | DataroomFolderWithCount;\n  teamInfo: TeamContextType | null;\n  isDataroom?: boolean;\n  dataroomId?: string;\n  isDragging?: boolean;\n  isOver?: boolean;\n  isHovered?: boolean;\n  isSelected?: boolean;\n  onDelete?: (folderId: string) => void;\n};\n\nexport default function FolderCard({\n  folder,\n  teamInfo,\n  isDataroom,\n  dataroomId,\n  isDragging,\n  isOver,\n  isSelected,\n  isHovered,\n  onDelete,\n}: FolderCardProps) {\n  const router = useRouter();\n  const queryParams = router.query;\n  const searchQuery = queryParams[\"search\"];\n  const sortQuery = queryParams[\"sort\"];\n  const folderList = \"folderList\" in folder ? folder.folderList : undefined;\n  const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);\n  const [openFolder, setOpenFolder] = useState<boolean>(false);\n  const [menuOpen, setMenuOpen] = useState<boolean>(false);\n  const [addDataroomOpen, setAddDataroomOpen] = useState<boolean>(false);\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n\n  // Get hierarchical display name for dataroom folders\n  const displayName = useHierarchicalDisplayName(\n    folder.name,\n    isDataroom && \"hierarchicalIndex\" in folder\n      ? folder.hierarchicalIndex\n      : undefined,\n  );\n\n  const folderPath =\n    isDataroom && dataroomId\n      ? `/datarooms/${dataroomId}/documents${folder.path}`\n      : `/documents/tree${folder.path}`;\n  const parentFolderPath = folder.path.substring(\n    0,\n    folder.path.lastIndexOf(\"/\"),\n  );\n\n  // https://github.com/radix-ui/primitives/issues/1241#issuecomment-1888232392\n  useEffect(() => {\n    if (!openFolder || !addDataroomOpen) {\n      setTimeout(() => {\n        document.body.style.pointerEvents = \"\";\n      });\n    }\n  }, [openFolder, addDataroomOpen]);\n\n  const handleCreateDataroom = (e: any, folderId: string) => {\n    e.stopPropagation();\n    e.preventDefault();\n\n    toast.promise(\n      fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/create-from-folder`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            folderId: folderId,\n          }),\n        },\n      ).then(async (response) => {\n        if (!response.ok) {\n          const errorData = await response.json();\n          throw new Error(\n            errorData.message || \"An error occurred while creating dataroom.\",\n          );\n        }\n        return response.json();\n      }),\n      {\n        loading: \"Creating dataroom...\",\n        success: (data) => {\n          toast.dismiss();\n          setMenuOpen(false);\n          mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);\n          mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);\n          toast.success(`Successfully created!`, {\n            description: `${folder.name} → ${data.name}`,\n            action: {\n              label: \"Open Dataroom\",\n              onClick: () => router.push(`/datarooms/${data.id}/documents`),\n            },\n            duration: 10000,\n          });\n          return null;\n        },\n        error: (error) => {\n          return error.message;\n        },\n      },\n    );\n  };\n\n  const handleCardClick = (e: React.MouseEvent) => {\n    if (isDragging || menuOpen) {\n      e.preventDefault();\n      e.stopPropagation();\n      return;\n    }\n    router.push(folderPath);\n  };\n\n  const handleHideFolder = async (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    toast.promise(\n      fetch(`/api/teams/${teamInfo?.currentTeam?.id}/folders/hide`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          folderIds: [folder.id],\n          hidden: true,\n        }),\n      }).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to hide folder\");\n        }\n        // Revalidate the folders and documents\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders?root=true`);\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders`);\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/folders${parentFolderPath}`,\n        );\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`);\n        setMenuOpen(false);\n      }),\n      {\n        loading: \"Hiding folder from All Documents...\",\n        success: \"Folder hidden from All Documents.\",\n        error: (err) => err.message || \"Failed to hide folder. Try again.\",\n      },\n    );\n  };\n\n  return (\n    <>\n      <div\n        onClick={handleCardClick}\n        className=\"group/row relative flex items-center justify-between rounded-lg border-0 bg-white p-3 ring-1 ring-gray-400 transition-all hover:bg-secondary hover:ring-gray-500 dark:bg-secondary dark:ring-gray-500 hover:dark:ring-gray-400 sm:p-4\"\n      >\n        <div className=\"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\">\n          {!isSelected && !isHovered ? (\n            <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n              {(() => {\n                const FolderIconComponent = getFolderIcon(folder.icon);\n                const colorClasses = getFolderColorClasses(folder.color);\n                return (\n                  <FolderIconComponent\n                    className={`h-8 w-8 ${colorClasses.iconClass}`}\n                    strokeWidth={1}\n                  />\n                );\n              })()}\n            </div>\n          ) : (\n            <div className=\"mx-0.5 w-8 sm:mx-1\"></div>\n          )}\n\n          <div className=\"flex-col\">\n            <div className=\"flex items-center\">\n              <h2\n                className=\"min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md\"\n                style={HIERARCHICAL_DISPLAY_STYLE}\n              >\n                {displayName}\n              </h2>\n            </div>\n            <div className=\"mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground\">\n              <p className=\"truncate\">{timeAgo(folder.createdAt)}</p>\n              <p>•</p>\n              <p className=\"truncate\">\n                {folder._count.documents}{\" \"}\n                {folder._count.documents === 1 ? \"Document\" : \"Documents\"}\n              </p>\n              <p>•</p>\n              <p className=\"truncate\">\n                {folder._count.childFolders}{\" \"}\n                {folder._count.childFolders === 1 ? \"Folder\" : \"Folders\"}\n              </p>\n            </div>\n            {searchQuery && folderList !== undefined ? (\n              <div className=\"relative z-10 mt-1 flex flex-wrap items-center space-x-1 text-xs leading-5 text-muted-foreground\">\n                {getBreadcrumbPath(folderList).map((segment, index) => (\n                  <p\n                    className=\"inset-2 flex items-center gap-x-1 truncate\"\n                    key={segment.pathLink}\n                  >\n                    {index !== 0 && <ChevronRight className=\"h-3 w-3\" />}\n                    <FolderIcon className=\"h-3 w-3\" />\n                    <Link\n                      href={segment.pathLink}\n                      onClick={(e) => e.stopPropagation()}\n                      className=\"relative z-10 hover:underline\"\n                    >\n                      {segment.name}\n                    </Link>\n                  </p>\n                ))}\n                <p className=\"inset-2 flex items-center gap-x-1 truncate\">\n                  <ChevronRight className=\"h-3 w-3\" />\n                  <FolderIcon className=\"h-3 w-3\" />\n                  <Link\n                    href={folderPath}\n                    onClick={(e) => e.stopPropagation()}\n                    className=\"relative z-10 hover:underline\"\n                  >\n                    {folder.name}\n                  </Link>\n                </p>\n              </div>\n            ) : null}\n          </div>\n        </div>\n\n        <div className=\"flex flex-row space-x-2\">\n          {/* <Link\n          onClick={(e) => {\n            e.stopPropagation();\n          }}\n          href={`/documents/${prismaDocument.id}`}\n          className=\"flex items-center z-10 space-x-1 rounded-md bg-gray-200 dark:bg-gray-700 px-1.5 sm:px-2 py-0.5 transition-all duration-75 hover:scale-105 active:scale-100\"\n        >\n          <BarChart className=\"w-3 h-3 sm:h-4 sm:w-4 text-muted-foreground\" />\n          <p className=\"text-xs whitespace-nowrap sm:text-sm text-muted-foreground\">\n            {nFormatter(prismaDocument._count.views)}\n            <span className=\"hidden ml-1 sm:inline-block\">views</span>\n          </p>\n        </Link> */}\n\n          <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n            <DropdownMenuTrigger asChild>\n              <Button\n                onClick={(e) => e.stopPropagation()}\n                variant=\"outline\"\n                className=\"z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n              >\n                <span className=\"sr-only\">Open menu</span>\n                <MoreVertical className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" ref={dropdownRef} className=\"w-64\">\n              <DropdownMenuLabel>Actions</DropdownMenuLabel>\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setOpenFolder(true);\n                }}\n              >\n                <FolderPenIcon className=\"mr-2 h-4 w-4\" />\n                Rename\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setMoveFolderOpen(true);\n                }}\n              >\n                <FolderInputIcon className=\"mr-2 h-4 w-4\" />\n                Move to Folder\n              </DropdownMenuItem>\n              {!isDataroom ? (\n                <DropdownMenuItem\n                  onClick={(e) => handleCreateDataroom(e, folder.id)}\n                >\n                  <PackagePlusIcon className=\"mr-2 h-4 w-4\" />\n                  Create dataroom from folder\n                </DropdownMenuItem>\n              ) : null}\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setAddDataroomOpen(true);\n                }}\n              >\n                <BetweenHorizontalStartIcon className=\"mr-2 h-4 w-4\" />\n                {isDataroom\n                  ? \"Copy folder to other dataroom\"\n                  : \"Add folder to dataroom\"}\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  navigator.clipboard.writeText(folder.id);\n                  toast.success(\"Folder ID copied to clipboard\");\n                }}\n                className=\"group/folderid\"\n              >\n                <CopyIcon className=\"mr-2 h-4 w-4\" />\n                <span className=\"inline group-hover/folderid:hidden\">\n                  Copy Folder ID\n                </span>\n                <span className=\"hidden group-hover/folderid:inline group-hover/folderid:cursor-copy\">\n                  {folder.id}\n                </span>\n              </DropdownMenuItem>\n              {!isDataroom && (\n                <DropdownMenuItem onClick={handleHideFolder}>\n                  <EyeOffIcon className=\"mr-2 h-4 w-4\" />\n                  Hide from All Documents\n                </DropdownMenuItem>\n              )}\n              <DropdownMenuSeparator />\n\n              <DropdownMenuItem\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  onDelete?.(folder.id);\n                  setMenuOpen(false);\n                }}\n                className=\"text-destructive duration-200 focus:bg-destructive focus:text-destructive-foreground\"\n              >\n                <TrashIcon className=\"mr-2 h-4 w-4\" />{\" \"}\n                {isDataroom ? \"Remove Folder\" : \"Delete Folder\"}\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n        {/* only used for drag and drop */}\n        {isOver && !isDragging && (\n          <div className=\"absolute inset-0 flex items-center justify-center rounded-lg bg-black bg-opacity-20 dark:bg-white dark:bg-opacity-20\">\n            <span className=\"font-semibold text-black dark:text-gray-100\">\n              Drop to move\n            </span>\n          </div>\n        )}\n      </div>\n\n      {openFolder ? (\n        <EditFolderModal\n          open={openFolder}\n          setOpen={setOpenFolder}\n          folderId={folder.id}\n          name={folder.name}\n          icon={folder.icon}\n          color={folder.color}\n          isDataroom={isDataroom}\n          dataroomId={dataroomId}\n        />\n      ) : null}\n      {addDataroomOpen ? (\n        <AddFolderToDataroomModal\n          open={addDataroomOpen}\n          setOpen={setAddDataroomOpen}\n          folderId={folder.id}\n          folderName={folder.name}\n          dataroomId={dataroomId}\n        />\n      ) : null}\n      {moveFolderOpen && !isDataroom ? (\n        <MoveToFolderModal\n          open={moveFolderOpen}\n          setOpen={setMoveFolderOpen}\n          folderIds={[folder.id]}\n          itemName={folder.name}\n          documentIds={[]}\n          folderParentId={folder.parentId!}\n        />\n      ) : null}\n      {moveFolderOpen && isDataroom && dataroomId ? (\n        <MoveToDataroomFolderModal\n          open={moveFolderOpen}\n          setOpen={setMoveFolderOpen}\n          dataroomId={dataroomId}\n          documentIds={[]}\n          folderIds={[folder.id]}\n          folderParentId={folder.parentId!}\n          itemName={folder.name}\n        />\n      ) : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/hidden-document-card.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  EyeIcon,\n  MoreVertical,\n  ServerIcon,\n  TrashIcon,\n} from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { DocumentWithLinksAndLinkCountAndViewCount } from \"@/lib/types\";\nimport { cn, nFormatter, timeAgo } from \"@/lib/utils\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\n\nimport BarChart from \"@/components/shared/icons/bar-chart\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\ntype HiddenDocumentCardProps = {\n  document: DocumentWithLinksAndLinkCountAndViewCount;\n  teamInfo: TeamContextType | null;\n  isSelected?: boolean;\n  onSelect?: () => void;\n};\n\nexport function HiddenDocumentCard({\n  document: prismaDocument,\n  teamInfo,\n  isSelected,\n  onSelect,\n}: HiddenDocumentCardProps) {\n  const { theme, systemTheme } = useTheme();\n  const isLight =\n    theme === \"light\" || (theme === \"system\" && systemTheme === \"light\");\n\n  const [isFirstClick, setIsFirstClick] = useState<boolean>(false);\n  const [menuOpen, setMenuOpen] = useState<boolean>(false);\n  const [isHovered, setIsHovered] = useState<boolean>(false);\n\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    function handleClickOutside(event: { target: any }) {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {\n        setMenuOpen(false);\n        setIsFirstClick(false);\n      }\n    }\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, []);\n\n  const handleButtonClick = (event: any, documentId: string) => {\n    event.stopPropagation();\n    event.preventDefault();\n\n    if (isFirstClick) {\n      handleDeleteDocument(documentId);\n      setIsFirstClick(false);\n      setMenuOpen(false);\n    } else {\n      setIsFirstClick(true);\n    }\n  };\n\n  const handleDeleteDocument = async (documentId: string) => {\n    if (!isFirstClick) {\n      setIsFirstClick(true);\n      return;\n    }\n\n    const teamId = teamInfo?.currentTeam?.id;\n    if (!teamId) {\n      toast.error(\"Team information is missing. Please try again.\");\n      return;\n    }\n\n    toast.promise(\n      fetch(`/api/teams/${teamId}/documents/${documentId}`, {\n        method: \"DELETE\",\n      }).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to delete document\");\n        }\n        mutate(`/api/teams/${teamId}/documents/hidden`);\n      }),\n      {\n        loading: \"Deleting document...\",\n        success: \"Document deleted successfully.\",\n        error: (err) => err.message || \"Failed to delete document. Try again.\",\n      },\n    );\n  };\n\n  const handleMenuStateChange = (open: boolean) => {\n    if (isSelected) return;\n\n    if (isFirstClick) {\n      setMenuOpen(true);\n      return;\n    }\n\n    if (!open) {\n      setIsFirstClick(false);\n      setMenuOpen(false);\n    } else {\n      setMenuOpen(true);\n    }\n  };\n\n  const handleUnhideDocument = async (event: any) => {\n    event.stopPropagation();\n    event.preventDefault();\n\n    const teamId = teamInfo?.currentTeam?.id;\n    if (!teamId) {\n      toast.error(\"Team information is missing. Please try again.\");\n      return;\n    }\n\n    toast.promise(\n      fetch(`/api/teams/${teamId}/documents/hide`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          documentIds: [prismaDocument.id],\n          hidden: false,\n        }),\n      }).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to unhide document\");\n        }\n        mutate(`/api/teams/${teamId}/documents/hidden`);\n        mutate(`/api/teams/${teamId}/documents`);\n        mutate(`/api/teams/${teamId}/folders?root=true`);\n        setMenuOpen(false);\n      }),\n      {\n        loading: \"Unhiding document...\",\n        success: \"Document is now visible in All Documents.\",\n        error: (err) =>\n          err.message || \"Failed to unhide document. Try again.\",\n      },\n    );\n  };\n\n  return (\n    <div\n      className={cn(\n        \"group/row relative flex items-center justify-between gap-x-2 rounded-lg border-0 bg-white p-3 ring-1 ring-gray-200 transition-all hover:bg-secondary hover:ring-gray-300 dark:bg-secondary dark:ring-gray-700 hover:dark:ring-gray-500 sm:p-4\",\n        isHovered && \"bg-secondary ring-gray-300 dark:ring-gray-500\",\n        isSelected && \"ring-2 ring-primary\",\n      )}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n    >\n      <div className=\"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\">\n        {isSelected || isHovered ? (\n          <div\n            className=\"mx-0.5 flex w-8 items-center justify-center sm:mx-1\"\n            onClick={(e) => {\n              e.preventDefault();\n              e.stopPropagation();\n              onSelect?.();\n            }}\n          >\n            <Checkbox\n              checked={isSelected}\n              className=\"h-5 w-5\"\n              aria-label={isSelected ? \"Deselect document\" : \"Select document\"}\n            />\n          </div>\n        ) : (\n          <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n            {fileIcon({\n              fileType: prismaDocument.type ?? \"\",\n              className: \"h-8 w-8\",\n              isLight,\n            })}\n          </div>\n        )}\n\n        <div className=\"flex-col\">\n          <div className=\"flex items-center\">\n            <h2 className=\"min-w-0 max-w-[250px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md\">\n              <Link\n                href={`/documents/${prismaDocument.id}`}\n                className=\"w-full truncate\"\n              >\n                <span>{prismaDocument.name}</span>\n                <span className=\"absolute inset-0\" />\n              </Link>\n            </h2>\n            {prismaDocument._count.datarooms > 0 && (\n              <div className=\"z-20\">\n                <BadgeTooltip\n                  content={`In ${prismaDocument._count.datarooms} dataroom${prismaDocument._count.datarooms > 1 ? \"s\" : \"\"}`}\n                  key=\"dataroom\"\n                >\n                  <ServerIcon className=\"ml-2 h-4 w-4 text-[#fb7a00] hover:text-[#fb7a00]/90\" />\n                </BadgeTooltip>\n              </div>\n            )}\n          </div>\n          <div className=\"mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground\">\n            <p className=\"truncate\">{timeAgo(prismaDocument.createdAt)}</p>\n            <p>•</p>\n            <p className=\"truncate\">\n              {prismaDocument._count.links}{\" \"}\n              {prismaDocument._count.links === 1 ? \"Link\" : \"Links\"}\n            </p>\n            {prismaDocument._count.versions > 1 ? (\n              <>\n                <p>•</p>\n                <p className=\"truncate\">{`${prismaDocument._count.versions} Versions`}</p>\n              </>\n            ) : null}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex flex-row space-x-2\">\n        <Link\n          onClick={(e) => {\n            e.stopPropagation();\n          }}\n          href={`/documents/${prismaDocument.id}`}\n          className=\"z-20 flex items-center space-x-1 rounded-md bg-gray-200 px-1.5 py-0.5 transition-all duration-75 hover:scale-105 active:scale-100 dark:bg-gray-700 sm:px-2\"\n        >\n          <BarChart className=\"h-3 w-3 text-muted-foreground sm:h-4 sm:w-4\" />\n          <p className=\"whitespace-nowrap text-xs text-muted-foreground sm:text-sm\">\n            {nFormatter(prismaDocument._count.views)}\n            <span className=\"ml-1 hidden sm:inline-block\">views</span>\n          </p>\n        </Link>\n\n        <DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>\n          <DropdownMenuTrigger asChild>\n            <Button\n              variant=\"outline\"\n              className=\"z-20 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n            >\n              <span className=\"sr-only\">Open menu</span>\n              <MoreVertical className=\"h-4 w-4\" />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"end\" ref={dropdownRef}>\n            <DropdownMenuLabel>Actions</DropdownMenuLabel>\n            <DropdownMenuItem onClick={handleUnhideDocument}>\n              <EyeIcon className=\"mr-2 h-4 w-4\" />\n              Show in All Documents\n            </DropdownMenuItem>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem\n              onClick={(event) => handleButtonClick(event, prismaDocument.id)}\n              className=\"text-destructive duration-200 focus:bg-destructive focus:text-destructive-foreground\"\n            >\n              {isFirstClick ? (\n                \"Really delete?\"\n              ) : (\n                <>\n                  <TrashIcon className=\"mr-2 h-4 w-4\" /> Delete document\n                </>\n              )}\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/hidden-documents-list.tsx",
    "content": "import { memo, useCallback, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  EyeIcon,\n  FileIcon,\n  FolderIcon,\n  Trash2Icon,\n  XIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { FolderWithCount } from \"@/lib/swr/use-documents\";\nimport { DocumentWithLinksAndLinkCountAndViewCount } from \"@/lib/types\";\n\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nimport { Button } from \"../ui/button\";\nimport { Checkbox } from \"../ui/checkbox\";\nimport { Portal } from \"../ui/portal\";\nimport { ButtonTooltip } from \"../ui/tooltip\";\nimport { useDeleteDocumentsAndFoldersModal } from \"./actions/delete-documents-modal\";\nimport { useDeleteFolderModal } from \"./actions/delete-folder-modal\";\nimport { HiddenDocumentCard } from \"./hidden-document-card\";\nimport { HiddenFolderCard } from \"./hidden-folder-card\";\n\nexport function HiddenDocumentsList({\n  folders,\n  documents,\n  teamInfo,\n  loading,\n  foldersLoading,\n}: {\n  folders: FolderWithCount[] | undefined;\n  documents: DocumentWithLinksAndLinkCountAndViewCount[] | undefined;\n  teamInfo: TeamContextType | null;\n  loading: boolean;\n  foldersLoading: boolean;\n}) {\n  const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);\n  const [selectedFolders, setSelectedFolders] = useState<string[]>([]);\n  const [isUnhiding, setIsUnhiding] = useState(false);\n\n  const { setDeleteModalOpen, setFolderToDelete, DeleteFolderModal } =\n    useDeleteFolderModal(teamInfo);\n\n  const handleDeleteFolder = useCallback(\n    (folderId: string) => {\n      const folderToDelete = folders?.find((f) => f.id === folderId);\n      if (folderToDelete) {\n        setFolderToDelete(folderToDelete);\n        setDeleteModalOpen(true);\n        setSelectedFolders((prev) => prev.filter((id) => id !== folderId));\n      }\n    },\n    [folders, setFolderToDelete, setDeleteModalOpen, setSelectedFolders],\n  );\n\n  const { setShowDeleteItemsModal, DeleteItemsModal } =\n    useDeleteDocumentsAndFoldersModal({\n      documentIds: selectedDocuments,\n      setSelectedDocuments,\n      folderIds: selectedFolders,\n      setSelectedFolder: setSelectedFolders,\n    });\n\n  const totalSelectedItem = [...selectedDocuments, ...selectedFolders].length;\n\n  const selectedDocumentsLength = useMemo(\n    () => selectedDocuments && selectedDocuments.length,\n    [selectedDocuments],\n  );\n\n  const selectedFoldersLength = useMemo(\n    () => selectedFolders && selectedFolders.length,\n    [selectedFolders],\n  );\n\n  const handleSelect = useCallback(\n    (id: string, type: \"document\" | \"folder\") => {\n      if (type === \"folder\") {\n        setSelectedFolders((prev) =>\n          prev.includes(id)\n            ? prev.filter((docId) => docId !== id)\n            : [...prev, id],\n        );\n      } else {\n        setSelectedDocuments((prev) =>\n          prev.includes(id)\n            ? prev.filter((docId) => docId !== id)\n            : [...prev, id],\n        );\n      }\n    },\n    [],\n  );\n\n  const resetSelection = () => {\n    setSelectedDocuments([]);\n    setSelectedFolders([]);\n  };\n\n  const handleBulkUnhide = useCallback(async () => {\n    if (selectedDocuments.length === 0 && selectedFolders.length === 0) return;\n\n    setIsUnhiding(true);\n\n    try {\n      const promises: Promise<Response>[] = [];\n\n      // Unhide documents\n      if (selectedDocuments.length > 0) {\n        promises.push(\n          fetch(`/api/teams/${teamInfo?.currentTeam?.id}/documents/hide`, {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              documentIds: selectedDocuments,\n              hidden: false,\n            }),\n          }),\n        );\n      }\n\n      // Unhide folders (cascades to children)\n      if (selectedFolders.length > 0) {\n        promises.push(\n          fetch(`/api/teams/${teamInfo?.currentTeam?.id}/folders/hide`, {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              folderIds: selectedFolders,\n              hidden: false,\n            }),\n          }),\n        );\n      }\n\n      const results = await Promise.all(promises);\n\n      // Check for errors\n      for (const res of results) {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to unhide items\");\n        }\n      }\n\n      // Revalidate data\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders?root=true`);\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders`);\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`);\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents/hidden`);\n\n      // Reset selection\n      setSelectedDocuments([]);\n      setSelectedFolders([]);\n\n      toast.success(\n        `Successfully unhidden ${selectedDocuments.length > 0 ? `${selectedDocuments.length} document${selectedDocuments.length > 1 ? \"s\" : \"\"}` : \"\"}${selectedDocuments.length > 0 && selectedFolders.length > 0 ? \" and \" : \"\"}${selectedFolders.length > 0 ? `${selectedFolders.length} folder${selectedFolders.length > 1 ? \"s\" : \"\"}` : \"\"}`,\n      );\n    } catch (error) {\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to unhide items\",\n      );\n    } finally {\n      setIsUnhiding(false);\n    }\n  }, [selectedDocuments, selectedFolders, teamInfo?.currentTeam?.id]);\n\n  const HeaderContent = memo(() => {\n    if (selectedDocumentsLength > 0 || selectedFoldersLength > 0) {\n      const totalItems = (documents?.length || 0) + (folders?.length || 0);\n      const isAllSelected = totalItems === totalSelectedItem;\n\n      const handleSelectAll = () => {\n        if (isAllSelected) {\n          setSelectedDocuments([]);\n          setSelectedFolders([]);\n        } else {\n          const allDocumentIds = documents?.map((doc) => doc.id) || [];\n          const allFolderIds = folders?.map((folder) => folder.id) || [];\n          setSelectedDocuments(allDocumentIds);\n          setSelectedFolders(allFolderIds);\n        }\n      };\n\n      return (\n        <div className=\"mb-2 flex items-center gap-x-1 rounded-3xl bg-gray-100 text-sm text-foreground dark:bg-gray-800\">\n          <div className=\"ml-5 flex h-8 w-8 items-center justify-center rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\">\n            <ButtonTooltip\n              content={isAllSelected ? \"Deselect all\" : \"Select all\"}\n            >\n              <Checkbox\n                id=\"select-all\"\n                checked={isAllSelected}\n                onCheckedChange={handleSelectAll}\n                className=\"h-5 w-5\"\n                aria-label={isAllSelected ? \"Deselect all\" : \"Select all\"}\n              />\n            </ButtonTooltip>\n          </div>\n          <ButtonTooltip content=\"Clear selection\">\n            <Button\n              onClick={resetSelection}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <XIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n          {selectedDocumentsLength ? (\n            <div className=\"mr-2 tabular-nums\">\n              {selectedDocumentsLength} document\n              {selectedDocumentsLength > 1 ? \"s\" : \"\"} selected\n            </div>\n          ) : null}\n          {selectedFoldersLength ? (\n            <div className=\"mr-2 tabular-nums\">\n              {selectedFoldersLength} folder\n              {selectedFoldersLength > 1 ? \"s\" : \"\"} selected\n            </div>\n          ) : null}\n          <ButtonTooltip content=\"Unhide (Show in All Documents)\">\n            <Button\n              onClick={handleBulkUnhide}\n              disabled={isUnhiding}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <EyeIcon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n          <ButtonTooltip content=\"Delete\">\n            <Button\n              onClick={() => setShowDeleteItemsModal(true)}\n              className=\"mx-1.5 my-1 size-8 rounded-full hover:bg-destructive hover:text-destructive-foreground\"\n              variant=\"ghost\"\n              size=\"icon\"\n            >\n              <Trash2Icon className=\"h-5 w-5\" />\n            </Button>\n          </ButtonTooltip>\n        </div>\n      );\n    } else {\n      return (\n        <div className=\"mb-2 flex items-center gap-x-2 pt-5\">\n          {folders && folders.length > 0 && (\n            <p className=\"flex items-center gap-x-1 text-sm text-gray-400\">\n              <FolderIcon className=\"h-5 w-5\" />\n              <span>\n                {folders.length} folder{folders.length > 1 ? \"s\" : \"\"}\n              </span>\n            </p>\n          )}\n          {documents && documents.length > 0 && (\n            <p className=\"flex items-center gap-x-1 text-sm text-gray-400\">\n              <FileIcon className=\"h-5 w-5\" />\n              <span>\n                {documents.length} document{documents.length > 1 ? \"s\" : \"\"}\n              </span>\n            </p>\n          )}\n        </div>\n      );\n    }\n  });\n  HeaderContent.displayName = \"HeaderContent\";\n\n  const isEmpty =\n    !loading &&\n    !foldersLoading &&\n    (!documents || documents.length === 0) &&\n    (!folders || folders.length === 0);\n\n  return (\n    <>\n      <div className=\"space-y-4\">\n        {/* Folders list */}\n        <ul role=\"list\" className=\"space-y-4\">\n          {folders && !foldersLoading\n            ? folders.map((folder) => {\n                const isSelected = selectedFolders.includes(folder.id);\n                return (\n                  <li key={folder.id}>\n                    <HiddenFolderCard\n                      folder={folder}\n                      teamInfo={teamInfo}\n                      isSelected={isSelected}\n                      onSelect={() => handleSelect(folder.id, \"folder\")}\n                      onDelete={handleDeleteFolder}\n                    />\n                  </li>\n                );\n              })\n            : foldersLoading &&\n              Array.from({ length: 2 }).map((_, i) => (\n                <li\n                  key={i}\n                  className=\"relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6\"\n                >\n                  <Skeleton className=\"h-9 w-9\" />\n                  <div>\n                    <Skeleton className=\"h-4 w-32\" />\n                    <Skeleton className=\"mt-2 h-3 w-12\" />\n                  </div>\n                  <Skeleton className=\"absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform\" />\n                </li>\n              ))}\n        </ul>\n\n        {/* Documents list */}\n        <ul role=\"list\" className=\"space-y-4\">\n          {documents && !loading\n            ? documents.map((document) => {\n                const isSelected = selectedDocuments.includes(document.id);\n                return (\n                  <li key={document.id}>\n                    <HiddenDocumentCard\n                      document={document}\n                      teamInfo={teamInfo}\n                      isSelected={isSelected}\n                      onSelect={() => handleSelect(document.id, \"document\")}\n                    />\n                  </li>\n                );\n              })\n            : loading &&\n              Array.from({ length: 3 }).map((_, i) => (\n                <li\n                  key={i}\n                  className=\"relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6\"\n                >\n                  <Skeleton className=\"h-9 w-9\" />\n                  <div>\n                    <Skeleton className=\"h-4 w-32\" />\n                    <Skeleton className=\"mt-2 h-3 w-12\" />\n                  </div>\n                  <Skeleton className=\"absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform\" />\n                </li>\n              ))}\n        </ul>\n\n        <Portal containerId={\"documents-header-count\"}>\n          <HeaderContent />\n        </Portal>\n\n        {isEmpty && (\n          <div className=\"flex flex-col items-center justify-center py-12\">\n            <EyeIcon className=\"mb-4 h-12 w-12 text-gray-400\" strokeWidth={1} />\n            <h3 className=\"mb-2 text-lg font-medium text-gray-900 dark:text-gray-100\">\n              No hidden documents or folders\n            </h3>\n            <p className=\"text-sm text-muted-foreground\">\n              Documents and folders you hide from All Documents will appear\n              here.\n            </p>\n          </div>\n        )}\n      </div>\n\n      <DeleteFolderModal />\n      <DeleteItemsModal />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/hidden-folder-card.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { TeamContextType } from \"@/context/team-context\";\nimport {\n  EyeIcon,\n  FolderIcon,\n  MoreVertical,\n  TrashIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { FolderWithCount } from \"@/lib/swr/use-documents\";\nimport { timeAgo } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\ntype HiddenFolderCardProps = {\n  folder: FolderWithCount;\n  teamInfo: TeamContextType | null;\n  isSelected?: boolean;\n  onSelect?: () => void;\n  onDelete?: (folderId: string) => void;\n};\n\nexport function HiddenFolderCard({\n  folder,\n  teamInfo,\n  isSelected,\n  onSelect,\n  onDelete,\n}: HiddenFolderCardProps) {\n  const router = useRouter();\n  const [menuOpen, setMenuOpen] = useState<boolean>(false);\n  const [isHovered, setIsHovered] = useState<boolean>(false);\n\n  const folderPath = `/documents/tree${folder.path}`;\n\n  const handleCardClick = (e: React.MouseEvent) => {\n    router.push(folderPath);\n  };\n\n  const handleUnhideFolder = async (e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    toast.promise(\n      fetch(`/api/teams/${teamInfo?.currentTeam?.id}/folders/hide`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          folderIds: [folder.id],\n          hidden: false,\n        }),\n      }).then(async (res) => {\n        if (!res.ok) {\n          const error = await res.json();\n          throw new Error(error.message || \"Failed to unhide folder\");\n        }\n        // Revalidate the folders and documents\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders?root=true`);\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders`);\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`);\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents/hidden`);\n        setMenuOpen(false);\n      }),\n      {\n        loading: \"Unhiding folder...\",\n        success: \"Folder is now visible in All Documents.\",\n        error: (err) => err.message || \"Failed to unhide folder. Try again.\",\n      },\n    );\n  };\n\n  return (\n    <div\n      onClick={handleCardClick}\n      onMouseEnter={() => setIsHovered(true)}\n      onMouseLeave={() => setIsHovered(false)}\n      className={`group/row relative flex cursor-pointer items-center justify-between rounded-lg border-0 bg-white p-3 ring-1 ring-gray-400 transition-all hover:bg-secondary hover:ring-gray-500 dark:bg-secondary dark:ring-gray-500 hover:dark:ring-gray-400 sm:p-4 ${isSelected ? \"ring-2 ring-primary\" : \"\"}`}\n    >\n      <div className=\"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\">\n        {isSelected || isHovered ? (\n          <div\n            className=\"mx-0.5 flex w-8 items-center justify-center sm:mx-1\"\n            onClick={(e) => {\n              e.preventDefault();\n              e.stopPropagation();\n              onSelect?.();\n            }}\n          >\n            <Checkbox\n              checked={isSelected}\n              className=\"h-5 w-5\"\n              aria-label={isSelected ? \"Deselect folder\" : \"Select folder\"}\n            />\n          </div>\n        ) : (\n          <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n            <FolderIcon className=\"h-8 w-8\" strokeWidth={1} />\n          </div>\n        )}\n\n        <div className=\"flex-col\">\n          <div className=\"flex items-center\">\n            <h2 className=\"min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md\">\n              {folder.name}\n            </h2>\n          </div>\n          <div className=\"mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground\">\n            <p className=\"truncate\">{timeAgo(folder.createdAt)}</p>\n            <p>•</p>\n            <p className=\"truncate\">\n              {folder._count.documents}{\" \"}\n              {folder._count.documents === 1 ? \"Document\" : \"Documents\"}\n            </p>\n            <p>•</p>\n            <p className=\"truncate\">\n              {folder._count.childFolders}{\" \"}\n              {folder._count.childFolders === 1 ? \"Folder\" : \"Folders\"}\n            </p>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex flex-row space-x-2\">\n        <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n          <DropdownMenuTrigger asChild>\n            <Button\n              variant=\"outline\"\n              className=\"z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n            >\n              <span className=\"sr-only\">Open menu</span>\n              <MoreVertical className=\"h-4 w-4\" />\n            </Button>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent align=\"end\" className=\"w-64\">\n            <DropdownMenuLabel>Actions</DropdownMenuLabel>\n            <DropdownMenuItem onClick={handleUnhideFolder}>\n              <EyeIcon className=\"mr-2 h-4 w-4\" />\n              Show in All Documents\n            </DropdownMenuItem>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem\n              onClick={(event) => {\n                event.preventDefault();\n                event.stopPropagation();\n                onDelete?.(folder.id);\n                setMenuOpen(false);\n              }}\n              className=\"text-destructive duration-200 focus:bg-destructive focus:text-destructive-foreground\"\n            >\n              <TrashIcon className=\"mr-2 h-4 w-4\" /> Delete Folder\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/link-document-indicator.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { DocumentVersion } from \"@prisma/client\";\nimport { Edit, ExternalLink, LinkIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport { ButtonTooltip } from \"@/components/ui/tooltip\";\n\ninterface LinkDocumentIndicatorProps {\n  documentId: string;\n  primaryVersion: DocumentVersion;\n  onUrlUpdate?: () => void;\n}\n\nexport default function LinkDocumentIndicator({\n  documentId,\n  primaryVersion,\n  onUrlUpdate,\n}: LinkDocumentIndicatorProps) {\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n  const [isMainDialogOpen, setIsMainDialogOpen] = useState(false);\n  const [isUrlDialogOpen, setIsUrlDialogOpen] = useState(false);\n  const [newUrl, setNewUrl] = useState(\"\");\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  // Local state for immediate UI updates\n  const [currentUrl, setCurrentUrl] = useState(primaryVersion.file);\n\n  // Sync with prop changes (e.g., when SWR revalidates)\n  useEffect(() => {\n    setCurrentUrl(primaryVersion.file);\n  }, [primaryVersion.file]);\n\n  const updateLinkUrl = async () => {\n    if (!teamInfo?.currentTeam?.id || !newUrl.trim()) return;\n\n    setIsUpdating(true);\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo.currentTeam.id}/documents/${documentId}/update-link-url`,\n        {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ linkUrl: newUrl.trim() }),\n        },\n      );\n\n      const data = await response.json();\n\n      if (response.ok) {\n        // Immediately update local state for instant UI feedback\n        setCurrentUrl(newUrl.trim());\n        toast.success(\"Link URL updated successfully\");\n        setIsUrlDialogOpen(false);\n        setNewUrl(\"\");\n\n        // Track analytics event\n        analytics.capture(\"Document Updated\", {\n          documentId: documentId,\n          url: newUrl.trim(),\n          type: \"link\",\n          teamId: teamInfo.currentTeam.id,\n          $set: {\n            teamId: teamInfo.currentTeam.id,\n          },\n        });\n\n        // Trigger parent component update if provided\n        if (onUrlUpdate) {\n          onUrlUpdate();\n        }\n      } else {\n        toast.error(data.message || \"Failed to update URL\");\n      }\n    } catch (error) {\n      console.error(\"Error updating URL:\", error);\n      toast.error(\"Failed to update URL\");\n    } finally {\n      setIsUpdating(false);\n    }\n  };\n\n  const openLinkInNewTab = () => {\n    if (currentUrl) {\n      window.open(currentUrl, \"_blank\", \"noopener,noreferrer\");\n    }\n  };\n\n  // Only render for link documents\n  if (primaryVersion.type !== \"link\") {\n    return null;\n  }\n\n  return (\n    <>\n      {/* Compact Icon Trigger */}\n      <Dialog open={isMainDialogOpen} onOpenChange={setIsMainDialogOpen}>\n        <ButtonTooltip content=\"View link details\">\n          <DialogTrigger asChild>\n            <Button\n              variant=\"outline\"\n              size=\"default\"\n              className=\"h-8 w-8 p-0 lg:h-9 lg:w-9\"\n            >\n              <LinkIcon className=\"h-4 w-4\" />\n            </Button>\n          </DialogTrigger>\n        </ButtonTooltip>\n\n        {/* Main Status Dialog */}\n        <DialogContent className=\"sm:max-w-lg\">\n          <DialogHeader>\n            <DialogTitle>Link Details</DialogTitle>\n            <DialogDescription>\n              View and manage your external link.\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4 py-4\">\n            {/* URL Display */}\n            <div className=\"rounded-lg border p-3\">\n              <div className=\"mb-1 text-sm font-medium text-muted-foreground\">\n                Current URL\n              </div>\n              <div className=\"break-all font-mono text-sm\">{currentUrl}</div>\n            </div>\n\n            {/* Action Buttons */}\n            <div className=\"flex gap-2\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={openLinkInNewTab}\n                className=\"flex-1\"\n              >\n                <ExternalLink className=\"mr-2 h-4 w-4\" />\n                Open Link\n              </Button>\n\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => {\n                  setNewUrl(currentUrl || \"\");\n                  setIsUrlDialogOpen(true);\n                }}\n                className=\"flex-1\"\n              >\n                <Edit className=\"mr-2 h-4 w-4\" />\n                Edit URL\n              </Button>\n            </div>\n          </div>\n        </DialogContent>\n      </Dialog>\n\n      {/* URL Edit Dialog */}\n      <Dialog open={isUrlDialogOpen} onOpenChange={setIsUrlDialogOpen}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>Update Link URL</DialogTitle>\n            <DialogDescription>\n              Change the URL for this link document.\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"grid gap-4 py-4\">\n            <div className=\"grid gap-2\">\n              <Label htmlFor=\"link-url\">Link URL</Label>\n              <Input\n                id=\"link-url\"\n                value={newUrl}\n                onChange={(e) => setNewUrl(e.target.value)}\n                placeholder=\"https://example.com/your-link\"\n                className=\"w-full\"\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                URL must use HTTPS protocol.\n              </p>\n            </div>\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setIsUrlDialogOpen(false)}\n              disabled={isUpdating}\n            >\n              Cancel\n            </Button>\n            <Button\n              onClick={updateLinkUrl}\n              disabled={isUpdating || !newUrl.trim()}\n            >\n              {isUpdating ? (\n                <>\n                  <LoadingSpinner className=\"mr-2 h-4 w-4\" />\n                  Updating...\n                </>\n              ) : (\n                \"Update URL\"\n              )}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/loading-document.tsx",
    "content": "import { Skeleton } from \"../ui/skeleton\";\n\nexport function LoadingDocuments({ count }: { count: number }) {\n  return (\n    <ul role=\"list\" className=\"space-y-4\">\n      {Array.from({ length: count }).map((_, i) => (\n        <li\n          key={i}\n          className=\"relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6\"\n        >\n          <Skeleton key={i} className=\"h-9 w-9\" />\n          <div>\n            <Skeleton key={i} className=\"h-4 w-32\" />\n            <Skeleton key={i + 1} className=\"mt-2 h-3 w-12\" />\n          </div>\n          <Skeleton\n            key={i + 1}\n            className=\"absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform\"\n          />\n        </li>\n      ))}\n    </ul>\n  );\n}\n"
  },
  {
    "path": "components/documents/move-folder-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\n\nimport { moveDocumentToFolder } from \"@/lib/documents/move-documents\";\nimport { moveFolderToFolder } from \"@/lib/documents/move-folder\";\n\nimport { SidebarFolderTreeSelection } from \"@/components/sidebar-folders\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\nexport type TSelectedFolder = {\n  id: string | null;\n  name: string;\n  path?: string | null;\n} | null;\n\nexport function MoveToFolderModal({\n  open,\n  setOpen,\n  setSelectedDocuments,\n  documentIds,\n  itemName,\n  folderIds,\n  folderParentId,\n  setSelectedFoldersId,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  setSelectedDocuments?: React.Dispatch<React.SetStateAction<string[]>>;\n  documentIds?: string[];\n  itemName?: string;\n  folderIds?: string[];\n  folderParentId?: string;\n  setSelectedFoldersId?: React.Dispatch<React.SetStateAction<string[]>>;\n}) {\n  const router = useRouter();\n  const [selectedFolder, setSelectedFolder] = useState<TSelectedFolder>(null);\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const currentPath = router.query.name\n    ? (router.query.name as string[]).join(\"/\")\n    : \"\";\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!selectedFolder) return;\n\n    setLoading(true);\n\n    if (folderParentId === selectedFolder?.id) {\n      toast.error(\"Item is already in the selected folder.\");\n      setLoading(false);\n      return;\n    }\n\n    if (folderIds?.includes(selectedFolder.id!)) {\n      toast.error(\"Cannot move folder to itself.\");\n      setLoading(false);\n      return;\n    }\n    if (documentIds && documentIds.length > 0) {\n      await moveDocumentToFolder({\n        documentIds,\n        folderId: selectedFolder.id!,\n        folderPathName: currentPath ? currentPath.split(\"/\") : undefined,\n        teamId,\n      });\n    }\n    if (folderIds && folderIds.length > 0) {\n      await moveFolderToFolder({\n        folderIds: folderIds,\n        folderPathName: currentPath ? currentPath.split(\"/\") : undefined,\n        teamId,\n        selectedFolder: selectedFolder.id!,\n        selectedFolderPath: selectedFolder.path!,\n      });\n    }\n\n    setLoading(false);\n    setOpen(false); // Close the modal\n    setSelectedDocuments?.([]); // Clear the selected documents\n    setSelectedFoldersId?.([]); // Clear the selected folders\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>\n            Move\n            <div className=\"truncate font-bold\">\n              {`${(documentIds?.length ?? 0) + (folderIds?.length ?? 0)} items`}\n            </div>\n          </DialogTitle>\n          <DialogDescription>Move your item to a folder.</DialogDescription>\n        </DialogHeader>\n        <form>\n          <div className=\"mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll\">\n            <SidebarFolderTreeSelection\n              selectedFolder={selectedFolder}\n              setSelectedFolder={setSelectedFolder}\n              disableId={folderIds}\n            />\n          </div>\n\n          <DialogFooter>\n            <Button\n              onClick={handleSubmit}\n              className=\"flex h-9 w-full gap-1\"\n              loading={loading}\n              disabled={\n                !selectedFolder || folderIds?.includes(selectedFolder.id!)\n              }\n            >\n              {!selectedFolder ? (\n                \"Select a folder\"\n              ) : (\n                <>\n                  Move to{\" \"}\n                  <span className=\"max-w-[200px] truncate font-medium\">\n                    {selectedFolder.name}\n                  </span>\n                </>\n              )}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/documents/notion-accessibility-indicator.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { DocumentVersion } from \"@prisma/client\";\nimport {\n  CircleCheckIcon,\n  CircleXIcon,\n  Edit,\n  ExternalLink,\n  RefreshCw,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport { ButtonTooltip } from \"@/components/ui/tooltip\";\n\ninterface NotionAccessibilityIndicatorProps {\n  documentId: string;\n  primaryVersion: DocumentVersion;\n  onUrlUpdate?: () => void;\n}\n\ninterface AccessibilityStatus {\n  isAccessible: boolean;\n  url: string;\n  statusCode?: number;\n  lastChecked: string;\n  error?: string;\n}\n\nexport default function NotionAccessibilityIndicator({\n  documentId,\n  primaryVersion,\n  onUrlUpdate,\n}: NotionAccessibilityIndicatorProps) {\n  const teamInfo = useTeam();\n  const [status, setStatus] = useState<AccessibilityStatus | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n  const [isMainDialogOpen, setIsMainDialogOpen] = useState(false);\n  const [isUrlDialogOpen, setIsUrlDialogOpen] = useState(false);\n  const [newUrl, setNewUrl] = useState(\"\");\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  const checkAccessibility = async () => {\n    if (!teamInfo?.currentTeam?.id) return;\n\n    setIsLoading(true);\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo.currentTeam.id}/documents/${documentId}/check-notion-accessibility`,\n      );\n      const data = await response.json();\n\n      if (response.ok) {\n        setStatus(data);\n      } else {\n        toast.error(\"Failed to check accessibility\");\n      }\n    } catch (error) {\n      console.error(\"Error checking accessibility:\", error);\n      toast.error(\"Failed to check accessibility\");\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const updateNotionUrl = async () => {\n    if (!teamInfo?.currentTeam?.id || !newUrl.trim()) return;\n\n    setIsUpdating(true);\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo.currentTeam.id}/documents/${documentId}/update-notion-url`,\n        {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ notionUrl: newUrl.trim() }),\n        },\n      );\n\n      const data = await response.json();\n\n      if (response.ok) {\n        toast.success(\"Notion URL updated successfully\");\n        setIsUrlDialogOpen(false);\n        setNewUrl(\"\");\n        // Refresh accessibility status\n        await checkAccessibility();\n        // Trigger parent component update if provided\n        if (onUrlUpdate) {\n          onUrlUpdate();\n        }\n      } else {\n        toast.error(data.message || \"Failed to update URL\");\n      }\n    } catch (error) {\n      console.error(\"Error updating URL:\", error);\n      toast.error(\"Failed to update URL\");\n    } finally {\n      setIsUpdating(false);\n    }\n  };\n\n  const openNotionPage = () => {\n    if (status?.url) {\n      window.open(status.url, \"_blank\");\n    }\n  };\n\n  // Check accessibility on component mount\n  useEffect(() => {\n    if (primaryVersion.type === \"notion\") {\n      checkAccessibility();\n    }\n  }, []);\n\n  // Only render for Notion documents\n  if (primaryVersion.type !== \"notion\") {\n    return null;\n  }\n\n  const getStatusIcon = () => {\n    if (isLoading) {\n      return <LoadingSpinner className=\"h-4 w-4\" />;\n    }\n\n    if (status?.isAccessible) {\n      return (\n        <CircleCheckIcon className=\"h-4 w-4 rounded-full bg-green-500 text-white\" />\n      );\n    } else {\n      return (\n        <CircleXIcon className=\"h-4 w-4 rounded-full bg-red-500 text-white\" />\n      );\n    }\n  };\n\n  const getTooltipText = () => {\n    if (isLoading) {\n      return \"Checking accessibility...\";\n    }\n\n    if (status?.isAccessible) {\n      return \"Notion page is publicly accessible\";\n    } else if (status?.error) {\n      return \"Unable to check accessibility\";\n    } else {\n      return \"Notion page is not publicly accessible\";\n    }\n  };\n\n  return (\n    <>\n      {/* Compact Icon Trigger */}\n      <Dialog open={isMainDialogOpen} onOpenChange={setIsMainDialogOpen}>\n        <ButtonTooltip content={getTooltipText()}>\n          <DialogTrigger asChild>\n            <Button\n              variant=\"outline\"\n              size=\"default\"\n              className=\"h-8 w-8 p-0 lg:h-9 lg:w-9\"\n            >\n              {getStatusIcon()}\n            </Button>\n          </DialogTrigger>\n        </ButtonTooltip>\n\n        {/* Main Status Dialog */}\n        <DialogContent className=\"sm:max-w-lg\">\n          <DialogHeader>\n            <DialogTitle>Notion Page Status</DialogTitle>\n            <DialogDescription>\n              Check and manage your Notion page accessibility.\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"space-y-4 py-4\">\n            {/* Status Display */}\n            <div className=\"flex items-center gap-3 rounded-lg border p-3\">\n              {getStatusIcon()}\n              <div className=\"flex-1\">\n                <div className=\"font-medium\">\n                  {isLoading\n                    ? \"Checking...\"\n                    : status?.isAccessible\n                      ? \"Publicly accessible\"\n                      : status?.error\n                        ? \"Unable to check\"\n                        : \"Not publicly accessible\"}\n                </div>\n                {status?.lastChecked && (\n                  <div className=\"text-sm text-gray-500\">\n                    Last checked:{\" \"}\n                    {new Date(status.lastChecked).toLocaleTimeString()}\n                  </div>\n                )}\n              </div>\n            </div>\n\n            {/* Action Buttons */}\n            <div className=\"flex gap-2\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={checkAccessibility}\n                disabled={isLoading}\n                className=\"flex-1\"\n              >\n                <RefreshCw\n                  className={`mr-2 h-4 w-4 ${isLoading ? \"animate-spin\" : \"\"}`}\n                />\n                Refresh\n              </Button>\n\n              {status?.url && (\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={openNotionPage}\n                  className=\"flex-1\"\n                >\n                  <ExternalLink className=\"mr-2 h-4 w-4\" />\n                  Open Page\n                </Button>\n              )}\n\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => {\n                  setNewUrl(status?.url || \"\");\n                  setIsUrlDialogOpen(true);\n                }}\n                className=\"flex-1\"\n              >\n                <Edit className=\"mr-2 h-4 w-4\" />\n                Edit URL\n              </Button>\n            </div>\n          </div>\n        </DialogContent>\n      </Dialog>\n\n      {/* URL Edit Dialog */}\n      <Dialog open={isUrlDialogOpen} onOpenChange={setIsUrlDialogOpen}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>Update Notion URL</DialogTitle>\n            <DialogDescription>\n              Change the URL of the Notion page for this document.\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"grid gap-4 py-4\">\n            <div className=\"grid gap-2\">\n              <Label htmlFor=\"notion-url\">Notion URL</Label>\n              <Input\n                id=\"notion-url\"\n                value={newUrl}\n                onChange={(e) => setNewUrl(e.target.value)}\n                placeholder=\"https://www.notion.so/your-page-url\"\n                className=\"w-full\"\n              />\n            </div>\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setIsUrlDialogOpen(false)}\n              disabled={isUpdating}\n            >\n              Cancel\n            </Button>\n            <Button\n              onClick={updateNotionUrl}\n              disabled={isUpdating || !newUrl.trim()}\n            >\n              {isUpdating ? (\n                <>\n                  <LoadingSpinner className=\"mr-2 h-4 w-4\" />\n                  Updating...\n                </>\n              ) : (\n                \"Update URL\"\n              )}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/documents/pagination.tsx",
    "content": "import {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  ChevronsLeftIcon,\n  ChevronsRightIcon,\n} from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\ninterface PaginationProps {\n  currentPage: number;\n  pageSize: number;\n  totalItems: number;\n  totalPages: number;\n  onPageChange: (page: number) => void;\n  onPageSizeChange: (size: number) => void;\n  totalShownItems: number;\n  itemName: string;\n  extraInfo?: string;\n}\n\nexport function Pagination({\n  currentPage,\n  pageSize,\n  totalItems,\n  totalPages,\n  onPageChange,\n  onPageSizeChange,\n  totalShownItems,\n  itemName,\n  extraInfo,\n}: PaginationProps) {\n  return (\n    <Card className=\"mt-4 p-4\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex-1 text-sm text-muted-foreground\">\n          Showing <span className=\"font-bold\">{totalShownItems}</span> of{\" \"}\n          {totalItems} {itemName}\n          {extraInfo && <span className=\"ml-2\">· {extraInfo}</span>}\n        </div>\n        <div className=\"flex items-center space-x-6 lg:space-x-8\">\n          <div className=\"hidden items-center space-x-2 sm:flex\">\n            <p className=\"text-sm font-medium\">Items per page</p>\n            <Select\n              value={`${pageSize}`}\n              onValueChange={(value) => onPageSizeChange(Number(value))}\n            >\n              <SelectTrigger className=\"h-8 w-[70px]\">\n                <SelectValue placeholder={pageSize} />\n              </SelectTrigger>\n              <SelectContent side=\"top\">\n                {[10, 20, 30, 40, 50].map((size) => (\n                  <SelectItem key={size} value={`${size}`}>\n                    {size}\n                  </SelectItem>\n                ))}\n              </SelectContent>\n            </Select>\n          </div>\n          <div className=\"flex w-[100px] items-center justify-center text-sm font-medium\">\n            Page {currentPage} of {totalPages || 1}\n          </div>\n          <div className=\"flex items-center space-x-2\">\n            <Button\n              variant=\"outline\"\n              className=\"hidden h-8 w-8 p-0 lg:flex\"\n              onClick={() => onPageChange(1)}\n              disabled={currentPage === 1}\n            >\n              <span className=\"sr-only\">Go to first page</span>\n              <ChevronsLeftIcon className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              variant=\"outline\"\n              className=\"h-8 w-8 p-0\"\n              onClick={() => onPageChange(currentPage - 1)}\n              disabled={currentPage === 1}\n            >\n              <span className=\"sr-only\">Go to previous page</span>\n              <ChevronLeftIcon className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              variant=\"outline\"\n              className=\"h-8 w-8 p-0\"\n              onClick={() => onPageChange(currentPage + 1)}\n              disabled={currentPage === totalPages || totalPages === 0}\n            >\n              <span className=\"sr-only\">Go to next page</span>\n              <ChevronRightIcon className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              variant=\"outline\"\n              className=\"hidden h-8 w-8 p-0 lg:flex\"\n              onClick={() => onPageChange(totalPages)}\n              disabled={currentPage === totalPages || totalPages === 0}\n            >\n              <span className=\"sr-only\">Go to last page</span>\n              <ChevronsRightIcon className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n    </Card>\n  );\n} "
  },
  {
    "path": "components/documents/preview-viewers/index.ts",
    "content": "export { PreviewExcelViewer } from \"./preview-excel-viewer\";\nexport { PreviewImageViewer } from \"./preview-image-viewer\";\nexport { PreviewPagesViewer } from \"./preview-pages-viewer\";\nexport { PreviewViewer } from \"./preview-viewer\";\n"
  },
  {
    "path": "components/documents/preview-viewers/preview-excel-viewer.tsx",
    "content": "import { useState } from \"react\";\n\nimport { DocumentPreviewData } from \"@/lib/types/document-preview\";\nimport { cn } from \"@/lib/utils\";\n\ninterface PreviewExcelViewerProps {\n  documentData: DocumentPreviewData;\n  onClose: () => void;\n}\n\nexport function PreviewExcelViewer({\n  documentData,\n  onClose,\n}: PreviewExcelViewerProps) {\n  const [iframeLoaded, setIframeLoaded] = useState(false);\n\n  const { file, documentName } = documentData;\n\n  if (!file) {\n    return (\n      <div className=\"flex h-full w-full items-center justify-center\">\n        <p className=\"text-gray-400\">Excel preview not available</p>\n      </div>\n    );\n  }\n\n  const embedUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(file)}&wdPrint=0&action=embedview&wdAllowInteractivity=False`;\n\n  return (\n    <div className=\"relative h-full w-full overflow-hidden\">\n      {/* Document Title */}\n      <div className=\"absolute left-1/2 top-4 z-50 -translate-x-1/2\">\n        <div className=\"rounded-lg bg-black/20 px-3 py-2 text-white\">\n          <span className=\"text-sm font-medium\">{documentName}</span>\n        </div>\n      </div>\n\n      {/* Loading indicator */}\n      {!iframeLoaded && (\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <div className=\"h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent\" />\n        </div>\n      )}\n\n      {/* Office Online iframe */}\n      <div className=\"h-full w-full pt-14 pb-2 px-2\">\n        <iframe\n          className={cn(\n            \"h-full w-full rounded-md transition-opacity duration-200\",\n            iframeLoaded ? \"opacity-100\" : \"opacity-0\",\n          )}\n          src={embedUrl}\n          onLoad={() => setIframeLoaded(true)}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/preview-viewers/preview-image-viewer.tsx",
    "content": "import { useState } from \"react\";\n\nimport { DocumentPreviewData } from \"@/lib/types/document-preview\";\nimport { cn } from \"@/lib/utils\";\n\ninterface PreviewImageViewerProps {\n  documentData: DocumentPreviewData;\n  onClose: () => void;\n}\n\nexport function PreviewImageViewer({\n  documentData,\n  onClose,\n}: PreviewImageViewerProps) {\n  const [imageLoaded, setImageLoaded] = useState(false);\n\n  const { file, documentName } = documentData;\n\n  if (!file) {\n    return (\n      <div className=\"flex h-full w-full items-center justify-center\">\n        <p className=\"text-gray-400\">Image not available</p>\n      </div>\n    );\n  }\n\n  const handleImageLoad = () => {\n    setImageLoaded(true);\n  };\n\n  return (\n    <div className=\"relative h-full w-full overflow-hidden\">\n      {/* Document Title */}\n      <div className=\"absolute left-1/2 top-4 z-50 -translate-x-1/2\">\n        <div className=\"rounded-lg bg-black/20 px-3 py-2 text-white\">\n          <span className=\"text-sm font-medium\">{documentName}</span>\n        </div>\n      </div>\n\n      {/* Image Content */}\n      <div className=\"flex h-full w-full items-center justify-center p-4\">\n        <div className=\"relative max-h-full max-w-full\">\n          {!imageLoaded && (\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <div className=\"h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent\"></div>\n            </div>\n          )}\n\n          <img\n            src={file}\n            alt={documentName}\n            className={cn(\n              \"max-h-[calc(100vh-120px)] max-w-full object-contain transition-opacity duration-200\",\n              imageLoaded ? \"opacity-100\" : \"opacity-0\",\n            )}\n            onLoad={handleImageLoad}\n            onError={() => setImageLoaded(true)}\n            draggable={false}\n            onContextMenu={(e) => e.preventDefault()}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/preview-viewers/preview-pages-viewer.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\n\nimport { DocumentPreviewData } from \"@/lib/types/document-preview\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\n\nconst PRELOAD_RADIUS = 5;\n\ninterface PreviewPagesViewerProps {\n  documentData: DocumentPreviewData;\n  onClose: () => void;\n  pagesApiEndpoint?: string;\n}\n\nexport function PreviewPagesViewer({\n  documentData,\n  onClose,\n  pagesApiEndpoint,\n}: PreviewPagesViewerProps) {\n  const [currentPage, setCurrentPage] = useState(1);\n  const [imageCache, setImageCache] = useState<{ [key: number]: boolean }>({});\n  const [imageLoaded, setImageLoaded] = useState(imageCache[1] || false);\n  const [pages, setPages] = useState(documentData.pages ?? []);\n  const pagesRef = useRef(pages);\n  const pendingRef = useRef<Set<number>>(new Set());\n  const generationRef = useRef(0);\n\n  const { numPages, documentName, isVertical } = documentData;\n\n  useEffect(() => {\n    pagesRef.current = pages;\n  }, [pages]);\n\n  useEffect(() => {\n    setPages(documentData.pages ?? []);\n    pagesRef.current = documentData.pages ?? [];\n    pendingRef.current = new Set();\n    generationRef.current += 1;\n  }, [documentData.pages]);\n\n  const ensurePagesLoaded = useCallback(\n    async (centerPage: number) => {\n      if (!pagesApiEndpoint) return;\n\n      const generation = generationRef.current;\n      const currentPages = pagesRef.current;\n      const start = Math.max(1, centerPage - PRELOAD_RADIUS);\n      const end = Math.min(numPages, centerPage + PRELOAD_RADIUS);\n      const needed: number[] = [];\n\n      for (let i = start; i <= end; i++) {\n        if (\n          !currentPages[i - 1]?.file &&\n          !pendingRef.current.has(i) &&\n          i <= currentPages.length\n        ) {\n          needed.push(i);\n        }\n      }\n\n      if (needed.length === 0) return;\n\n      needed.forEach((pn) => pendingRef.current.add(pn));\n\n      try {\n        const response = await fetch(pagesApiEndpoint, {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify({ pageNumbers: needed }),\n        });\n\n        if (generationRef.current !== generation) return;\n\n        if (response.ok) {\n          const data = await response.json();\n          setPages((prev) => {\n            const updated = [...prev];\n            for (const fetchedPage of data.pages) {\n              const idx = fetchedPage.pageNumber - 1;\n              if (idx >= 0 && idx < updated.length && updated[idx]) {\n                updated[idx] = {\n                  ...updated[idx],\n                  file: fetchedPage.file,\n                };\n              }\n            }\n            return updated;\n          });\n        }\n      } finally {\n        if (generationRef.current === generation) {\n          needed.forEach((pn) => pendingRef.current.delete(pn));\n        }\n      }\n    },\n    [pagesApiEndpoint, numPages],\n  );\n\n  useEffect(() => {\n    ensurePagesLoaded(currentPage);\n  }, [currentPage, ensurePagesLoaded]);\n\n  const goToNextPage = useCallback(() => {\n    if (currentPage < numPages) {\n      setCurrentPage(currentPage + 1);\n      setImageLoaded(imageCache[currentPage + 1] || false);\n    }\n  }, [currentPage, numPages, imageCache]);\n\n  const goToPreviousPage = useCallback(() => {\n    if (currentPage > 1) {\n      setCurrentPage(currentPage - 1);\n      setImageLoaded(imageCache[currentPage - 1] || false);\n    }\n  }, [currentPage, numPages, imageCache]);\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      switch (e.key) {\n        case \"ArrowLeft\":\n          goToPreviousPage();\n          break;\n        case \"ArrowRight\":\n          goToNextPage();\n          break;\n        case \"Escape\":\n          onClose();\n          break;\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n  }, [goToPreviousPage, goToNextPage, onClose]);\n\n  if (!pages || pages.length === 0) {\n    return (\n      <div className=\"flex h-full w-full items-center justify-center\">\n        <p className=\"text-gray-400\">No pages available for preview</p>\n      </div>\n    );\n  }\n\n  const currentPageData = pages[currentPage - 1];\n\n  const handleImageLoad = () => {\n    setImageLoaded(true);\n    setImageCache((prev) => ({ ...prev, [currentPage]: true }));\n  };\n\n  if (!currentPageData) {\n    return (\n      <div className=\"flex h-full w-full items-center justify-center\">\n        <p className=\"text-gray-400\">Page not found</p>\n      </div>\n    );\n  }\n\n  const hasFileUrl = !!currentPageData.file;\n\n  return (\n    <div className=\"relative h-full w-full overflow-hidden\">\n      {/* Navigation Controls */}\n      <div className=\"absolute left-4 top-4 z-50\">\n        <div className=\"flex items-center gap-2 rounded-lg bg-black/20 px-3 py-2 text-white\">\n          <span className=\"text-sm\">\n            Page {currentPage} of {numPages}\n          </span>\n        </div>\n      </div>\n\n      {/* Document Title */}\n      <div className=\"absolute left-1/2 top-4 z-50 -translate-x-1/2\">\n        <div className=\"rounded-lg bg-black/20 px-3 py-2 text-white\">\n          <span className=\"text-sm font-medium\">{documentName}</span>\n        </div>\n      </div>\n\n      {/* Previous Page Button */}\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        onClick={goToPreviousPage}\n        disabled={currentPage <= 1}\n        className={cn(\n          \"absolute left-4 top-1/2 z-40 h-12 w-12 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40\",\n          currentPage <= 1 && \"cursor-not-allowed opacity-50\",\n        )}\n      >\n        <ChevronLeftIcon className=\"h-6 w-6\" />\n      </Button>\n\n      {/* Next Page Button */}\n      <Button\n        variant=\"ghost\"\n        size=\"icon\"\n        onClick={goToNextPage}\n        disabled={currentPage >= numPages}\n        className={cn(\n          \"absolute right-4 top-1/2 z-40 h-12 w-12 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40\",\n          currentPage >= numPages && \"cursor-not-allowed opacity-50\",\n        )}\n      >\n        <ChevronRightIcon className=\"h-6 w-6\" />\n      </Button>\n\n      {/* Page Content */}\n      <div className=\"flex h-full w-full items-center justify-center p-4\">\n        <div className=\"relative max-h-full max-w-full\">\n          {(!imageLoaded || !hasFileUrl) && (\n            <div className=\"absolute inset-0 flex items-center justify-center\">\n              <div className=\"h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent\"></div>\n            </div>\n          )}\n\n          {hasFileUrl && (\n            <img\n              src={currentPageData.file!}\n              alt={`Page ${currentPage}`}\n              className={cn(\n                \"max-h-[calc(100vh-120px)] max-w-full object-contain transition-opacity duration-200\",\n                imageLoaded ? \"opacity-100\" : \"opacity-0\",\n              )}\n              onLoad={handleImageLoad}\n              onError={() => {\n                setImageLoaded(false);\n                setImageCache((prev) => ({ ...prev, [currentPage]: false }));\n\n                const idx = currentPage - 1;\n                const current = pagesRef.current;\n                if (idx >= 0 && idx < current.length && current[idx]) {\n                  const updated = [...current];\n                  updated[idx] = { ...updated[idx], file: \"\" };\n                  pagesRef.current = updated;\n                  setPages(updated);\n                }\n                pendingRef.current.delete(currentPage);\n                ensurePagesLoaded(currentPage);\n              }}\n              draggable={false}\n              onContextMenu={(e) => e.preventDefault()}\n            />\n          )}\n        </div>\n      </div>\n\n      {/* Bottom Navigation */}\n      <div className=\"absolute bottom-4 left-1/2 z-50 -translate-x-1/2\">\n        <div className=\"flex items-center gap-2 rounded-lg bg-black/20 px-4 py-2\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={goToPreviousPage}\n            disabled={currentPage <= 1}\n            className=\"h-8 px-3 text-white hover:bg-white/10\"\n          >\n            Previous\n          </Button>\n\n          <span className=\"text-sm text-white\">\n            {currentPage} / {numPages}\n          </span>\n\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={goToNextPage}\n            disabled={currentPage >= numPages}\n            className=\"h-8 px-3 text-white hover:bg-white/10\"\n          >\n            Next\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/preview-viewers/preview-viewer.tsx",
    "content": "import { useTeam } from \"@/context/team-context\";\n\nimport { DocumentPreviewData } from \"@/lib/types/document-preview\";\n\nimport { PreviewExcelViewer } from \"./preview-excel-viewer\";\nimport { PreviewImageViewer } from \"./preview-image-viewer\";\nimport { PreviewPagesViewer } from \"./preview-pages-viewer\";\n\ninterface PreviewViewerProps {\n  documentData: DocumentPreviewData;\n  onClose: () => void;\n}\n\nexport function PreviewViewer({ documentData, onClose }: PreviewViewerProps) {\n  const { currentTeamId } = useTeam();\n\n  const previewPagesEndpoint = currentTeamId\n    ? `/api/teams/${currentTeamId}/documents/${documentData.documentId}/preview-pages`\n    : undefined;\n\n  const renderViewer = () => {\n    // Documents with pages (PDFs, docs, slides)\n    if (documentData.pages && documentData.pages.length > 0) {\n      return (\n        <PreviewPagesViewer\n          documentData={documentData}\n          onClose={onClose}\n          pagesApiEndpoint={previewPagesEndpoint}\n        />\n      );\n    }\n\n    // Single image files\n    if (documentData.fileType === \"image\" && documentData.file) {\n      return (\n        <PreviewImageViewer documentData={documentData} onClose={onClose} />\n      );\n    }\n\n    // Excel/CSV files with advanced mode\n    if (\n      documentData.fileType === \"sheet\" &&\n      documentData.advancedExcelEnabled &&\n      documentData.file\n    ) {\n      return (\n        <PreviewExcelViewer documentData={documentData} onClose={onClose} />\n      );\n    }\n\n    // Excel/CSV files without advanced mode\n    if (documentData.fileType === \"sheet\") {\n      return (\n        <div className=\"flex h-full w-full items-center justify-center\">\n          <div className=\"text-center\">\n            <p className=\"text-gray-400\">\n              Enable advanced Excel mode to preview this document.\n            </p>\n          </div>\n        </div>\n      );\n    }\n\n    // Notion documents (not fully supported yet)\n    if (documentData.fileType === \"notion\") {\n      return (\n        <div className=\"flex h-full w-full items-center justify-center\">\n          <div className=\"text-center\">\n            <p className=\"text-gray-400\">Notion document preview coming soon</p>\n          </div>\n        </div>\n      );\n    }\n\n    // Fallback for unsupported types\n    return (\n      <div className=\"flex h-full w-full items-center justify-center\">\n        <div className=\"text-center\">\n          <p className=\"text-gray-400\">\n            Preview not available for this document type\n          </p>\n        </div>\n      </div>\n    );\n  };\n\n  return (\n    <div className=\"relative h-full w-full rounded-lg bg-gray-900\">\n      {renderViewer()}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/stats-card.tsx",
    "content": "import ErrorPage from \"next/error\";\n\nimport { TStatsData } from \"@/lib/swr/use-stats\";\n\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nimport StatsElement from \"./stats-element\";\n\nexport default function StatsCard({\n  statsData,\n}: {\n  statsData: { stats: TStatsData | undefined; loading: boolean; error: any };\n}) {\n  const { stats, loading, error } = statsData;\n\n  if (error && error.status === 404) {\n    return <ErrorPage statusCode={404} />;\n  }\n\n  if (loading) {\n    return (\n      <div className=\"grid grid-cols-1 space-y-2 border-foreground/5 sm:grid-cols-3 sm:space-x-2 sm:space-y-0 lg:grid-cols-3 lg:space-x-3\">\n        {Array.from({ length: 3 }).map((_, i) => (\n          <div\n            className=\"rounded-lg border border-foreground/5 px-4 py-6 sm:px-6 lg:px-8\"\n            key={i}\n          >\n            <Skeleton className=\"h-6 w-[80%] rounded-sm\" />\n            <Skeleton className=\"mt-4 h-8 w-9\" />\n          </div>\n        ))}\n      </div>\n    );\n  }\n\n  const statistics = [\n    {\n      name: \"Number of views\",\n      value: stats?.totalViews.toString() ?? \"0\",\n      active: true,\n    },\n    {\n      name: \"Average view completion\",\n      value: `${stats?.avgCompletionRate ?? 0}%`,\n      active: true,\n    },\n    {\n      name: \"Total average view duration\",\n      value:\n        stats?.total_duration == null\n          ? \"46\"\n          : stats?.total_duration < 60000\n            ? `${Math.round(stats?.total_duration / 1000)}`\n            : `${Math.floor(stats?.total_duration / 60000)}:${\n                Math.round((stats?.total_duration % 60000) / 1000) < 10\n                  ? `0${Math.round((stats?.total_duration % 60000) / 1000)}`\n                  : Math.round((stats?.total_duration % 60000) / 1000)\n              }`,\n      unit: stats?.total_duration! < 60000 ? \"seconds\" : \"minutes\",\n      active: stats?.total_duration ? true : false,\n    },\n  ];\n\n  return stats && stats.views.length > 0 ? (\n    <div className=\"grid grid-cols-1 space-y-2 border-foreground/5 sm:grid-cols-3 sm:space-x-2 sm:space-y-0 lg:grid-cols-3 lg:space-x-3\">\n      {statistics.map((stat, statIdx) => (\n        <StatsElement key={statIdx} stat={stat} statIdx={statIdx} />\n      ))}\n    </div>\n  ) : null;\n}\n"
  },
  {
    "path": "components/documents/stats-chart-dummy.tsx",
    "content": "import BarChartComponent from \"../charts/bar-chart\";\n\nexport default function StatsChartDummy({\n  totalPagesMax = 0,\n}: {\n  totalPagesMax?: number;\n}) {\n  let durationData = Array.from({ length: totalPagesMax }, (_, i) => ({\n    pageNumber: (i + 1).toString(),\n    data: [\n      {\n        versionNumber: 1,\n        avg_duration: 16000 / (i + 1),\n      },\n    ],\n  }));\n\n  return (\n    <div className=\"rounded-bl-lg border-b border-l pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n      <BarChartComponent data={durationData} isDummy />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/stats-chart-skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\";\n\nimport { Skeleton } from \"../ui/skeleton\";\n\nconst StatsChartSkeleton = ({ className }: { className?: string }) => {\n  return (\n    <section className={cn(\"rounded-bl-lg border-b border-l px-4\", className)}>\n      <div className=\"flex items-center justify-end\">\n        <Skeleton className=\"h-4 w-28\" />\n      </div>\n      <div className=\"flex items-end justify-start space-x-8\">\n        <div className=\"flex flex-col\">\n          {Array.from({ length: 5 }).map((_, i) => (\n            <Skeleton className=\"my-[18px] h-4 w-10 rounded-sm\" key={i} />\n          ))}\n        </div>\n        <div className=\"flex w-full items-end space-x-4 sm:space-x-5 md:space-x-8\">\n          {[250, 200, 150, 100, 50, 20].map((item, i) => (\n            <Skeleton\n              className=\"w-16 !rounded-t-lg rounded-b-none sm:w-20 md:w-28\"\n              style={{ height: `${item}px` }}\n              key={i}\n            />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default StatsChartSkeleton;\n"
  },
  {
    "path": "components/documents/stats-chart.tsx",
    "content": "import ErrorPage from \"next/error\";\n\nimport { TStatsData } from \"@/lib/swr/use-stats\";\n\nimport BarChartComponent from \"../charts/bar-chart\";\nimport StatsChartDummy from \"./stats-chart-dummy\";\nimport StatsChartSkeleton from \"./stats-chart-skeleton\";\n\nexport default function StatsChart({\n  documentId,\n  statsData,\n  totalPagesMax = 0,\n}: {\n  documentId: string;\n  statsData: { stats: TStatsData | undefined; loading: boolean; error: any };\n  totalPagesMax?: number;\n}) {\n  const { stats, loading, error } = statsData;\n\n  if (error && error.status === 404) {\n    return <ErrorPage statusCode={404} />;\n  }\n\n  if (loading) {\n    return <StatsChartSkeleton className=\"my-8\" />;\n  }\n\n  let durationData = Array.from({ length: totalPagesMax }, (_, i) => ({\n    pageNumber: (i + 1).toString(),\n    data: [\n      {\n        versionNumber: 1,\n        avg_duration: 0,\n      },\n    ],\n  }));\n\n  const swrData = stats?.duration;\n\n  if (swrData) {\n    swrData.data.forEach((dataItem) => {\n      const pageIndex = durationData.findIndex(\n        (item) => item.pageNumber === dataItem.pageNumber,\n      );\n\n      if (pageIndex !== -1) {\n        // If page exists in the initialized array, update its data\n        const versionIndex = durationData[pageIndex].data.findIndex(\n          (v) => v.versionNumber === dataItem.versionNumber,\n        );\n        if (versionIndex === -1) {\n          // If this version number doesn't exist, add it\n          durationData[pageIndex].data.push({\n            versionNumber: dataItem.versionNumber,\n            avg_duration: dataItem.avg_duration,\n          });\n        } else {\n          // Update existing data for this version\n          durationData[pageIndex].data[versionIndex] = {\n            ...durationData[pageIndex].data[versionIndex],\n            avg_duration: dataItem.avg_duration,\n          };\n        }\n      } else {\n        // If this page number doesn't exist, add it with the version data\n        durationData.push({\n          pageNumber: dataItem.pageNumber,\n          data: [\n            {\n              versionNumber: dataItem.versionNumber,\n              avg_duration: dataItem.avg_duration,\n            },\n          ],\n        });\n      }\n    });\n\n    // Sort by page number\n    durationData.sort(\n      (a, b) => parseInt(a.pageNumber) - parseInt(b.pageNumber),\n    );\n  }\n\n  return stats && stats.views.length > 0 ? (\n    <div className=\"rounded-bl-lg border-b border-l pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n      <BarChartComponent data={durationData} />\n    </div>\n  ) : (\n    <StatsChartDummy totalPagesMax={totalPagesMax} />\n  );\n}\n"
  },
  {
    "path": "components/documents/stats-element.tsx",
    "content": "import { cn } from \"@/lib/utils\";\n\ninterface Stat {\n  name: string;\n  value: string;\n  unit?: string;\n  active: boolean;\n}\ninterface StatsElementProps {\n  stat: Stat;\n  statIdx: number;\n}\n\nexport default function StatsElement({ stat, statIdx }: StatsElementProps) {\n  return (\n    <div\n      key={statIdx}\n      className=\"overflow-hidden rounded-lg border border-foreground/5 px-6 py-6 xl:px-8\"\n    >\n      <div\n        className={cn(\n          \"flex items-center space-x-2 sm:flex-col sm:items-start sm:space-x-0 sm:space-y-2 lg:flex-row lg:items-center lg:space-x-2 lg:space-y-0\",\n          !stat.active\n            ? \"text-gray-300 dark:text-gray-700\"\n            : \"text-muted-foreground\",\n        )}\n      >\n        <p className=\"whitespace-nowrap text-sm font-medium capitalize leading-6\">\n          {stat.name}\n        </p>\n      </div>\n\n      <p className=\"mt-3 flex items-baseline gap-x-2\">\n        <span\n          className={cn(\n            !stat.active\n              ? \"text-gray-300 dark:text-gray-700\"\n              : \"text-foreground\",\n            \"text-4xl font-semibold tracking-tight \",\n          )}\n        >\n          {stat.value}\n        </span>\n        {stat.unit ? (\n          <span\n            className={cn(\n              !stat.active\n                ? \"text-gray-300 dark:text-gray-700\"\n                : \"text-muted-foreground\",\n              \"text-sm\",\n            )}\n          >\n            {stat.unit}\n          </span>\n        ) : null}\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/stats.tsx",
    "content": "import { useRouter, useSearchParams } from \"next/navigation\";\n\nimport { useState } from \"react\";\n\nimport { useStats } from \"@/lib/swr/use-stats\";\n\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\n\nimport StatsCard from \"./stats-card\";\nimport StatsChart from \"./stats-chart\";\n\nexport const StatsComponent = ({\n  documentId,\n  numPages,\n}: {\n  documentId: string;\n  numPages: number;\n}) => {\n  const searchParams = useSearchParams();\n  const router = useRouter();\n\n  const initialExclude = searchParams?.get(\"excludeInternal\") === \"true\";\n  const [excludeTeamMembers, setExcludeTeamMembers] =\n    useState<boolean>(initialExclude);\n\n  const statsData = useStats({ excludeTeamMembers });\n\n  const onToggle = (checked: boolean) => {\n    setExcludeTeamMembers(checked);\n    const params = new URLSearchParams(searchParams?.toString());\n    params.set(\"excludeInternal\", checked.toString());\n    router.push(`${documentId}/?${params.toString()}`);\n  };\n\n  return (\n    <>\n      <div className=\"flex items-center justify-end space-x-2\">\n        <Switch\n          disabled={statsData.loading || statsData.error}\n          id=\"toggle-stats\"\n          checked={excludeTeamMembers}\n          onCheckedChange={onToggle}\n        />\n        <Label\n          htmlFor=\"toggle-stats\"\n          className={excludeTeamMembers ? \"\" : \"text-muted-foreground\"}\n        >\n          Exclude internal views\n        </Label>\n      </div>\n\n      {/* Stats Chart */}\n      <StatsChart\n        documentId={documentId}\n        totalPagesMax={numPages}\n        statsData={statsData}\n      />\n\n      {/* Stats Card */}\n      <StatsCard statsData={statsData} />\n    </>\n  );\n};\n"
  },
  {
    "path": "components/documents/video-analytics.tsx",
    "content": "import { DocumentVersion } from \"@prisma/client\";\nimport { formatDistanceToNow } from \"date-fns\";\nimport {\n  Area,\n  AreaChart,\n  ResponsiveContainer,\n  Tooltip,\n  XAxis,\n  YAxis,\n} from \"recharts\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport StatsElement from \"@/components/documents/stats-element\";\nimport VideoChartPlaceholder from \"@/components/documents/video-chart-placeholder\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\n\ninterface VideoAnalyticsProps {\n  teamId: string;\n  documentId: string;\n  primaryVersion: DocumentVersion;\n}\n\nexport default function VideoAnalytics({\n  teamId,\n  documentId,\n  primaryVersion,\n}: VideoAnalyticsProps) {\n  const { data, error, isLoading } = useSWR<{\n    overall: {\n      unique_views: number;\n      total_views: number;\n      total_watch_time: number;\n      avg_view_duration: number;\n      last_viewed_at: string;\n      first_viewed_at: string;\n      view_distribution: Array<{\n        start_time: number;\n        unique_views: number;\n        total_views: number;\n      }>;\n    } | null;\n  }>(`/api/teams/${teamId}/documents/${documentId}/video-analytics`, fetcher);\n\n  if (error) {\n    console.error(\"Error loading video analytics:\", error);\n    return null;\n  }\n\n  if (isLoading) {\n    return (\n      <Card>\n        <CardContent className=\"flex h-[300px] items-center justify-center\">\n          <LoadingSpinner />\n        </CardContent>\n      </Card>\n    );\n  }\n\n  if (!data?.overall) {\n    const emptyStats = [\n      {\n        name: \"Total views\",\n        value: \"0\",\n        active: false,\n      },\n      {\n        name: \"Watch time\",\n        value: \"0:00\",\n        unit: \"minutes\",\n        active: false,\n      },\n      {\n        name: \"Average view duration\",\n        value: \"0:00\",\n        unit: \"minutes\",\n        active: false,\n      },\n    ];\n\n    return (\n      <div className=\"space-y-4\">\n        <VideoChartPlaceholder length={primaryVersion.length} />\n        <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n          {emptyStats.map((stat, index) => (\n            <StatsElement key={stat.name} stat={stat} statIdx={index} />\n          ))}\n        </div>\n      </div>\n    );\n  }\n\n  const formatTime = (seconds: number) => {\n    const minutes = Math.floor(seconds / 60);\n    const remainingSeconds = Math.floor(seconds % 60);\n    return `${minutes}:${remainingSeconds.toString().padStart(2, \"0\")}`;\n  };\n\n  const formatTooltipTime = (value: number) => {\n    return formatTime(value);\n  };\n\n  const CustomTooltip = ({ active, payload, label }: any) => {\n    if (active && payload && payload.length) {\n      const uniqueViews = payload[1].value;\n      const playbackCount = payload[0].value;\n      const intensity = playbackCount / uniqueViews || 1;\n\n      return (\n        <div className=\"space-y-1 rounded-md border bg-background p-2 text-sm\">\n          <p className=\"font-medium\">{formatTooltipTime(label)}</p>\n          <div className=\"space-y-0.5 text-muted-foreground\">\n            <p className=\"flex items-center gap-2\">\n              <span className=\"h-2 w-2 rounded-full bg-emerald-500\" />\n              {uniqueViews} unique viewer{uniqueViews !== 1 ? \"s\" : \"\"}\n            </p>\n            <p className=\"flex items-center gap-2\">\n              <span className=\"h-2 w-2 rounded-full bg-[#3B82F6]\" />\n              {playbackCount} playback{playbackCount !== 1 ? \"s\" : \"\"}\n            </p>\n            <p className=\"pt-1 text-xs\">\n              Replayed {(intensity - 1).toFixed(1)}x on average\n            </p>\n          </div>\n        </div>\n      );\n    }\n    return null;\n  };\n\n  const stats = [\n    {\n      name: \"Total views\",\n      value: data.overall.unique_views.toString(),\n      active: true,\n    },\n    {\n      name: \"View time\",\n      value: formatTime(data.overall.total_watch_time),\n      unit: \"minutes\",\n      active: true,\n    },\n    {\n      name: \"Average view duration\",\n      value: formatTime(data.overall.avg_view_duration),\n      unit: \"minutes\",\n      active: true,\n    },\n  ];\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"mt-2 flex items-center justify-center gap-4 text-sm text-muted-foreground\">\n        <div className=\"flex items-center gap-2\">\n          <div className=\"h-3 w-3 rounded-full bg-emerald-500\" />\n          <span>Unique Viewers</span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <div className=\"h-3 w-3 rounded-full bg-[#3B82F6]\" />\n          <span>Playback Count</span>\n        </div>\n      </div>\n      <div className=\"-mx-9 h-[300px]\">\n        <ResponsiveContainer width=\"100%\" height=\"100%\">\n          <AreaChart\n            data={data.overall.view_distribution}\n            margin={{ top: 10, right: 10, left: 0, bottom: 0 }}\n          >\n            <XAxis\n              dataKey=\"start_time\"\n              tickFormatter={formatTooltipTime}\n              stroke=\"#888888\"\n              fontSize={12}\n            />\n            <YAxis\n              stroke=\"#888888\"\n              fontSize={12}\n              tickFormatter={(value) => Math.floor(value).toString()}\n              domain={[0, (dataMax: number) => dataMax + 1]}\n              padding={{ top: 20 }}\n              allowDecimals={false}\n            />\n            <Tooltip content={<CustomTooltip />} />\n            <defs>\n              <linearGradient\n                id=\"uniqueViewsGradient\"\n                x1=\"0\"\n                y1=\"0\"\n                x2=\"0\"\n                y2=\"1\"\n              >\n                <stop offset=\"5%\" stopColor=\"#10b981\" stopOpacity={0.3} />\n                <stop offset=\"95%\" stopColor=\"#10b981\" stopOpacity={0} />\n              </linearGradient>\n              <linearGradient id=\"playbackGradient\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                <stop offset=\"5%\" stopColor=\"#3B82F6\" stopOpacity={0.3} />\n                <stop offset=\"95%\" stopColor=\"#3B82F6\" stopOpacity={0} />\n              </linearGradient>\n            </defs>\n            <Area\n              type=\"monotone\"\n              dataKey=\"total_views\"\n              stroke=\"#3B82F6\"\n              strokeWidth={2}\n              fill=\"url(#playbackGradient)\"\n              dot={false}\n              name=\"Playback Count\"\n            />\n            <Area\n              type=\"monotone\"\n              dataKey=\"unique_views\"\n              stroke=\"#10b981\"\n              strokeWidth={2}\n              fill=\"url(#uniqueViewsGradient)\"\n              dot={false}\n              name=\"Unique Viewers\"\n            />\n          </AreaChart>\n        </ResponsiveContainer>\n      </div>\n\n      <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n        {stats.map((stat, index) => (\n          <StatsElement key={stat.name} stat={stat} statIdx={index} />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/video-chart-placeholder.tsx",
    "content": "import {\n  Area,\n  AreaChart,\n  ResponsiveContainer,\n  Tooltip,\n  XAxis,\n  YAxis,\n} from \"recharts\";\n\nexport default function VideoChartPlaceholder({\n  length,\n}: {\n  length: number | null;\n}) {\n  const videoLength = length || 51;\n  // Generate placeholder data with a nice curve\n  const data = Array.from({ length: videoLength }, (_, i) => ({\n    start_time: i,\n    unique_views:\n      i < videoLength / 3 ? 10 : i > videoLength - videoLength / 3 ? 3 : 6,\n  }));\n\n  const formatTime = (seconds: number) => {\n    const minutes = Math.floor(seconds / 60);\n    const remainingSeconds = Math.floor(seconds % 60);\n    return `${minutes}:${remainingSeconds.toString().padStart(2, \"0\")}`;\n  };\n\n  const CustomTooltip = ({ active, payload, label }: any) => {\n    if (active && payload && payload.length) {\n      return (\n        <div className=\"space-y-1 rounded-md border bg-background p-2 text-sm\">\n          <p className=\"text-xs text-muted-foreground\">Example Data</p>\n          <p className=\"font-medium\">{formatTime(label)}</p>\n          <div className=\"space-y-0.5 text-muted-foreground\">\n            <p className=\"flex items-center gap-2\">\n              <span className=\"h-2 w-2 rounded-full bg-gray-400\" />\n              {payload[0].value} unique viewers\n            </p>\n            <p className=\"flex items-center gap-2\">\n              <span className=\"h-2 w-2 rounded-full bg-gray-600\" />\n              {payload[0].value} playbacks\n            </p>\n          </div>\n        </div>\n      );\n    }\n    return null;\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"-ml-9 h-[300px]\">\n        <ResponsiveContainer width=\"100%\" height=\"100%\">\n          <AreaChart\n            data={data}\n            margin={{ top: 10, right: 10, left: 0, bottom: 0 }}\n          >\n            <XAxis\n              dataKey=\"start_time\"\n              tickFormatter={formatTime}\n              stroke=\"#888888\"\n              fontSize={12}\n            />\n            <YAxis\n              stroke=\"#888888\"\n              fontSize={12}\n              tickFormatter={(value) => Math.floor(value).toString()}\n              domain={[0, 1]}\n              padding={{ top: 20 }}\n              allowDecimals={false}\n            />\n            <Tooltip content={<CustomTooltip />} />\n            <defs>\n              <linearGradient\n                id=\"uniqueViewsGradient\"\n                x1=\"0\"\n                y1=\"0\"\n                x2=\"0\"\n                y2=\"1\"\n              >\n                <stop offset=\"5%\" stopColor=\"#9CA3AF\" stopOpacity={0.3} />\n                <stop offset=\"95%\" stopColor=\"#9CA3AF\" stopOpacity={0} />\n              </linearGradient>\n            </defs>\n            <Area\n              type=\"monotone\"\n              dataKey=\"unique_views\"\n              stroke=\"#9CA3AF\"\n              strokeWidth={2}\n              fill=\"url(#uniqueViewsGradient)\"\n              dot={false}\n              name=\"Unique Viewers\"\n            />\n          </AreaChart>\n        </ResponsiveContainer>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/documents/video-stats-placeholder.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\n\nimport VideoChartPlaceholder from \"./video-chart-placeholder\";\n\ninterface VideoStatsPlaceholderProps {\n  length: number;\n  onCreateLink: () => void;\n}\n\nexport default function VideoStatsPlaceholder({\n  length,\n  onCreateLink,\n}: VideoStatsPlaceholderProps) {\n  const formatTime = (seconds: number) => {\n    const minutes = Math.floor(seconds / 60);\n    const remainingSeconds = Math.floor(seconds % 60);\n    return `${minutes}:${remainingSeconds.toString().padStart(2, \"0\")}`;\n  };\n\n  const mockStats = [\n    {\n      name: \"Total views\",\n      value: \"0\",\n      active: false,\n    },\n    {\n      name: \"Watch time\",\n      value: \"0:00\",\n      unit: \"minutes\",\n      active: false,\n    },\n    {\n      name: \"Average view duration\",\n      value: \"0:00\",\n      unit: \"minutes\",\n      active: false,\n    },\n  ];\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Video Chart Placeholder */}\n      <VideoChartPlaceholder length={length} />\n\n      {/* Stats Cards */}\n      <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n        {mockStats.map((stat, index) => (\n          <div\n            key={stat.name}\n            className=\"rounded-lg border border-foreground/5 px-4 py-6 sm:px-6 lg:px-8\"\n          >\n            <dt className=\"text-sm font-medium text-gray-500\">{stat.name}</dt>\n            <dd className=\"mt-1 flex items-baseline justify-between md:block lg:flex\">\n              <div className=\"flex items-baseline text-2xl font-semibold text-gray-400\">\n                {stat.value}\n                {stat.unit && (\n                  <span className=\"ml-2 text-sm font-medium text-gray-300\">\n                    {stat.unit}\n                  </span>\n                )}\n              </div>\n            </dd>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/domains/add-domain-modal.tsx",
    "content": "import { type ElementType, useEffect, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LinkType } from \"@prisma/client\";\nimport { AlertTriangleIcon, CircleCheckIcon, InfoIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { useDebounce } from \"use-debounce\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { validDomainRegex } from \"@/lib/domains\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport { UpgradeButton } from \"../ui/upgrade-button\";\n\nconst sanitizeDomain = (value: string) =>\n  value\n    .trim()\n    .toLowerCase()\n    .replace(/^(?:https?:\\/\\/)?(?:www\\.)?/i, \"\")\n    .split(\"/\")[0];\n\ntype DomainStatus =\n  | \"checking\"\n  | \"has site\"\n  | \"available\"\n  | \"idle\"\n  | \"invalid\"\n  | \"error\";\n\nconst STATUS_CONFIG: Record<\n  DomainStatus,\n  {\n    prefix?: string;\n    useStrong?: boolean;\n    suffix?: string;\n    icon?: ElementType;\n    className?: string;\n    message?: string;\n  }\n> = {\n  checking: {\n    prefix: \"Checking availability for\",\n    useStrong: true,\n    suffix: \"...\",\n    icon: LoadingSpinner,\n    className: \"bg-neutral-100 text-neutral-500\",\n  },\n  \"has site\": {\n    suffix:\n      \"is currently pointing to an existing website. Only proceed if you're sure you want to use this domain for Papermark links.\",\n    icon: InfoIcon,\n    className: \"bg-blue-100 text-blue-800\",\n  },\n  available: {\n    suffix: \"is ready to connect.\",\n    icon: CircleCheckIcon,\n    className: \"bg-emerald-100 text-emerald-600\",\n  },\n  invalid: {\n    message: \"Enter a valid domain to check availability.\",\n    icon: AlertTriangleIcon,\n    className: \"bg-rose-100 text-rose-600\",\n  },\n  idle: {\n    message: \"Enter a valid domain to check availability.\",\n    className: \"bg-neutral-100 text-neutral-500\",\n  },\n  error: {\n    message: \"We couldn't check this domain right now. Try again.\",\n    icon: AlertTriangleIcon,\n    className: \"bg-rose-100 text-rose-600\",\n  },\n};\n\nexport function AddDomainModal({\n  open,\n  setOpen,\n  onAddition,\n  linkType,\n  children,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  onAddition?: (newDomain: string) => void;\n  linkType?: Omit<LinkType, \"WORKFLOW_LINK\">;\n  children?: React.ReactNode;\n}) {\n  const [domainInput, setDomainInput] = useState<string>(\"\");\n  const [submitting, setSubmitting] = useState<boolean>(false);\n  const [domainStatus, setDomainStatus] = useState<DomainStatus>(\"idle\");\n  const [statusMessageOverride, setStatusMessageOverride] = useState<\n    string | null\n  >(null);\n  const abortRef = useRef<AbortController | null>(null);\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { isFree, isPro, isBusiness } = usePlan();\n  const { limits } = useLimits();\n  const analytics = useAnalytics();\n\n  useEffect(() => {\n    if (!open) {\n      setDomainInput(\"\");\n      setSubmitting(false);\n      setDomainStatus(\"idle\");\n      setStatusMessageOverride(null);\n    }\n  }, [open]);\n\n  const sanitizedDomain = sanitizeDomain(domainInput);\n  const [debouncedDomain] = useDebounce(sanitizedDomain, 500);\n\n  useEffect(() => {\n    if (!open) return;\n    if (!teamId) return;\n\n    if (!debouncedDomain) {\n      setDomainStatus(\"idle\");\n      setStatusMessageOverride(null);\n      return;\n    }\n\n    if (debouncedDomain.includes(\"papermark\")) {\n      setDomainStatus(\"invalid\");\n      setStatusMessageOverride(\"Domain cannot contain 'papermark'.\");\n      return;\n    }\n\n    if (!validDomainRegex.test(debouncedDomain)) {\n      setDomainStatus(\"idle\");\n      setStatusMessageOverride(null);\n      return;\n    }\n\n    // Abort any in-flight validation request before starting a new one\n    abortRef.current?.abort();\n    const controller = new AbortController();\n    abortRef.current = controller;\n\n    setDomainStatus(\"checking\");\n    setStatusMessageOverride(null);\n\n    fetch(\n      `/api/teams/${teamId}/domains/${encodeURIComponent(\n        debouncedDomain,\n      )}/validate`,\n      { signal: controller.signal },\n    )\n      .then(async (res) => res.json())\n      .then((data) => {\n        const nextStatus = data?.status as DomainStatus | undefined;\n        if (\n          nextStatus &&\n          [\"invalid\", \"has site\", \"available\"].includes(nextStatus)\n        ) {\n          setDomainStatus(nextStatus);\n        } else {\n          setDomainStatus(\"error\");\n        }\n      })\n      .catch((err) => {\n        // Ignore aborted requests – they are expected when the user types again\n        if ((err as DOMException).name === \"AbortError\") return;\n        setDomainStatus(\"error\");\n      });\n\n    return () => {\n      controller.abort();\n      abortRef.current = null;\n    };\n  }, [debouncedDomain, open, teamId]);\n\n  const saveDisabled =\n    ![\"available\", \"has site\"].includes(domainStatus) || submitting;\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const normalizedDomain = sanitizeDomain(domainInput);\n    if (!normalizedDomain || !validDomainRegex.test(normalizedDomain)) {\n      return toast.error(\"Please enter a valid domain (e.g., example.com).\");\n    }\n\n    if (normalizedDomain.includes(\"papermark\")) {\n      return toast.error(\"Domain cannot contain 'papermark'.\");\n    }\n\n    if (saveDisabled) {\n      return toast.error(\n        statusMessageOverride ?? \"Please enter a valid domain before adding.\",\n      );\n    }\n\n    setSubmitting(true);\n    try {\n      const response = await fetch(`/api/teams/${teamId}/domains`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          domain: normalizedDomain,\n        }),\n      });\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        toast.error(message);\n        return;\n      }\n\n      const newDomain = await response.json();\n\n      analytics.capture(\"Domain Added\", { slug: normalizedDomain });\n      toast.success(\"Domain added successfully! 🎉\");\n\n      // Update local data with the new link\n      onAddition && onAddition(newDomain);\n\n      setOpen(false);\n\n      !onAddition && window.open(\"/settings/domains\", \"_blank\");\n    } catch (error: unknown) {\n      const message =\n        error instanceof Error ? error.message : \"An unknown error occurred\";\n      toast.error(`Failed to add domain: ${message}`);\n    } finally {\n      setSubmitting(false);\n    }\n  };\n\n  // If the team is\n  // - on a free plan\n  // - on pro plan and has custom domain on pro plan disabled\n  // - on business plan and has custom domain in dataroom disabled\n  // => then show the upgrade modal\n  if (\n    isFree ||\n    (isPro && !limits?.customDomainOnPro) ||\n    (linkType === \"DATAROOM_LINK\" &&\n      isBusiness &&\n      !limits?.customDomainInDataroom)\n  ) {\n    if (children) {\n      return (\n        <UpgradeButton\n          text=\"Add Domain\"\n          clickedPlan={\n            linkType === \"DATAROOM_LINK\"\n              ? PlanEnum.DataRooms\n              : PlanEnum.Business\n          }\n          highlightItem={[\"custom-domain\"]}\n          trigger=\"add_domain_overview\"\n        />\n      );\n    } else {\n      return (\n        <UpgradePlanModal\n          clickedPlan={\n            linkType === \"DATAROOM_LINK\"\n              ? PlanEnum.DataRooms\n              : PlanEnum.Business\n          }\n          open={open}\n          setOpen={setOpen}\n          trigger={\"add_domain_link_sheet\"}\n          highlightItem={[\"custom-domain\"]}\n        />\n      );\n    }\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[520px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Add Domain</DialogTitle>\n          <DialogDescription>\n            Add a custom domain and verify it with DNS.\n          </DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"domain\" className=\"opacity-80\">\n            Your domain\n          </Label>\n          {(() => {\n            const currentStatus = STATUS_CONFIG[domainStatus];\n            const StatusIcon = currentStatus.icon;\n            return (\n              <div\n                className={cn(\n                  \"-m-1 mt-2 rounded-[0.625rem] p-1\",\n                  currentStatus.className || \"bg-neutral-100 text-neutral-500\",\n                )}\n              >\n                <div className=\"flex rounded-md border border-neutral-300 bg-white\">\n                  <Input\n                    id=\"domain\"\n                    placeholder=\"docs.yourdomain.com\"\n                    className=\"border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0\"\n                    value={domainInput}\n                    onBlur={() => {\n                      const normalized = sanitizeDomain(domainInput);\n                      if (normalized && normalized !== domainInput) {\n                        setDomainInput(normalized);\n                      }\n                    }}\n                    onChange={(e) => {\n                      // Cancel any in-flight validation so stale results\n                      // don't overwrite the reset status below\n                      abortRef.current?.abort();\n                      abortRef.current = null;\n                      setDomainStatus(\"idle\");\n                      setStatusMessageOverride(null);\n                      setDomainInput(e.target.value);\n                    }}\n                  />\n                </div>\n                <div className=\"flex items-center justify-between gap-4 p-2 text-sm\">\n                  <p>\n                    {[\"checking\", \"has site\", \"available\"].includes(\n                      domainStatus,\n                    ) ? (\n                      <>\n                        {currentStatus.prefix || \"The domain\"}{\" \"}\n                        {currentStatus.useStrong ? (\n                          <strong className=\"font-semibold underline underline-offset-2\">\n                            {sanitizedDomain || \"this domain\"}\n                          </strong>\n                        ) : (\n                          <span className=\"font-semibold underline underline-offset-2\">\n                            {sanitizedDomain || \"this domain\"}\n                          </span>\n                        )}{\" \"}\n                        {currentStatus.suffix}\n                      </>\n                    ) : (\n                      statusMessageOverride ||\n                      currentStatus.message ||\n                      \"Enter a valid domain to check availability.\"\n                    )}\n                  </p>\n                  {StatusIcon && <StatusIcon className=\"h-5 w-5 shrink-0\" />}\n                </div>\n              </div>\n            );\n          })()}\n\n          <DialogFooter className=\"mt-6\">\n            <Button\n              type=\"submit\"\n              className=\"h-9 w-full\"\n              disabled={saveDisabled}\n            >\n              {submitting ? \"Adding domain...\" : \"Add domain\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/domains/delete-domain-modal.tsx",
    "content": "import {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Modal } from \"@/components/ui/modal\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nfunction DeleteDomainModal({\n  showDeleteDomainModal,\n  setShowDeleteDomainModal,\n  domain,\n  onDelete,\n}: {\n  showDeleteDomainModal: boolean;\n  setShowDeleteDomainModal: Dispatch<SetStateAction<boolean>>;\n  domain: string;\n  onDelete: (deletedDomain: string) => void;\n}) {\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n\n  const [deleting, setDeleting] = useState(false);\n  const [isValid, setIsValid] = useState(false);\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const { value } = e.target;\n    // Check if the input matches the pattern\n    if (value === \"permanently delete\") {\n      setIsValid(true);\n    } else {\n      setIsValid(false);\n    }\n  };\n\n  async function deleteDomain() {\n    return new Promise(async (resolve, reject) => {\n      setDeleting(true);\n\n      try {\n        const response = await fetch(\n          `/api/teams/${teamInfo?.currentTeam?.id}/domains/${domain}`,\n          {\n            method: \"DELETE\",\n          },\n        );\n\n        if (!response.ok) {\n          throw new Error(`HTTP error! status: ${response.status}`);\n        }\n\n        analytics.capture(\"Domain Deleted\", {\n          slug: domain,\n          teamId: teamInfo?.currentTeam?.id,\n        });\n\n        // Update local data by filtering out the deleted domain\n        onDelete(domain);\n        setDeleting(false);\n        resolve(null);\n      } catch (error) {\n        setDeleting(false);\n        reject((error as Error).message);\n      } finally {\n        setShowDeleteDomainModal(false);\n      }\n    });\n  }\n\n  return (\n    <Modal\n      showModal={showDeleteDomainModal}\n      setShowModal={setShowDeleteDomainModal}\n      noBackdropBlur\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <DialogTitle className=\"text-2xl\">Delete Domain</DialogTitle>\n        <DialogDescription>\n          This will permanently delete your domain. Links using this domain will\n          be reset to <span className=\"font-medium\">papermark.com</span> links.\n          This action cannot be undone.\n          <div className=\"mt-3 text-sm font-medium text-foreground\">\n            {domain}{\" \"}\n            <span className=\"text-muted-foreground\">→ papermark.com</span>\n          </div>\n        </DialogDescription>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          toast.promise(deleteDomain(), {\n            loading: \"Deleting domain...\",\n            success: \"Domain deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-muted-foreground\"\n          >\n            To confirm deletion, type{\" \"}\n            <span className=\"font-semibold text-foreground\">\n              permanently delete\n            </span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"permanently delete\"\n              required\n              autoComplete=\"off\"\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n              onInput={handleInputChange}\n            />\n          </div>\n        </div>\n\n        <Button variant=\"destructive\" loading={deleting} disabled={!isValid}>\n          Confirm delete domain\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteDomainModal({\n  domain,\n  onDelete,\n}: {\n  domain: string;\n  onDelete: (deletedDomain: string) => void;\n}) {\n  const [showDeleteDomainModal, setShowDeleteDomainModal] = useState(false);\n\n  const DeleteDomainModalCallback = useCallback(() => {\n    return (\n      <DeleteDomainModal\n        showDeleteDomainModal={showDeleteDomainModal}\n        setShowDeleteDomainModal={setShowDeleteDomainModal}\n        domain={domain}\n        onDelete={onDelete}\n      />\n    );\n  }, [showDeleteDomainModal, setShowDeleteDomainModal, domain, onDelete]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteDomainModal,\n      DeleteDomainModal: DeleteDomainModalCallback,\n    }),\n    [setShowDeleteDomainModal, DeleteDomainModalCallback],\n  );\n}\n"
  },
  {
    "path": "components/domains/domain-card.tsx",
    "content": "import { useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  ChevronDownIcon,\n  CircleCheckIcon,\n  ExternalLinkIcon,\n  FlagIcon,\n  GlobeIcon,\n  MoreVertical,\n  RefreshCwIcon,\n  SettingsIcon,\n  TrashIcon,\n} from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\nimport { StatusBadge } from \"@/components/ui/status-badge\";\n\nimport { useDeleteDomainModal } from \"./delete-domain-modal\";\nimport DomainConfiguration from \"./domain-configuration\";\nimport { useDomainStatus } from \"./use-domain-status\";\n\nexport default function DomainCard({\n  domain,\n  isDefault,\n  redirectUrl: initialRedirectUrl,\n  redirectsAllowed,\n  onDelete,\n}: {\n  domain: string;\n  isDefault: boolean;\n  redirectUrl?: string | null;\n  redirectsAllowed: boolean;\n  onDelete: (deletedDomain: string) => void;\n}) {\n  const [showDetails, setShowDetails] = useState(false);\n  const [groupHover, setGroupHover] = useState(false);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const [redirectUrl, setRedirectUrl] = useState(initialRedirectUrl || \"\");\n  const [savingRedirect, setSavingRedirect] = useState(false);\n\n  const domainRef = useRef<HTMLDivElement>(null);\n\n  const {\n    status,\n    loading,\n    domainJson,\n    configJson,\n    mutate: mutateDomain,\n  } = useDomainStatus({\n    domain,\n  });\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const isInvalid =\n    status && ![\"Valid Configuration\", \"Pending Verification\"].includes(status);\n\n  const { setShowDeleteDomainModal, DeleteDomainModal } = useDeleteDomainModal({\n    domain,\n    onDelete,\n  });\n\n  const handleSaveRedirectUrl = async () => {\n    setSavingRedirect(true);\n    try {\n      const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {\n        method: \"PUT\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ redirectUrl: redirectUrl || null }),\n      });\n\n      if (!response.ok) {\n        const data = await response.json();\n        toast.error(data?.message || \"Failed to save redirect URL\");\n        return;\n      }\n\n      mutate(`/api/teams/${teamId}/domains`);\n      toast.success(\n        redirectUrl\n          ? \"Root redirect URL saved\"\n          : \"Root redirect URL removed\",\n      );\n    } catch {\n      toast.error(\"Failed to save redirect URL\");\n    } finally {\n      setSavingRedirect(false);\n    }\n  };\n\n  const handleMakeDefault = async () => {\n    const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {\n      method: \"PATCH\",\n    });\n\n    if (!response.ok) {\n      throw new Error(`HTTP error! status: ${response.status}`);\n    }\n\n    // Update domains by refetching\n    mutate(`/api/teams/${teamId}/domains`);\n  };\n\n  return (\n    <>\n      <div\n        ref={domainRef}\n        className=\"group rounded-xl border border-gray-200 bg-white p-4 transition-[filter] dark:border-gray-400 dark:bg-secondary sm:p-5\"\n        onPointerEnter={() => setGroupHover(true)}\n        onPointerLeave={() => setGroupHover(false)}\n      >\n        <div className=\"flex items-center justify-between gap-3 sm:gap-4\">\n          <div className=\"flex min-w-0 items-center gap-4\">\n            <div className=\"hidden rounded-full border border-gray-200 dark:border-gray-400 sm:block\">\n              <div\n                className={cn(\n                  \"rounded-full\",\n                  \"border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3\",\n                )}\n              >\n                <GlobeIcon className=\"size-5\" />\n              </div>\n            </div>\n            <div className=\"overflow-hidden\">\n              <div className=\"flex items-center gap-1.5 truncate text-sm font-medium sm:gap-2.5\">\n                {domain}\n\n                {isDefault ? (\n                  <span className=\"xs:px-3 xs:py-1 flex items-center gap-1 rounded-full bg-sky-400/[.15] px-1.5 py-0.5 text-xs font-medium text-sky-600\">\n                    <FlagIcon className=\"hidden h-3 w-3 sm:block\" />\n                    Default\n                  </span>\n                ) : null}\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex items-center justify-end gap-2 sm:gap-3\">\n            {/* Status */}\n            <div className=\"hidden sm:block\">\n              {status && !loading ? (\n                <StatusBadge\n                  variant={\n                    status === \"Valid Configuration\"\n                      ? \"success\"\n                      : status === \"Pending Verification\"\n                        ? \"pending\"\n                        : \"error\"\n                  }\n                >\n                  {status === \"Valid Configuration\"\n                    ? \"Active\"\n                    : status === \"Pending Verification\"\n                      ? \"Pending\"\n                      : \"Invalid\"}\n                </StatusBadge>\n              ) : (\n                <div className=\"h-6 w-16 animate-pulse rounded-md bg-gray-200 dark:bg-gray-400\" />\n              )}\n            </div>\n            <Button\n              variant=\"secondary\"\n              className={cn(\n                \"h-8 w-auto px-2 opacity-100 transition-opacity lg:h-9\",\n                !showDetails &&\n                  !isInvalid &&\n                  \"sm:opacity-0 sm:group-hover:opacity-100\",\n              )}\n              onClick={() => setShowDetails((s) => !s)}\n              data-state={showDetails ? \"open\" : \"closed\"}\n            >\n              <div className=\"flex items-center gap-1\">\n                <div className=\"relative\">\n                  <SettingsIcon\n                    className={cn(\n                      \"h-4 w-4\",\n                      showDetails\n                        ? \"text-gray-800 dark:text-gray-200\"\n                        : \"text-gray-600 dark:text-gray-400\",\n                    )}\n                  />\n                  {/* Error indicator */}\n                  {status && isInvalid && (\n                    <div className=\"absolute -right-px -top-px h-[5px] w-[5px] rounded-full bg-destructive\">\n                      <div className=\"h-full w-full animate-pulse rounded-full ring-2 ring-destructive/30\" />\n                    </div>\n                  )}\n                </div>\n                <ChevronDownIcon\n                  className={cn(\n                    \"hidden h-4 w-4 text-gray-400 transition-transform sm:block\",\n                    showDetails && \"rotate-180\",\n                  )}\n                />\n              </div>\n            </Button>\n            <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n              <DropdownMenuTrigger asChild>\n                <Button\n                  // size=\"icon\"\n                  variant=\"outline\"\n                  className=\"z-20 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-400 hover:dark:border-gray-400 hover:dark:bg-gray-700 lg:h-9 lg:w-9\"\n                >\n                  <span className=\"sr-only\">Open menu</span>\n                  <MoreVertical className=\"h-4 w-4\" />\n                </Button>\n              </DropdownMenuTrigger>\n              <DropdownMenuContent align=\"end\">\n                <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                <DropdownMenuItem\n                  disabled={isDefault}\n                  onClick={handleMakeDefault}\n                >\n                  <FlagIcon className=\"mr-2 h-4 w-4\" />\n                  Make default\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  onClick={() => mutateDomain()}\n                  disabled={loading}\n                >\n                  <RefreshCwIcon className=\"mr-2 h-4 w-4\" />\n                  Refresh\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  className=\"text-destructive transition-colors duration-200 focus:bg-destructive focus:text-destructive-foreground\"\n                  onClick={() => setShowDeleteDomainModal(true)}\n                >\n                  <TrashIcon className=\"mr-2 h-4 w-4\" />\n                  Delete domain\n                </DropdownMenuItem>\n              </DropdownMenuContent>\n            </DropdownMenu>\n          </div>\n        </div>\n        <motion.div\n          initial={false}\n          animate={{ height: showDetails ? \"auto\" : 0 }}\n          className=\"overflow-hidden\"\n        >\n          {status ? (\n            status === \"Valid Configuration\" ? (\n              <div className=\"mt-6 flex items-center gap-2 text-pretty rounded-lg bg-green-100/80 p-3 text-sm text-green-600\">\n                <CircleCheckIcon className=\"h-5 w-5 shrink-0\" />\n                <div>\n                  Good news! Your DNS records are set up correctly, but it can\n                  take some time for them to propagate globally.\n                </div>\n              </div>\n            ) : (\n              <DomainConfiguration\n                status={status}\n                response={{ domainJson, configJson }}\n              />\n            )\n          ) : (\n            <div className=\"mt-6 h-6 w-32 animate-pulse rounded-md bg-gray-200 dark:bg-gray-400\" />\n          )}\n\n          {/* Root domain redirect */}\n          <div className=\"mt-4 rounded-lg border border-gray-200 p-4 dark:border-gray-400\">\n            <div className=\"flex items-center gap-2 text-sm font-medium text-foreground\">\n              <ExternalLinkIcon className=\"h-4 w-4\" />\n              Root Domain Redirect\n            </div>\n            {redirectsAllowed ? (\n              <>\n                <p className=\"mt-1 text-xs text-muted-foreground\">\n                  Redirect visitors who land on{\" \"}\n                  <span className=\"font-medium\">{domain}</span> to a specific\n                  URL. Leave empty to redirect to papermark.com.\n                </p>\n                <div className=\"mt-3 flex items-center gap-2\">\n                  <Input\n                    type=\"url\"\n                    placeholder=\"https://example.com\"\n                    value={redirectUrl}\n                    onChange={(e) => setRedirectUrl(e.target.value)}\n                    className=\"h-9 flex-1\"\n                  />\n                  <Button\n                    size=\"sm\"\n                    onClick={handleSaveRedirectUrl}\n                    disabled={savingRedirect}\n                    className=\"h-9 shrink-0\"\n                  >\n                    {savingRedirect ? \"Saving...\" : \"Save\"}\n                  </Button>\n                </div>\n              </>\n            ) : (\n              <p className=\"mt-1 text-xs text-muted-foreground\">\n                Root domain redirects require a{\" \"}\n                <span className=\"font-semibold\">Business</span> plan or higher.\n              </p>\n            )}\n          </div>\n        </motion.div>\n      </div>\n      <DeleteDomainModal />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/domains/domain-configuration.tsx",
    "content": "import { Fragment, useState } from \"react\";\n\nimport { InfoIcon } from \"lucide-react\";\n\nimport { getSubdomain } from \"@/lib/domains\";\nimport { DomainVerificationStatusProps } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport { CopyButton } from \"../ui/copy-button\";\nimport { TabSelect } from \"../ui/tab-select\";\n\nexport default function DomainConfiguration({\n  status,\n  response,\n}: {\n  status: DomainVerificationStatusProps;\n  response: { domainJson: any; configJson: any };\n}) {\n  const { domainJson, configJson } = response;\n  const subdomain = getSubdomain(domainJson.name, domainJson.apexName);\n  const [recordType, setRecordType] = useState(!!subdomain ? \"CNAME\" : \"A\");\n\n  if (status === \"Conflicting DNS Records\") {\n    return (\n      <div className=\"pt-5\">\n        <div className=\"flex justify-start space-x-4\">\n          <div className=\"ease border-b-2 border-black pb-1 text-sm text-foreground transition-all duration-150\">\n            {configJson?.conflicts.some((x: any) => x.type === \"A\")\n              ? \"A Record (recommended)\"\n              : \"CNAME Record (recommended)\"}\n          </div>\n        </div>\n        <DnsRecord\n          instructions=\"Please remove the following conflicting DNS records from your DNS provider:\"\n          records={configJson?.conflicts.map(\n            ({\n              name,\n              type,\n              value,\n            }: {\n              name: string;\n              type: string;\n              value: string;\n            }) => ({\n              name,\n              type,\n              value,\n            }),\n          )}\n        />\n        <DnsRecord\n          instructions=\"Afterwards, set the following record on your DNS provider:\"\n          records={[\n            {\n              type: recordType,\n              name: recordType === \"A\" ? \"@\" : (subdomain ?? \"www\"),\n              value:\n                recordType === \"A\" ? `76.76.21.21` : `cname.vercel-dns.com`,\n              ttl: \"86400\",\n            },\n          ]}\n        />\n      </div>\n    );\n  }\n\n  if (status === \"Unknown Error\") {\n    return (\n      <div className=\"pt-5\">\n        <p className=\"mb-5 text-sm\">{response.domainJson.error.message}</p>\n      </div>\n    );\n  }\n\n  const txtVerification =\n    status === \"Pending Verification\"\n      ? domainJson.verification.find((x: any) => x.type === \"TXT\")\n      : undefined;\n\n  return (\n    <div className=\"pt-2\">\n      <div className=\"-ml-1.5 border-b border-gray-200 dark:border-gray-400\">\n        <TabSelect\n          options={[\n            { id: \"A\", label: `A Record${!subdomain ? \" (recommended)\" : \"\"}` },\n            {\n              id: \"CNAME\",\n              label: `CNAME Record${subdomain ? \" (recommended)\" : \"\"}`,\n            },\n          ]}\n          selected={recordType}\n          onSelect={setRecordType}\n        />\n      </div>\n\n      <DnsRecord\n        instructions={`To configure your ${\n          recordType === \"A\" ? \"apex domain\" : \"subdomain\"\n        } <code>${\n          recordType === \"A\" ? domainJson.apexName : domainJson.name\n        }</code>, set the following ${txtVerification ? \"records\" : `${recordType} record`} on your DNS provider:`}\n        records={[\n          {\n            type: recordType,\n            name: recordType === \"A\" ? \"@\" : (subdomain ?? \"www\"),\n            value:\n              recordType === \"A\"\n                ? (configJson?.recommendedIPv4?.[0]?.value?.[0] ??\n                  \"76.76.21.21\")\n                : (configJson?.recommendedCNAME?.[0]?.value ??\n                  \"cname.vercel-dns.com\"),\n            ttl: \"86400\",\n          },\n          ...(txtVerification\n            ? [\n                {\n                  type: txtVerification.type,\n                  name: txtVerification.domain.slice(\n                    0,\n                    txtVerification.domain.length -\n                      domainJson.apexName.length -\n                      1,\n                  ),\n                  value: txtVerification.value,\n                },\n              ]\n            : []),\n        ]}\n        warning={\n          txtVerification\n            ? \"Warning: if you are using this domain for another site, setting this TXT record will transfer domain ownership away from that site and break it. Please exercise caution when setting this record; make sure that the domain that is shown in the TXT verification value is actually the <b><i>domain you want to use on Papermark</i></b> – <b><i>not your production site</i></b>.\"\n            : undefined\n        }\n      />\n    </div>\n  );\n}\n\nconst MarkdownText = ({ text }: { text: string }) => {\n  return (\n    <p\n      className=\"prose-sm max-w-none prose-code:rounded-md prose-code:bg-gray-100 prose-code:p-1 prose-code:font-mono prose-code:text-[.8125rem] prose-code:font-medium prose-code:text-gray-900\"\n      dangerouslySetInnerHTML={{ __html: text }}\n    />\n  );\n};\n\nconst DnsRecord = ({\n  instructions,\n  records,\n  warning,\n}: {\n  instructions: string;\n  records: { type: string; name: string; value: string; ttl?: string }[];\n  warning?: string;\n}) => {\n  const hasTtl = records.some((x) => x.ttl);\n\n  return (\n    <div className=\"mt-3 text-left text-gray-600 dark:text-gray-400\">\n      <div className=\"my-5\">\n        <MarkdownText text={instructions} />\n      </div>\n      <div\n        className={cn(\n          \"grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-gray-100/80 p-4 text-sm scrollbar-hide\",\n          hasTtl\n            ? \"grid-cols-[repeat(4,max-content)]\"\n            : \"grid-cols-[repeat(3,max-content)]\",\n        )}\n      >\n        {[\"Type\", \"Name\", \"Value\"].concat(hasTtl ? \"TTL\" : []).map((s) => (\n          <p key={s} className=\"font-medium text-gray-950\">\n            {s}\n          </p>\n        ))}\n\n        {records.map((record, idx) => (\n          <Fragment key={idx}>\n            <p key={record.type} className=\"font-mono\">\n              {record.type}\n            </p>\n            <p key={record.name} className=\"font-mono\">\n              {record.name}\n            </p>\n            <p key={record.value} className=\"flex items-end gap-1 font-mono\">\n              {record.value}{\" \"}\n              <CopyButton\n                variant=\"neutral\"\n                className=\"-mb-0.5\"\n                value={record.value}\n              />\n            </p>\n            {hasTtl && (\n              <p key={record.ttl} className=\"font-mono\">\n                {record.ttl}\n              </p>\n            )}\n          </Fragment>\n        ))}\n      </div>\n      {(warning || hasTtl) && (\n        <div\n          className={cn(\n            \"mt-4 flex items-center gap-2 rounded-lg p-3\",\n            warning\n              ? \"bg-orange-50 text-orange-600\"\n              : \"bg-indigo-50 text-indigo-600\",\n          )}\n        >\n          <InfoIcon className=\"h-5 w-5 shrink-0\" />\n          <MarkdownText\n            text={\n              warning ||\n              \"If a TTL value of 86400 is not available, choose the highest available value. Domain propagation may take up to 12 hours.\"\n            }\n          />\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/domains/use-domain-status.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\nimport useSWRImmutable from \"swr/immutable\";\n\nimport {\n  DomainConfigResponse,\n  DomainResponse,\n  DomainVerificationStatusProps,\n} from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useDomainStatus({\n  domain,\n  enabled = true,\n}: {\n  domain: string;\n  enabled?: boolean;\n}) {\n  const teamInfo = useTeam();\n\n  const key =\n    enabled && domain\n      ? `/api/teams/${teamInfo?.currentTeam?.id}/domains/${domain}/verify`\n      : null;\n\n  const { data, isValidating, mutate } = useSWR<{\n    status: DomainVerificationStatusProps;\n    response: {\n      domainJson: DomainResponse & { error: { code: string; message: string } };\n      configJson: DomainConfigResponse;\n    };\n  }>(key, fetcher);\n\n  return {\n    status: data?.status,\n    domainJson: data?.response.domainJson,\n    configJson: data?.response.configJson,\n    loading: isValidating,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "components/emails/custom-domain-setup.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function CustomDomainSetup({\n  name = \"there\",\n  currentPlan = \"Free\",\n  hasAccess = false,\n}: {\n  name?: string;\n  currentPlan?: string;\n  hasAccess?: boolean;\n}) {\n  const getPlanInfo = () => {\n    if (hasAccess) {\n      return {\n        title: \"Your custom domain is ready to set up! 🎉\",\n        subtitle: `Great news! Your ${currentPlan} plan includes custom domain access.`,\n      };\n    } else {\n      return {\n        title: \"Interested in custom domains? 🚀\",\n        subtitle:\n          \"Learn how custom domains can enhance your document sharing experience.\",\n      };\n    }\n  };\n\n  const { title, subtitle } = getPlanInfo();\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Your Papermark custom domain set up</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              {title}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">Hi {name},</Text>\n            <Text className=\"text-sm leading-6 text-black\">{subtitle}</Text>\n\n            {!hasAccess && (\n              <>\n                <Text className=\"text-sm leading-6 text-black\">\n                  <strong>Custom domains are available: </strong>\n                </Text>\n                <ul className=\"list-inside list-disc text-sm\">\n                  <li>\n                    <strong>Business:</strong> Custom domain for documents\n                  </li>\n                  <li>\n                    <strong>Data Rooms:</strong> Custom domain for data rooms\n                  </li>\n                  <li>\n                    <strong>Data Rooms Plus:</strong> Unlimited custom domains\n                    for data rooms and documents\n                  </li>\n                </ul>\n              </>\n            )}\n\n            <Text className=\"text-sm leading-6 text-black\">\n              <strong>Setting up your custom domain:</strong>\n            </Text>\n            <ol className=\"list-inside list-decimal text-sm\">\n              <li>Choose your subdomain (e.g. docs.yourcompany.com)</li>\n              <li>Add a CNAME record pointing to papermark.com</li>\n              <li>Configure the domain in your Papermark settings</li>\n              <li>Start sharing with your branded domain!</li>\n            </ol>\n\n            <Section className=\"my-8 text-center\">\n              {hasAccess ? (\n                <Button\n                  className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                  href={`https://app.papermark.com/settings/domains`}\n                  style={{ padding: \"12px 20px\" }}\n                >\n                  Set up your custom domain\n                </Button>\n              ) : (\n                <Button\n                  className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                  href={`https://app.papermark.com/settings/upgrade`}\n                  style={{ padding: \"12px 20px\" }}\n                >\n                  Upgrade to use custom domains\n                </Button>\n              )}\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-black\">\n              {hasAccess ? (\n                <>\n                  Need help? Check out our{\" \"}\n                  <Link\n                    href=\"https://docs.papermark.com/custom-domains\"\n                    className=\"font-medium text-blue-600 no-underline\"\n                  >\n                    custom domain documentation\n                  </Link>{\" \"}\n                  or reply to this email - we&apos;re here to help!\n                </>\n              ) : (\n                <>\n                  Want to learn more about our plans?{\" \"}\n                  <Link\n                    href=\"https://app.papermark.com/settings/upgrade\"\n                    className=\"font-medium text-blue-600 no-underline\"\n                  >\n                    View pricing\n                  </Link>{\" \"}\n                  or reply to this email if you have any questions!\n                </>\n              )}\n            </Text>\n\n            <Footer footerText=\"If you have any questions about setting up your custom domain, simply reply to this email. We'd love to help you get started!\" />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/data-rooms-information.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nconst DataRoomsInformationEmail = () => {\n  const previewText = `The document sharing infrastructure for the modern web`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              Virtual Data Rooms\n            </Text>\n            <Text className=\"text-sm\">Unlimited branded data rooms!</Text>\n            <Text className=\"text-sm\">\n              With Papermark Data Rooms plan you can:\n            </Text>\n            <ul className=\"list-inside list-disc text-sm\">\n              <li>Share data rooms with one link</li>\n              <li>Upload unlimited files</li>\n              <li>Create unlimited folders and subfolders</li>\n              <li>\n                Connect your <strong>custom domain 💫</strong>{\" \"}\n              </li>\n              <li>Create fully branded experience </li>\n              <li>Use advanced link settings</li>\n            </ul>\n            <Text className=\"text-sm\">\n              All about Papermark{\" \"}\n              <a\n                href=\"https://www.papermark.com/data-room\"\n                className=\"text-blue-500 underline\"\n              >\n                Data Rooms\n              </a>{\" \"}\n              features and plans\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/datarooms?utm_source=dataroom-info&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Create new data room\n              </Button>\n            </Section>\n            <Text className=\"text-sm\">\n              If you require a fully customizable experience,{\" \"}\n              <a\n                href=\"https://cal.com/marcseitz/papermark\"\n                className=\"text-blue-500 underline\"\n              >\n                book a call\n              </a>{\" \"}\n              with us.\n            </Text>\n            <Footer\n              withAddress={false}\n              footerText=\"This is a last onboarding email. If you have any feedback or questions about this email, simply reply to it. I'd love to hear from you!\"\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default DataRoomsInformationEmail;\n"
  },
  {
    "path": "components/emails/dataroom-digest-notification.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ntype DocumentChange = {\n  documentName: string;\n};\n\nexport default function DataroomDigestNotification({\n  dataroomName = \"Example Data Room\",\n  documents = [\n    { documentName: \"Document A\" },\n    { documentName: \"Document B\" },\n    { documentName: \"Document C\" },\n  ],\n  senderEmail = \"example@example.com\",\n  url = \"https://app.papermark.com/datarooms/123\",\n  preferencesUrl = \"https://app.papermark.com/notification-preferences?token=abc\",\n  frequency = \"daily\",\n}: {\n  dataroomName: string;\n  documents: DocumentChange[];\n  senderEmail: string;\n  url: string;\n  preferencesUrl: string;\n  frequency: \"daily\" | \"weekly\";\n}) {\n  const count = documents.length;\n  const periodLabel = frequency === \"daily\" ? \"today\" : \"this week\";\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {`${count} new document${count !== 1 ? \"s\" : \"\"} in ${dataroomName} ${periodLabel}`}\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mb-8 mt-4 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-semibold mb-8 mt-4 text-center text-xl\">\n              {`${count} new document${count !== 1 ? \"s\" : \"\"} in ${dataroomName}`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              The following document{count !== 1 ? \"s have\" : \" has\"} been added\n              to <span className=\"font-semibold\">{dataroomName}</span>{\" \"}\n              {periodLabel}:\n            </Text>\n            <Section className=\"my-4\">\n              {documents.map((doc, i) => (\n                <Text\n                  key={i}\n                  className=\"my-1 text-sm leading-6 text-black\"\n                >\n                  • <span className=\"font-semibold\">{doc.documentName}</span>\n                </Text>\n              ))}\n            </Section>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={url}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View the dataroom\n              </Button>\n            </Section>\n            <Text className=\"text-sm text-black\">\n              or copy and paste this URL into your browser: <br />\n              {url}\n            </Text>\n            <Text className=\"text-sm text-gray-400\">Papermark</Text>\n\n            <Hr />\n            <Section className=\"text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc. All rights\n                reserved.\n              </Text>\n              <Text className=\"text-xs\">\n                You received this {frequency} digest from{\" \"}\n                <span className=\"font-semibold\">{senderEmail}</span> because you\n                viewed the dataroom{\" \"}\n                <span className=\"font-semibold\">{dataroomName}</span> on\n                Papermark. If you have any feedback or questions about this\n                email, simply reply to it.{\" \"}\n                <a\n                  href={preferencesUrl}\n                  className=\"text-gray-400 underline underline-offset-2\"\n                >\n                  Manage your notification preferences\n                </a>\n                .\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/dataroom-notification.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nexport default function DataroomNotification({\n  dataroomName = \"Example Data Room\",\n  documentName = \"Example Document\",\n  senderEmail = \"example@example.com\",\n  url = \"https://app.papermark.com/datarooms/123\",\n  unsubscribeUrl = \"https://app.papermark.com/datarooms/123/unsubscribe\",\n}: {\n  dataroomName: string;\n  documentName: string | undefined;\n  senderEmail: string;\n  url: string;\n  unsubscribeUrl: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Dataroom update available</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mb-8 mt-4 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mb-8 mt-4 text-center text-xl\">\n              {`New document available for ${dataroomName}`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              A new document{\" \"}\n              <span className=\"font-semibold\">{documentName}</span> has been\n              added to <span className=\"font-semibold\">{dataroomName}</span>{\" \"}\n              dataroom on Papermark.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${url}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View the dataroom\n              </Button>\n            </Section>\n            <Text className=\"text-sm text-black\">\n              or copy and paste this URL into your browser: <br />\n              {`${url}`}\n            </Text>\n            <Text className=\"text-sm text-gray-400\">Papermark</Text>\n\n            <Hr />\n            <Section className=\"text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc. All rights\n                reserved.\n              </Text>\n              <Text className=\"text-xs\">\n                You received this email from{\" \"}\n                <span className=\"font-semibold\">{senderEmail}</span> because you\n                viewed the dataroom{\" \"}\n                <span className=\"font-semibold\">{dataroomName}</span> on\n                Papermark. If you have any feedback or questions about this\n                email, simply reply to it.{\" \"}\n                <a\n                  href={unsubscribeUrl}\n                  className=\"text-gray-400 underline underline-offset-2\"\n                >\n                  Manage your notification preferences\n                </a>\n                .\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/dataroom-trial-24h.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\ninterface TrialEndReminderEmail {\n  name: string | null | undefined;\n}\n\nconst DataroomTrial24hReminderEmail = ({ name }: TrialEndReminderEmail) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>Upgrade to Papermark Data Rooms Plan</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl\">\n              Your Data Room plan trial expires in 24 hours\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Hey{name && ` ${name}`}!\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your Papermark Data Room plan trial expires in 24 hours.\n              Don&apos;t lose access to these features -{\" \"}\n              <Link href={`https://app.papermark.com/settings/billing`}>\n                upgrade today\n              </Link>\n              :\n            </Text>\n            <ul className=\"list-inside list-disc text-sm\">\n              <li>\n                Build unlimited <strong>data rooms</strong>\n              </li>\n              <li>\n                Upload files of any <strong>size</strong>\n              </li>\n              <li>\n                Collaborate with your <strong>team</strong>\n              </li>\n              <li>\n                Set up <strong>secure link permissions</strong> and controls\n              </li>\n              <li>\n                Brand everything with your <strong>custom domain</strong>\n              </li>\n              <li>\n                Track detailed <strong>analytics</strong> and user activity\n              </li>\n            </ul>\n            <Section className=\"mb-[32px] mt-[32px] text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/settings/billing`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Upgrade now\n              </Button>\n            </Section>\n            <Text className=\"text-sm font-semibold\">\n              <span className=\"text-red-500\">⚠️</span> Dataroom links and links\n              with advanced access controls will be{\" \"}\n              <span className=\"text-red-500 underline\">disabled</span> in 24\n              hours.\n            </Text>\n            <Text className=\"text-sm text-gray-400\">Marc from Papermark</Text>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default DataroomTrial24hReminderEmail;\n"
  },
  {
    "path": "components/emails/dataroom-trial-end.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\ninterface DataroomTrialEnd {\n  name: string | null | undefined;\n}\n\nconst DataroomTrialEnd = ({ name }: DataroomTrialEnd) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>Upgrade to continue using data rooms</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mb-8 mt-4 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mb-8 mt-4 text-center text-xl\">\n              Your Data Room plan trial has expired\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Hey{name && ` ${name}`}!\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your Papermark Data Room trial has expired.\n              <br />\n              <Link\n                href={`https://app.papermark.com/settings/billing`}\n                className=\"underline\"\n              >\n                Upgrade now\n              </Link>{\" \"}\n              to:\n            </Text>\n            <ul className=\"list-inside list-disc text-sm\">\n              <li>Create new datarooms</li>\n              <li>\n                Upload <strong>large</strong> files\n              </li>\n              <li>\n                Invite your <strong>team members</strong>\n              </li>\n              <li>\n                Protect documents with <strong>advanced access controls</strong>\n              </li>\n              <li>\n                Share documents and data rooms with{\" \"}\n                <strong>custom domain</strong>\n              </li>\n              <li>Access advanced analytics and audit logs</li>\n            </ul>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/settings/billing`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Upgrade now\n              </Button>\n            </Section>\n            <Text className=\"text-sm font-semibold\">\n              <span className=\"text-red-500\">⚠️</span> Dataroom links and links\n              with advanced access controls have been{\" \"}\n              <span className=\"underline\">disabled</span>.\n            </Text>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default DataroomTrialEnd;\n"
  },
  {
    "path": "components/emails/dataroom-trial-welcome.tsx",
    "content": "import React from \"react\";\n\nimport { Body, Head, Html, Tailwind, Text } from \"@react-email/components\";\n\ninterface WelcomeEmailProps {\n  name: string | null | undefined;\n}\n\nconst DataroomTrialWelcomeEmail = ({ name }: WelcomeEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Tailwind>\n        <Body className=\"font-sans text-sm\">\n          <Text>Hi{name && ` ${name}`},</Text>\n          <Text>\n            I am Marc, founder of Papermark. Thanks for creating a trial. Do you\n            need any help with Data Rooms setup?\n          </Text>\n          <Text>Marc</Text>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default DataroomTrialWelcomeEmail;\n"
  },
  {
    "path": "components/emails/dataroom-upload-notification.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function DataroomUploadNotification({\n  dataroomId = \"123\",\n  dataroomName = \"Example Dataroom\",\n  uploaderEmail = \"visitor@example.com\",\n  documentNames = [\"Document 1.pdf\", \"Document 2.pdf\"],\n  linkName = \"Link #abc12\",\n}: {\n  dataroomId: string;\n  dataroomName: string;\n  uploaderEmail: string | null;\n  documentNames: string[];\n  linkName: string;\n}) {\n  const documentCount = documentNames.length;\n  const documentLabel = documentCount === 1 ? \"document\" : \"documents\";\n\n  return (\n    <Html>\n      <Head />\n      <Preview>\n        {`${documentCount} new ${documentLabel} uploaded to ${dataroomName}`}\n      </Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              New File Upload\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              {uploaderEmail ? (\n                <>\n                  <span className=\"font-semibold\">{uploaderEmail}</span> has\n                  uploaded{\" \"}\n                </>\n              ) : (\n                <>A visitor has uploaded </>\n              )}\n              <span className=\"font-semibold\">\n                {documentCount} {documentLabel}\n              </span>{\" \"}\n              to your dataroom{\" \"}\n              <span className=\"font-semibold\">{dataroomName}</span> via the link{\" \"}\n              <span className=\"font-semibold\">{linkName}</span>.\n            </Text>\n            {documentNames.length <= 10 && (\n              <Section className=\"my-4\">\n                {documentNames.map((name, index) => (\n                  <Text\n                    key={index}\n                    className=\"my-1 text-sm leading-6 text-black\"\n                  >\n                    {\"\\u2022\"} {name}\n                  </Text>\n                ))}\n              </Section>\n            )}\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/datarooms/${dataroomId}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View the dataroom\n              </Button>\n            </Section>\n\n            <Footer\n              footerText={\n                <>\n                  If you have any feedback or questions about this email, simply\n                  reply to it. I&apos;d love to hear from you!\n                  <br />\n                  <br />\n                  To stop email notifications for this link, edit the link and\n                  uncheck &quot;Receive email notification&quot;.\n                </>\n              }\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/deleted-domain.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function DomainDeleted({\n  domain = \"papermark.com\",\n}: {\n  domain: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Domain Deleted</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl\">\n              Domain Deleted\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your domain <code className=\"text-purple-600\">{domain}</code> for\n              your Papermark account has been invalid for 30 days. As a result,\n              it has been deleted from Papermark.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you would like to restore the domain, you can easily create it\n              again on Papermark with the link below.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/settings/domains`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Set up your custom domain\n              </Button>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you did not want to keep using this domain on Papermark anyway,\n              you can simply ignore this email.\n            </Text>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/download-ready.tsx",
    "content": "import {\n  Body,\n  Button,\n  Container,\n  Head,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nfunction formatExpirationTime(expiresAt?: string): string {\n  if (!expiresAt) return \"3 days\";\n\n  const expires = new Date(expiresAt);\n  const now = new Date();\n  const diffMs = expires.getTime() - now.getTime();\n  const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n  const diffDays = Math.floor(diffHours / 24);\n\n  if (diffDays > 0) {\n    return `${diffDays} day${diffDays > 1 ? \"s\" : \"\"}`;\n  } else if (diffHours > 0) {\n    return `${diffHours} hour${diffHours > 1 ? \"s\" : \"\"}`;\n  }\n  return \"less than an hour\";\n}\n\nexport default function DownloadReady({\n  dataroomName = \"Dataroom\",\n  downloadUrl = \"https://app.papermark.com\",\n  email = \"email@example.com\",\n  expiresAt,\n  isViewer = false,\n}: {\n  dataroomName?: string;\n  downloadUrl?: string;\n  email: string;\n  expiresAt?: string;\n  isViewer?: boolean;\n}) {\n  const expirationTime = formatExpirationTime(expiresAt);\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Your {dataroomName} download is ready</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your download of <strong>{dataroomName}</strong> is ready!\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              {isViewer\n                ? \"Click the button below to open your downloads page and get your files.\"\n                : \"Click the button below to download your files. You'll need to be logged in to your Papermark account to access the download.\"}\n            </Text>\n\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={downloadUrl}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Download Files\n              </Button>\n            </Section>\n\n            <Text className=\"text-sm leading-6 text-black\">\n              Download details:\n            </Text>\n            <ul className=\"break-all text-sm leading-6 text-black\">\n              <li className=\"text-sm leading-6 text-black\">\n                Dataroom: {dataroomName}\n              </li>\n              <li className=\"text-sm leading-6 text-black\">\n                Expires: in {expirationTime}\n              </li>\n            </ul>\n            <Text className=\"text-sm leading-6 text-black\">\n              Best,\n              <br />\n              The Papermark Team\n            </Text>\n            <Footer\n              footerText={\n                <>\n                  This email was intended for{\" \"}\n                  <span className=\"text-black\">{email}</span>. If you were not\n                  expecting this email, you can ignore this email. If you have\n                  any feedback or questions about this email, simply reply to\n                  it.\n                </>\n              }\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/email-updated.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport function EmailUpdated({\n  oldEmail = \"old@example.com\",\n  newEmail = \"new@example.com\",\n}: {\n  oldEmail: string;\n  newEmail: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your email address has been updated</Preview>\n\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              Your email address has been changed\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              The email address for your Papermark account has been changed from{\" \"}\n              <strong>{oldEmail}</strong> to <strong>{newEmail}</strong>.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you did not make this change, please contact our support team\n              or{\" \"}\n              <Link href=\"https://app.papermark.com/account/general\">\n                update your email address\n              </Link>\n              .\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              This message is being sent to your old email address only.\n            </Text>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n\nexport default EmailUpdated;\n"
  },
  {
    "path": "components/emails/export-ready.tsx",
    "content": "import {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function ExportReady({\n  resourceName = \"Export\",\n  downloadUrl = \"https://app.papermark.com/datarooms/123\",\n  email = \"email@example.com\",\n}: {\n  resourceName?: string;\n  downloadUrl: string;\n  email: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your {resourceName} export is ready for download</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              The export you requested is ready to download for your Papermark\n              account. Make sure you&apos;re signed into this account, and click\n              below to download. The file will be available for the next three\n              days.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={downloadUrl}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Download Export\n              </Button>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              Export details:\n            </Text>\n            <ul className=\"break-all text-sm leading-6 text-black\">\n              <li className=\"text-sm leading-6 text-black\">\n                Export type: {resourceName}\n              </li>\n            </ul>\n            <Text className=\"text-sm leading-6 text-black\">\n              Best,\n              <br />\n              The Papermark Team\n            </Text>\n            <Footer\n              footerText={\n                <>\n                  This email was intended for{\" \"}\n                  <span className=\"text-black\">{email}</span>. If you were not\n                  expecting this email, you can ignore this email. If you have\n                  any feedback or questions about this email, simply reply to\n                  it.\n                </>\n              }\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/hundred-views-congrats.tsx",
    "content": "import {\n  Body,\n  Head,\n  Html,\n  Link,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface HundredViewsCongratsEmailProps {\n  name: string | null | undefined;\n}\n\nconst HundredViewsCongratsEmail = ({\n  name,\n}: HundredViewsCongratsEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Tailwind>\n        <Body className=\"font-sans text-sm\">\n          <Text>Hi{name && ` ${name}`},</Text>\n          <Text>\n            I&apos;m Marc, founder of Papermark. Congrats on 100 views on your\n            documents.\n          </Text>\n          <Text>Would you help others discover us too?</Text>\n          <Text>\n            <Link\n              href=\"https://www.g2.com/products/papermark/reviews\"\n              target=\"_blank\"\n              className=\"text-blue-500 underline\"\n            >\n              Leave a review on G2 →\n            </Link>\n          </Text>\n          <Text>Small gift from us inside.</Text>\n          <Text>\n            Thanks so much,\n            <br />\n            Marc\n          </Text>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default HundredViewsCongratsEmail;\n"
  },
  {
    "path": "components/emails/installed-integration-notification.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function SlackIntegrationNotification({\n  email = \"panic@thedis.co\",\n  team = {\n    name: \"Acme, Inc\",\n  },\n  integration = {\n    name: \"Slack\",\n    slug: \"slack\",\n  },\n}: {\n  email: string;\n  team: {\n    name: string;\n  };\n  integration: {\n    name: string;\n    slug: string;\n  };\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>An integration has been added to your team</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              An integration has been added to your team\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              The <strong>{integration.name}</strong> integration has been added\n              to your team {team.name} on Papermark.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              You can now receive notifications about document views, dataroom\n              access and downloads directly in your Slack channels.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${process.env.NEXT_PUBLIC_BASE_URL}/settings/integrations/${integration.slug}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View installed integration\n              </Button>\n            </Section>\n\n            <Footer\n              footerText={\n                <>\n                  This email was intended for{\" \"}\n                  <span className=\"text-black\">{email}</span>. If you were not\n                  expecting this email, you can ignore this email. If you have\n                  any feedback or questions about this email, simply reply to\n                  it.\n                </>\n              }\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/invalid-domain.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function InvalidDomain({\n  domain = \"papermark.com\",\n  invalidDays = 14,\n}: {\n  domain: string;\n  invalidDays: number;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Invalid Domain Configuration</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              {invalidDays >= 14\n                ? `Invalid Domain Configuration`\n                : `Finish configuring your domain`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your domain <code className=\"text-purple-600\">{domain}</code> for\n              your Papermark account{\" \"}\n              {invalidDays >= 14\n                ? `has been invalid for ${invalidDays} days.`\n                : `is still unconfigured.`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If your domain remains unconfigured for 30 days, it will be\n              automatically deleted from Papermark. Please click the link below\n              to configure your domain.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/settings/domains`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Configure domain\n              </Button>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you do not want to keep this domain on Papermark, you can{\" \"}\n              <Link\n                href={`https://app.papermark.com/settings/domains`}\n                className=\"font-medium text-blue-600 no-underline\"\n              >\n                delete it\n              </Link>{\" \"}\n              or simply ignore this email.{\" \"}\n              {invalidDays >= 14\n                ? `To respect your inbox,${\" \"} \n                  ${\n                    invalidDays < 28\n                      ? `we will only send you one more email about this in ${\n                          28 - invalidDays\n                        } days.`\n                      : `this will be the last time we will email you about this.`\n                  }`\n                : \"\"}\n            </Text>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/onboarding-1.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nconst Onboarding1Email = () => {\n  const previewText = `Share documents not attachments`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              Turn your documents into links\n            </Text>\n            <Text className=\"text-sm\">\n              It all starts from sharing your first document!\n            </Text>\n            <Text className=\"text-sm\">\n              With Papermark you can upload different kind of documents and turn\n              them into shareable links:\n            </Text>\n            <ul className=\"list-inside list-disc text-sm\">\n              <li>PDFs</li>\n              <li>Microsoft Office files</li>\n              <li>Excel, CSV files</li>\n              <li>Notion via link</li>\n            </ul>\n            <Text className=\"text-sm\">\n              (All Notion changes are instantly reflected in shared documents)\n            </Text>\n            <Text className=\"text-sm\">\n              You can also use{\" \"}\n              <span className=\"font-semibold\">Bulk upload 💫</span> Just drop\n              multiple documents at once\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Upload my documents\n              </Button>\n            </Section>\n            <Text className=\"text-sm\">\n              After sharing start tracking document activity on each page\n            </Text>\n            <Hr />\n            <Section className=\"text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc. All rights\n                reserved.\n              </Text>\n              <Text className=\"text-xs\">\n                If you have any feedback or questions about this email, simply\n                reply to it. I&apos;d love to hear from you!{\" \"}\n              </Text>\n\n              <Text className=\"text-xs\">Stop this onboarding sequence</Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default Onboarding1Email;\n"
  },
  {
    "path": "components/emails/onboarding-2.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nconst Onboarding2Email = () => {\n  return (\n    <Html>\n      <Head />\n      <Preview>The document sharing infrastructure for the modern web</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              Set link permissions\n            </Text>\n            <Text className=\"text-sm\">\n              There are many ways how you can protect your documents!\n            </Text>\n            <Text className=\"text-sm\">\n              With Papermark you can use different link settings for shared\n              documents and data rooms:\n            </Text>\n            <ul className=\"list-inside list-disc text-sm\">\n              <li>Require email to view</li>\n              <li>Expiration date</li>\n              <li>Allow & block list 🌟</li>\n              <li>Email verification</li>\n              <li>Password protection</li>\n              <li>NDA and other agreements</li>\n              <li>Screenshot protection</li>\n            </ul>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                To my link settings\n              </Button>\n            </Section>\n            <Text className=\"text-sm\">\n              Additionally you can turn on/off: downloading, notifications,\n              feedback, and cta settings\n            </Text>\n            <Hr />\n            <Section className=\"text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc. All rights\n                reserved.\n              </Text>\n              <Text className=\"text-xs\">\n                If you have any feedback or questions about this email, simply\n                reply to it. I&apos;d love to hear from you!\n              </Text>\n\n              <Text className=\"text-xs\">Stop this onboarding sequence</Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default Onboarding2Email;\n"
  },
  {
    "path": "components/emails/onboarding-3.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nconst Onboarding3Email = () => {\n  return (\n    <Html>\n      <Head />\n      <Preview>The document sharing infrastructure for the modern web</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              Watch the views come in real-time\n            </Text>\n            <Text className=\"text-sm\">\n              You need to know who viewed your documents!\n            </Text>\n            <Text className=\"text-sm\">\n              With Papermark you can track progress on each page of your\n              document and other analytics:\n            </Text>\n            <ul className=\"list-inside list-disc text-sm\">\n              <li>\n                Track time on{\" \"}\n                <span className=\"font-semibold\">each page 💫</span>\n              </li>\n              <li>See who viewed your documents</li>\n              <li>Capture email </li>\n              <li>Receive feedback</li>\n              <li>Ask questions and get answers</li>\n            </ul>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View your document activity\n              </Button>\n            </Section>\n            <Text className=\"text-sm\">\n              Get instant notifications on who viewed your documents\n            </Text>\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc. All rights\n                reserved.\n              </Text>\n              <Text className=\"text-xs\">\n                If you have any feedback or questions about this email, simply\n                reply to it. I&apos;d love to hear from you!{\" \"}\n              </Text>\n\n              <Text className=\"text-xs\">Stop this onboarding sequence</Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default Onboarding3Email;\n"
  },
  {
    "path": "components/emails/onboarding-4.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nconst Onboarding4Email = () => {\n  const previewText = `The document sharing infrastructure for the modern web`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              Custom domains and branding\n            </Text>\n            <Text className=\"text-sm\">\n              Look professional with custom branding!\n            </Text>\n            <Text className=\"text-sm\">With Papermark you can:</Text>\n            <ul className=\"list-inside list-disc text-sm\">\n              <li>\n                Share documnets with your <strong>custom domain💫</strong>{\" \"}\n              </li>\n\n              <li>Remove &quot;powered by Papermark&quot;</li>\n              <li>Add logo and custom colors</li>\n              <li>Share data room with custom domain</li>\n              <li>Add banner and custom brand to data rooms</li>\n            </ul>\n            <Text className=\"text-sm\">\n              (Customization for data rooms is seaprate and available in each\n              data room you create)\n            </Text>\n            {/* <Text className=\"text-sm\">You can also use Bulk upload</Text> */}\n            <Section className=\"mb-[32px] mt-[32px] text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Add your domain and branding\n              </Button>\n            </Section>\n            <Text className=\"text-sm\">\n              If you are looking for full white-labelling just{\" \"}\n              <a\n                href=\"https://cal.com/marcseitz/papermark\"\n                className=\"text-blue-500 underline\"\n              >\n                book a call\n              </a>{\" \"}\n              with us.\n            </Text>\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()}{\" \"}\n                <a\n                  href=\"https://www.papermark.com\"\n                  className=\"text-gray-400 no-underline\"\n                  target=\"_blank\"\n                >\n                  papermark.com\n                </a>\n              </Text>\n              <Text className=\"text-xs\">\n                If you have any feedback or questions about this email, simply\n                reply to it. I&apos;d love to hear from you!{\" \"}\n              </Text>\n\n              <Text className=\"text-xs\">Stop this onboarding sequence</Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default Onboarding4Email;\n"
  },
  {
    "path": "components/emails/otp-verification.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Img,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nexport default function OtpEmailVerification({\n  email = \"test@example.co\",\n  code = \"123456\",\n  isDataroom = false,\n  logo,\n}: {\n  email: string;\n  code: string;\n  isDataroom: boolean;\n  logo?: string;\n}) {\n  const resourceLabel = isDataroom ? \"dataroom\" : \"document\";\n\n  return (\n    <Html>\n      <Head />\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              {logo ? (\n                <Img\n                  src={logo}\n                  alt=\"Logo\"\n                  width=\"120\"\n                  height=\"36\"\n                />\n              ) : (\n                <Text className=\"text-2xl font-bold tracking-tighter\">\n                  Papermark\n                </Text>\n              )}\n            </Section>\n            <Text className=\"mx-0 my-7 p-0 text-xl font-semibold text-black\">\n              Verify your email\n            </Text>\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Enter this code to verify your email and access the{\" \"}\n              {resourceLabel} shared with you on Papermark:\n            </Text>\n            <Section className=\"my-6\">\n              <Text\n                className=\"m-0 rounded-lg bg-neutral-100 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] text-black\"\n                style={{ fontFamily: \"monospace\", letterSpacing: \"0.3em\" }}\n              >\n                {code}\n              </Text>\n            </Section>\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              This code will expire in 10 minutes.\n            </Text>\n            <Text className=\"mt-4 text-sm leading-5 text-neutral-500\">\n              This email was intended for{\" \"}\n              <span className=\"text-black\">{email}</span>. If you were not\n              expecting this email, you can safely ignore it.\n            </Text>\n            <Hr className=\"my-6\" />\n            <Section className=\"text-gray-400\">\n              <Text className=\"text-xs text-neutral-500\">\n                Papermark, Inc.\n                <br />\n                1111B S Governors Ave #28117\n                <br />\n                Dover, DE 19904\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/shared/footer.tsx",
    "content": "import { Hr, Link, Section, Text } from \"@react-email/components\";\n\nexport const Footer = ({\n  withAddress = false,\n  marketing = false,\n  footerText = \"If you have any feedback or questions about this email, simply reply to it. I'd love to hear from you!\",\n}: {\n  withAddress?: boolean;\n  marketing?: boolean;\n  footerText?: string | React.ReactNode;\n}) => {\n  if (marketing) {\n    return (\n      <>\n        <Hr className=\"mx-0 my-6 w-full border border-neutral-200\" />\n        <Text className=\"text-[12px] leading-6 text-neutral-500\">\n          We send out product update emails once a month – no spam, no nonsense.\n          Don&apos;t want to get these emails?{\" \"}\n          <Link\n            className=\"text-neutral-700 underline\"\n            href=\"https://app.papermark.com/account/general\"\n          >\n            Unsubscribe here.\n          </Link>\n        </Text>\n        <Text className=\"text-[12px] text-neutral-500\">\n          Papermark, Inc.\n          <br />\n          1111B S Governors Ave #28117\n          <br />\n          Dover, DE 19904\n        </Text>\n      </>\n    );\n  }\n\n  return (\n    <>\n      <Hr />\n      <Section className=\"text-gray-400\">\n        <Text className=\"text-xs\">\n          © {new Date().getFullYear()} Papermark, Inc. All rights reserved.{\" \"}\n          {withAddress && (\n            <>\n              <br />\n              1111B S Governors Ave #28117, Dover, DE 19904\n            </>\n          )}\n        </Text>\n        <Text className=\"text-xs\">{footerText}</Text>\n      </Section>\n    </>\n  );\n};\n"
  },
  {
    "path": "components/emails/slack-integration.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface SlackIntegrationEmailProps {\n  name: string | null | undefined;\n}\n\nconst SlackIntegrationEmail = ({ name }: SlackIntegrationEmailProps) => {\n  const previewText = `See who viewed your documents in slack in 2 clicks`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              Connect Slack in 2 clicks\n            </Text>\n            <Text className=\"text-sm\">Hi{name && ` ${name}`}!</Text>\n            <Text className=\"text-sm\">\n              We offer direct integration to Slack, and it&apos;s free for all\n              users for 30 days.\n            </Text>\n            <Text className=\"text-sm\">\n              With our Slack integration, you can get real-time notifications\n              about document and data roomviews directly in your Slack channels\n              !\n            </Text>\n            <Section className=\"mb-[32px] mt-[32px] text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${process.env.NEXT_PUBLIC_BASE_URL}/settings/integrations`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                See who viewed your documents in Slack\n              </Button>\n            </Section>\n\n            <Text className=\"text-sm\">\n              If you have any questions or need help setting it up, just respond\n              to this email. I&apos;m always happy to help!\n            </Text>\n            <Text className=\"text-sm text-gray-400\">Marc from Papermark</Text>\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()}{\" \"}\n                <a\n                  href=\"https://www.papermark.com\"\n                  className=\"text-gray-400 no-underline\"\n                  target=\"_blank\"\n                >\n                  papermark.com\n                </a>\n              </Text>\n              <Text className=\"text-xs\">\n                Feel free to always reach out to me or our support team.\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default SlackIntegrationEmail;\n"
  },
  {
    "path": "components/emails/team-invitation.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function TeamInvitation({\n  senderName,\n  senderEmail,\n  teamName,\n  url = \"https://app.papermark.com\",\n}: {\n  senderName: string;\n  senderEmail: string;\n  teamName: string;\n  url: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Join the team on Papermark</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl\">\n              {`Join ${teamName} on Papermark`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">Hey!</Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              <span className=\"font-semibold\">{senderName}</span> ({senderEmail}\n              ) has invited you to the{\" \"}\n              <span className=\"font-semibold\">{teamName}</span> team on{\" \"}\n              <span className=\"font-semibold\">Papermark</span>.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={url}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Join the team\n              </Button>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {url.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/thousand-views-congrats.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Head,\n  Html,\n  Link,\n  Preview,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface ThousandViewsCongratsEmailProps {\n  name: string | null | undefined;\n}\n\nconst ThousandViewsCongratsEmail = ({\n  name,\n}: ThousandViewsCongratsEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>1000 views on Papermark.</Preview>\n      <Tailwind>\n        <Body className=\"font-sans text-sm\">\n          <Text>Hi{name && ` ${name}`},</Text>\n          <Text>\n            I&apos;m Marc, founder of Papermark. Congrats on 1000 views on your\n            documents.\n          </Text>\n          <Text>How is your experience so far?</Text>\n\n          <Text>\n            Thanks so much,\n            <br />\n            Marc\n          </Text>\n          <Text>\n            <Link\n              href=\"https://www.g2.com/products/papermark/reviews\"\n              target=\"_blank\"\n              className=\"text-blue-500 underline\"\n              rel=\"noopener noreferrer\"\n            >\n              Leave us a G2 review\n            </Link>\n          </Text>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default ThousandViewsCongratsEmail;\n"
  },
  {
    "path": "components/emails/upgrade-one-month-checkin.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Head,\n  Html,\n  Preview,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface UpgradeCheckinEmailProps {\n  name: string | null | undefined;\n}\n\nconst UpgradeCheckinEmail = ({ name }: UpgradeCheckinEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>Check-in from Marc</Preview>\n      <Tailwind>\n        <Body className=\"font-sans text-sm\">\n          <Text>Hi{name && ` ${name}`},</Text>\n          <Text>\n            It is Marc here. How has your experience been so far? Are you\n            getting the value you expected from the advanced features?\n          </Text>\n\n          <Text>\n            Any feedback - what&apos;s working well and what could we improve?\n          </Text>\n          <Text>Marc</Text>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default UpgradeCheckinEmail;\n"
  },
  {
    "path": "components/emails/upgrade-personal-welcome.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Head,\n  Html,\n  Preview,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface UpgradePersonalEmailProps {\n  name: string | null | undefined;\n  planName?: string;\n}\n\nconst UpgradePersonalEmail = ({\n  name,\n  planName = \"Pro\",\n}: UpgradePersonalEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>Welcome to {planName}</Preview>\n      <Tailwind>\n        <Body className=\"font-sans text-sm\">\n          <Text>Hi{name && ` ${name}`},</Text>\n          <Text>\n            I&apos;m Iuliia, co-founder of Papermark. Thanks for upgrading!\n            I&apos;m thrilled to have you on our {planName} plan.\n          </Text>\n          <Text>\n            You now have access to advanced features. Any questions so far??\n          </Text>\n          <Text>Iuliia</Text>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default UpgradePersonalEmail;\n"
  },
  {
    "path": "components/emails/upgrade-plan.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\ninterface UpgradePlanEmailProps {\n  name: string | null | undefined;\n  planType: string;\n}\n\nconst UpgradePlanEmail = ({\n  name,\n  planType = \"datarooms-plus\",\n}: UpgradePlanEmailProps) => {\n  const previewText = `The document sharing infrastructure for the modern web`;\n\n  const PLAN_TYPE_MAP = {\n    pro: \"Pro\",\n    business: \"Business\",\n    datarooms: \"Data Rooms\",\n    \"datarooms-plus\": \"Data Rooms Plus\",\n    \"datarooms-premium\": \"Data Rooms Premium\",\n  };\n\n  const planTypeText = PLAN_TYPE_MAP[planType as keyof typeof PLAN_TYPE_MAP];\n  const features: any = {\n    pro: [\n      \"Custom branding\",\n      \"Unlimited link views\",\n      \"Folder organization\",\n      \"1 team member\",\n    ],\n    business: [\n      \"Custom domains on document links\",\n      \"Unlimited data rooms for multi-file sharing\",\n      \"Advanced access controls\",\n      \"3 team members\",\n    ],\n    datarooms: [\n      \"Custom domains on data room links\",\n      \"Unlimited data rooms\",\n      \"Unlimited document uploads\",\n      \"3 team members\",\n    ],\n    \"datarooms-plus\": [\n      \"Custom domains on data room links\",\n      \"Unlimited data rooms\",\n      \"Q&A module\",\n      \"5 team members\",\n    ],\n    \"datarooms-premium\": [\n      \"Multiple teams (up to 5 teams)\",\n      \"Unlimited encrypted storage\",\n      \"No file size limit\",\n      \"10 team members\",\n    ],\n  };\n\n  const planFeatures = features[planType];\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl\">\n              Thanks for upgrading to Papermark {planTypeText}!\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Hey{name && ` ${name}`}!\n            </Text>\n            <Text className=\"text-sm\">\n              Marc is here. I wanted to personally reach out to thank you for\n              upgrading to Papermark {planTypeText}!\n            </Text>\n\n            <Text className=\"text-sm leading-6 text-black\">\n              On the {planTypeText} plan, you now have access to:\n            </Text>\n            {planFeatures?.map(\n              (feature: string, index: number) => (\n                <Text key={index} className=\"ml-1 text-sm leading-4 text-black\">\n                  ◆ {feature}\n                </Text>\n              ),\n              [],\n            )}\n            <Section className=\"mb-[32px] mt-[32px] text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${process.env.NEXT_PUBLIC_BASE_URL}/${\n                  planTypeText.includes(\"datarooms\") ? \"datarooms\" : \"documents\"\n                }`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Share your{\" \"}\n                {planType.includes(\"datarooms\") ? \"data rooms\" : \"documents\"}\n              </Button>\n            </Section>\n            <Section>\n              <Text className=\"text-sm\">\n                Let me know if you have any questions or feedback. I&apos;m\n                always happy to help!\n              </Text>\n              <Text className=\"text-sm text-gray-400\">Marc from Papermark</Text>\n            </Section>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default UpgradePlanEmail;\n"
  },
  {
    "path": "components/emails/upgrade-six-month-checkin.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Head,\n  Html,\n  Preview,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface SixMonthMilestoneEmailProps {\n  name: string | null | undefined;\n  planName?: string;\n}\n\nconst SixMonthMilestoneEmail = ({\n  name,\n  planName = \"Pro\",\n}: SixMonthMilestoneEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>6 months with Papermark</Preview>\n      <Tailwind>\n        <Body className=\"font-sans text-sm\">\n          <Text>Hi {name},</Text>\n          <Text>What&apos;s been your biggest win using Papermark?</Text>\n          <Text>\n            Marc here. It&apos;s been 6 months since you using advanced\n            Papermark features! Excited to hear your story and feedback for us.\n          </Text>\n\n          <Text>Marc</Text>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default SixMonthMilestoneEmail;\n"
  },
  {
    "path": "components/emails/verification-email-change.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\ninterface ConfirmEmailChangeProps {\n  email: string;\n  newEmail: string;\n  confirmUrl: string;\n}\n\nexport function ConfirmEmailChange({\n  email = \"email@example.com\",\n  newEmail = \"new@example.com\",\n  confirmUrl = \"https://www.papermark.com\",\n}: ConfirmEmailChangeProps) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Confirm your email address change</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[500px] rounded border border-solid border-gray-200 px-10 py-5\">\n            <Section>\n              <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n                <span className=\"font-bold tracking-tighter\">Papermark</span>\n              </Text>\n              <Heading className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n                Your Papermark Email Change Confirmation Link\n              </Heading>\n            </Section>\n            <Heading className=\"text-sm leading-6 text-black\">\n              Confirm your email address change\n            </Heading>\n            <Text className=\"text-sm leading-6 text-black\">\n              Follow this link to confirm the update to your email from{\" \"}\n              <strong>{email}</strong> to <strong>{newEmail}</strong>.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Link\n                href={confirmUrl}\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                style={{ padding: \"12px 20px\" }}\n              >\n                Confirm email change\n              </Link>\n            </Section>\n            <Text className=\"text-sm leading-6 text-black\">\n              or copy and paste this URL into your browser:\n            </Text>\n            <Text className=\"max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline\">\n              {confirmUrl.replace(/^https?:\\/\\//, \"\")}\n            </Text>\n            <Footer\n              footerText={\n                <>\n                  This email was intended for{\" \"}\n                  <span className=\"text-black\">{email}</span>. If you were not\n                  expecting this email, you can ignore this email. If you have\n                  any feedback or questions about this email, simply reply to\n                  it.\n                </>\n              }\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n\nexport default ConfirmEmailChange;\n"
  },
  {
    "path": "components/emails/verification-link.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nconst VerificationCodeEmail = ({\n  email = \"user@example.com\",\n  code = \"45PFSNUDYW\",\n}: {\n  email?: string;\n  code?: string;\n}) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your login code for Papermark: {code}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Text className=\"text-2xl font-bold tracking-tighter\">\n                Papermark\n              </Text>\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-semibold text-black\">\n              Your login code\n            </Heading>\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              Enter this code to sign in to your Papermark account:\n            </Text>\n            <Section className=\"my-6\">\n              <Text\n                className=\"m-0 rounded-lg bg-neutral-100 px-4 py-3 text-center text-2xl font-bold tracking-[0.3em] text-black\"\n                style={{ fontFamily: \"monospace\", letterSpacing: \"0.3em\" }}\n              >\n                {code}\n              </Text>\n            </Section>\n            <Text className=\"text-sm leading-6 text-neutral-600\">\n              This code will expire in 15 minutes.\n            </Text>\n            <Text className=\"mt-4 text-sm leading-5 text-neutral-500\">\n              If you didn&apos;t request this code, you can safely ignore this\n              email.\n            </Text>\n            <Hr className=\"my-6\" />\n            <Section className=\"text-gray-400\">\n              <Text className=\"text-xs text-neutral-500\">\n                Papermark, Inc.\n                <br />\n                1111B S Governors Ave #28117\n                <br />\n                Dover, DE 19904\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default VerificationCodeEmail;\n"
  },
  {
    "path": "components/emails/viewed-dataroom-paused.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function ViewedDataroomPausedEmail({\n  dataroomName = \"Example Dataroom\",\n  linkName = \"Example Link\",\n}: {\n  dataroomName?: string;\n  linkName?: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>See who visited your dataroom</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              New Dataroom Visitor\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your dataroom{\" \"}\n              <span className=\"font-semibold\">{dataroomName}</span> was just\n              viewed by <span className=\"font-semibold\">someone</span>\n              from the link <span className=\"font-semibold\">{linkName}</span>.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your team is currently paused, so detailed visitor information is\n              not available. To see who visited your dataroom and access full\n              analytics, please unpause your subscription.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/settings/billing`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Manage Subscription\n              </Button>\n            </Section>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/viewed-dataroom.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function ViewedDataroom({\n  dataroomId = \"123\",\n  dataroomName = \"Example Dataroom\",\n  linkName = \"Dataroom\",\n  viewerEmail,\n  locationString,\n}: {\n  dataroomId: string;\n  dataroomName: string;\n  linkName: string;\n  viewerEmail: string | null;\n  locationString?: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>See who visited your dataroom</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              New Dataroom Visitor\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your dataroom{\" \"}\n              <span className=\"font-semibold\">{dataroomName}</span> was just\n              viewed by{\" \"}\n              <span className=\"font-semibold\">\n                {viewerEmail ? `${viewerEmail}` : `someone`}\n              </span>\n              {locationString ? (\n                <span>\n                  {\" \"}\n                  in <span className=\"font-semibold\">{locationString}</span>\n                </span>\n              ) : null}{\" \"}\n              from the link <span className=\"font-semibold\">{linkName}</span>.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              You can get the detailed engagement analytics like time-spent per\n              document page and total duration for this dataroom on Papermark.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/datarooms/${dataroomId}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                See my dataroom insights\n              </Button>\n            </Section>\n\n            <Footer\n              footerText={\n                <>\n                  If you have any feedback or questions about this email, simply\n                  reply to it. I&apos;d love to hear from you!\n                  <br />\n                  <br />\n                  To stop email notifications for this link, edit the link and\n                  uncheck &quot;Receive email notification&quot;.\n                </>\n              }\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/viewed-document-paused.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function ViewedDocumentPausedEmail({\n  documentName = \"Example Document\",\n  linkName = \"Example Link\",\n}: {\n  documentName?: string;\n  linkName?: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>See who visited your document</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              New Document Visitor\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your document{\" \"}\n              <span className=\"font-semibold\">{documentName}</span> was just\n              viewed by <span className=\"font-semibold\">someone</span>\n              from the link <span className=\"font-semibold\">{linkName}</span>.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your team is currently paused, so detailed visitor information is\n              not available. To see who visited your documents and access full\n              analytics, please unpause your subscription.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/settings/billing`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Manage Subscription\n              </Button>\n            </Section>\n            <Footer />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/viewed-document.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\nexport default function ViewedDocument({\n  documentId = \"123\",\n  documentName = \"Pitchdeck\",\n  linkName = \"Pitchdeck\",\n  viewerEmail,\n  locationString,\n}: {\n  documentId: string;\n  documentName: string;\n  linkName: string;\n  viewerEmail: string | null;\n  locationString?: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>See who visited your document</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              New Document Visitor\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              Your document{\" \"}\n              <span className=\"font-semibold\">{documentName}</span> was just\n              viewed by{\" \"}\n              <span className=\"font-semibold\">\n                {viewerEmail ? `${viewerEmail}` : `someone`}\n              </span>\n              {locationString ? (\n                <span>\n                  {\" \"}\n                  in <span className=\"font-semibold\">{locationString}</span>\n                </span>\n              ) : null}{\" \"}\n              from the link <span className=\"font-semibold\">{linkName}</span>.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              You can get the detailed engagement insights like time-spent per\n              page and total duration for this document on Papermark.\n            </Text>\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`https://app.papermark.com/documents/${documentId}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                See my document insights\n              </Button>\n            </Section>\n            <Footer\n              footerText={\n                <>\n                  If you have any feedback or questions about this email, simply\n                  reply to it. I&apos;d love to hear from you!\n                  <br />\n                  <br />\n                  To stop email notifications for this link, edit the link and\n                  uncheck &quot;Receive email notification&quot;.\n                </>\n              }\n            />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/emails/welcome.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"./shared/footer\";\n\ninterface WelcomeEmailProps {\n  name: string | null | undefined;\n}\n\nconst WelcomeEmail = ({ name }: WelcomeEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>Welcome to Papermark</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 max-w-[600px] rounded border border-solid border-neutral-200 px-10 py-5\">\n            <Section className=\"mt-8\">\n              <Text className=\"text-2xl font-bold tracking-tighter\">\n                Papermark\n              </Text>\n            </Section>\n            <Heading className=\"mx-0 my-7 p-0 text-xl font-semibold text-black\">\n              Welcome {name ? name : \"to Papermark\"}!\n            </Heading>\n            <Text className=\"mb-8 text-sm leading-6 text-gray-600\">\n              Thank you for signing up for Papermark! You can now start sharing\n              documents securely, create data rooms, and track engagement in\n              real-time.\n            </Text>\n\n            <Hr />\n\n            <Heading className=\"mx-0 my-6 p-0 text-lg font-semibold text-black\">\n              Getting started\n            </Heading>\n\n            <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                1. Upload your document\n              </strong>\n              : Simply{\" \"}\n              <Link\n                href=\"https://www.papermark.com/help/article/how-to-upload-document\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                drag and drop\n              </Link>{\" \"}\n              your PDF, spreadsheet, or presentation to create a shareable link.\n            </Text>\n\n            <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                2. Share securely\n              </strong>\n              : Add{\" \"}\n              <Link\n                href=\"https://www.papermark.com/help/article/require-email-verification\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                email verification\n              </Link>\n              ,{\" \"}\n              <Link\n                href=\"https://www.papermark.com/password-protection\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                password protection\n              </Link>\n              , or{\" \"}\n              <Link\n                href=\"https://www.papermark.com/help/article/expiration-date\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                link expiration\n              </Link>{\" \"}\n              to control access.\n            </Text>\n\n            <Text className=\"mb-4 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                3. Track engagement\n              </strong>\n              : Watch{\" \"}\n              <Link\n                href=\"https://www.papermark.com/help/article/built-in-page-by-page-analytics\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                page-by-page analytics\n              </Link>{\" \"}\n              in real-time to see who&apos;s viewing your documents.\n            </Text>\n\n            <Text className=\"mb-8 text-sm leading-6 text-gray-600\">\n              <strong className=\"font-medium text-black\">\n                4. Create a data room\n              </strong>\n              :{\" \"}\n              <Link\n                href=\"https://www.papermark.com/help/article/create-data-room\"\n                className=\"font-semibold text-black underline underline-offset-4\"\n              >\n                Set up a secure data room\n              </Link>{\" \"}\n              for due diligence and enterprise document sharing.\n            </Text>\n\n            <Section className=\"mb-8\">\n              <Link\n                className=\"rounded-lg bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`}\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Footer marketing />\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default WelcomeEmail;\n"
  },
  {
    "path": "components/emails/year-in-review-papermark.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Column,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Link,\n  Preview,\n  Row,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\nimport { UploadIcon } from \"lucide-react\";\n\ninterface PapermarkYearInReviewEmailProps {\n  year: number;\n  minutesSpentOnDocs: number;\n  uploadedDocuments: number;\n  sharedLinks: number;\n  receivedViews: number;\n  topDocumentName: string;\n  topDocumentViews: number;\n  mostActiveMonth: string;\n  mostActiveMonthViews: number;\n  sharerPercentile: number;\n  viewingLocations: string[];\n  unsubscribeUrl: string;\n}\n\nexport default function PapermarkYearInReviewEmail({\n  year = 2024,\n  minutesSpentOnDocs = 1234,\n  uploadedDocuments = 25,\n  sharedLinks = 50,\n  receivedViews = 500,\n  topDocumentName = \"Q4 Financial Report\",\n  topDocumentViews = 150,\n  mostActiveMonth = \"September\",\n  mostActiveMonthViews = 200,\n  sharerPercentile = 95,\n  viewingLocations = [\"United States\", \"United Kingdom\", \"Germany\", \"Japan\"],\n  unsubscribeUrl,\n}: PapermarkYearInReviewEmailProps) {\n  return (\n    <Html>\n      <Head />\n      <Preview>See your stats from 2024</Preview>\n      <Tailwind>\n        <Body className=\"bg-white font-sans\">\n          <Container className=\"mx-auto w-full max-w-[600px] p-0\">\n            <Section className=\"p-8 text-center\">\n              <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n                <span className=\"font-bold tracking-tighter\">Papermark</span>\n              </Text>\n              <Text className=\"text-sm font-normal uppercase tracking-wider\">\n                {year} in review\n              </Text>\n              <Heading className=\"my-4 text-4xl font-medium leading-tight\">\n                Your Year with Papermark\n              </Heading>\n              <Text className=\"mb-8 text-lg leading-8\">\n                What a year it&apos;s been! Let&apos;s take a look at how\n                you&apos;ve used Papermark to share your important documents.\n              </Text>\n              <Link\n                href={`https://x.com/intent/post?text=In%202024%2C%20my%20documents%20have%20been%20viewed%20${minutesSpentOnDocs}%20minutes%20on%20%40papermarkio%2C%20by%3A%0A%0A%E2%80%A2%20Uploading%20${uploadedDocuments}%20documents%0A%E2%80%A2%20Sharing%20${sharedLinks}%20links%0A%E2%80%A2%20Receiving%20${receivedViews}%20views%0A%0A&url=https%3A%2F%2Fwww.papermark.com%2Fyear-in-review`}\n                className=\"inline-flex items-center rounded-full bg-gray-900 px-12 py-4 text-center text-sm font-bold text-white no-underline\"\n              >\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  width=\"24\"\n                  height=\"24\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  className=\"lucide lucide-upload mr-2 h-4 w-4\"\n                >\n                  <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n                  <polyline points=\"17 8 12 3 7 8\" />\n                  <line x1=\"12\" x2=\"12\" y1=\"3\" y2=\"15\" />\n                </svg>\n                Share your stats\n              </Link>\n            </Section>\n\n            <Section className=\"my-6 rounded-2xl bg-[#fb7a00]/10 bg-[radial-gradient(circle_at_bottom_right,#fb7a00_0%,transparent_60%)] p-8 text-center\">\n              <Heading className=\"m-0 text-3xl font-medium text-[#a63b00]\">\n                Viewers spent\n              </Heading>\n              <Text className=\"my-4 text-7xl font-bold leading-none text-gray-900\">\n                {minutesSpentOnDocs}\n              </Text>\n              <Text className=\"mb-4 text-3xl font-medium text-gray-900\">\n                minutes on your documents\n              </Text>\n              <Text className=\"text-sm leading-5 text-gray-900\">\n                That&apos;s a lot of engagement! Your documents are resonating\n                with your visitors.\n              </Text>\n\n              <Hr className=\"mt-6\" style={{ borderColor: \"#fb7a00\" }} />\n              <Heading className=\"pt-5 text-xs font-medium uppercase tracking-wider text-gray-900\">\n                Your activity\n              </Heading>\n              <Row className=\"mt-5\">\n                <Column className=\"w-1/3 text-center\">\n                  <Text className=\"text-sm font-medium text-[#a63b00]\">\n                    You uploaded\n                  </Text>\n                  <Text className=\"my-1 text-4xl font-bold text-gray-900\">\n                    {uploadedDocuments}\n                  </Text>\n                  <Text className=\"text-2xl text-gray-900\">documents</Text>\n                </Column>\n                <Column className=\"w-1/3 text-center\">\n                  <Text className=\"text-sm font-medium text-[#a63b00]\">\n                    You shared\n                  </Text>\n                  <Text className=\"my-1 text-4xl font-bold text-gray-900\">\n                    {sharedLinks}\n                  </Text>\n                  <Text className=\"text-2xl text-gray-900\">links</Text>\n                </Column>\n                <Column className=\"w-1/3 text-center\">\n                  <Text className=\"text-sm font-medium text-[#a63b00]\">\n                    You received\n                  </Text>\n                  <Text className=\"my-1 text-4xl font-bold text-gray-900\">\n                    {receivedViews}\n                  </Text>\n                  <Text className=\"text-2xl text-gray-900\">views</Text>\n                </Column>\n              </Row>\n            </Section>\n\n            <Section className=\"my-6 rounded-2xl bg-[#4b5563]/10 bg-[radial-gradient(circle_at_bottom_right,#4b5563_0%,transparent_60%)] p-8 text-center\">\n              <Heading className=\"m-0 text-3xl font-medium text-gray-800\">\n                Your top document\n              </Heading>\n              <Text className=\"my-4 text-2xl font-bold leading-none text-gray-900\">\n                &quot;{topDocumentName}&quot;\n              </Text>\n              <Text className=\"mb-4 text-5xl font-medium text-gray-900\">\n                {topDocumentViews} views\n              </Text>\n              <Text className=\"text-sm leading-5 text-gray-900\">\n                This document really caught your visitor&apos;s attention!\n              </Text>\n            </Section>\n\n            <Section className=\"my-6 rounded-2xl bg-[#e4c5a0]/10 bg-[radial-gradient(circle_at_bottom_right,#e4c5a0_0%,transparent_60%)] p-8 text-center\">\n              <Heading className=\"m-0 text-3xl font-medium text-[#9c7b4a]\">\n                Your most active month\n              </Heading>\n              <Text className=\"my-4 text-5xl font-bold leading-none text-gray-900\">\n                {mostActiveMonth}\n              </Text>\n              <Text className=\"mb-4 text-3xl font-medium text-gray-900\">\n                with {mostActiveMonthViews} views\n              </Text>\n              <Text className=\"text-sm leading-5 text-gray-900\">\n                {mostActiveMonth} was your busiest month. What did you share\n                that got so much attention?\n              </Text>\n\n              <Hr className=\"mt-6\" style={{ borderColor: \"#e4c5a0\" }} />\n              {sharerPercentile <= 10 ? (\n                <>\n                  <Heading className=\"pt-5 text-xs font-medium uppercase tracking-wider text-gray-900\">\n                    You&apos;re in the top\n                  </Heading>\n                  <Text className=\"my-4 text-7xl font-bold leading-none text-gray-900\">\n                    {sharerPercentile}%\n                  </Text>\n                  <Text className=\"mb-4 text-xl font-medium text-gray-900\">\n                    of sharers on Papermark\n                  </Text>\n                  <Text className=\"text-sm leading-5 text-gray-900\">\n                    You&apos;re one of our most active users. Thank you for\n                    sharing with Papermark!\n                  </Text>\n                </>\n              ) : (\n                <>\n                  <Heading className=\"pt-5 text-xs font-medium uppercase tracking-wider text-gray-900\">\n                    So close to the top 10%\n                  </Heading>\n                  <Text className=\"my-4 text-2xl font-medium text-gray-900\">\n                    Keep up sharing in 2025!\n                  </Text>\n                </>\n              )}\n            </Section>\n\n            <Section className=\"my-6 rounded-2xl bg-[#10b981]/10 bg-[radial-gradient(circle_at_bottom_right,#10b981_0%,transparent_60%)] p-8 text-center\">\n              <Heading className=\"m-0 text-3xl font-medium text-[#065f46]\">\n                Your documents were viewed from\n              </Heading>\n              <Row className=\"mt-4\">\n                <Column>\n                  {viewingLocations.map((location, index) => (\n                    <Text\n                      key={index}\n                      className=\"rounded-full bg-[#10b981] px-3 py-1 text-sm font-medium text-white\"\n                      style={{\n                        margin: \"4px 4px\",\n                        display: \"inline-block\",\n                      }}\n                    >\n                      {location}\n                    </Text>\n                  ))}\n                </Column>\n              </Row>\n              <Text className=\"mt-4 text-sm leading-5 text-[#065f46]\">\n                Your documents get attention from all over the world!\n              </Text>\n            </Section>\n\n            <Section className=\"pb-6 text-center\">\n              <Text className=\"text-xl leading-8 text-gray-900\">\n                We&apos;re excited to support you next year! <br />\n                Happy Holidays from the Papermark team :)\n              </Text>\n              <Link\n                href={`https://x.com/intent/post?text=In%202024%2C%20my%20documents%20have%20been%20viewed%20${minutesSpentOnDocs}%20minutes%20on%20%40papermarkio%2C%20by%3A%0A%0A%E2%80%A2%20Uploading%20${uploadedDocuments}%20documents%0A%E2%80%A2%20Sharing%20${sharedLinks}%20links%0A%E2%80%A2%20Receiving%20${receivedViews}%20views%0A%0A&url=https%3A%2F%2Fwww.papermark.com%2Fyear-in-review`}\n                className=\"mt-4 inline-flex items-center rounded-full bg-gray-900 px-12 py-4 text-center text-sm font-bold text-white no-underline\"\n              >\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  width=\"24\"\n                  height=\"24\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  className=\"lucide lucide-upload mr-2 h-4 w-4\"\n                >\n                  <path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\" />\n                  <polyline points=\"17 8 12 3 7 8\" />\n                  <line x1=\"12\" x2=\"12\" y1=\"3\" y2=\"15\" />\n                </svg>\n                Share your stats\n              </Link>\n              <Link\n                href=\"https://app.papermark.com/documents\"\n                className=\"mt-4 block items-center text-center text-sm font-bold text-gray-900 no-underline\"\n              >\n                Go to your dashboard\n              </Link>\n            </Section>\n\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()}{\" \"}\n                <a\n                  href=\"https://www.papermark.com\"\n                  className=\"text-gray-400 no-underline\"\n                  target=\"_blank\"\n                >\n                  papermark.com\n                </a>\n              </Text>\n              <Text className=\"text-xs\">\n                You received this Year in Review email because you have an\n                account with Papermark during 2024. If you have any feedback or\n                questions about this email, simply reply to it. To unsubscribe\n                from future Year in Review emails,{\" \"}\n                <a\n                  href={unsubscribeUrl}\n                  className=\"text-gray-400 underline underline-offset-2\"\n                >\n                  click here\n                </a>\n                .\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "components/folders/add-folder-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport {\n  DEFAULT_FOLDER_COLOR,\n  DEFAULT_FOLDER_ICON,\n  FolderColorId,\n  FolderIconId,\n} from \"@/lib/constants/folder-constants\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport { FolderIconColorPicker } from \"./folder-icon-picker\";\n\nexport function AddFolderModal({\n  // open,\n  // setOpen,\n  onAddition,\n  isDataroom,\n  dataroomId,\n  children,\n}: {\n  // open?: boolean;\n  // setOpen?: React.Dispatch<React.SetStateAction<boolean>>;\n  onAddition?: (folderName: string) => void;\n  isDataroom?: boolean;\n  dataroomId?: string;\n  children?: React.ReactNode;\n}) {\n  const router = useRouter();\n  const [folderName, setFolderName] = useState<string>(\"\");\n  const [folderIcon, setFolderIcon] = useState<FolderIconId>(DEFAULT_FOLDER_ICON);\n  const [folderColor, setFolderColor] = useState<FolderColorId>(DEFAULT_FOLDER_COLOR);\n  const [loading, setLoading] = useState<boolean>(false);\n  const [open, setOpen] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const { isFree, isTrial } = usePlan();\n  const analytics = useAnalytics();\n\n  /** current folder name */\n  const currentFolderPath = router.query.name as string[] | undefined;\n\n  const folderPath =\n    isDataroom && dataroomId\n      ? `/datarooms/${dataroomId}/documents/${currentFolderPath ? currentFolderPath?.join(\"/\") : \"\"}/${\"/\" + safeSlugify(folderName.trim())}`\n      : `/documents/tree/${currentFolderPath ? currentFolderPath?.join(\"/\") : \"\"}${\"/\" + safeSlugify(folderName.trim())}`;\n\n  const addFolderSchema = z.object({\n    name: z\n      .string()\n      .min(3, {\n        message: \"Please provide a folder name with at least 3 characters.\",\n      })\n      .max(256, {\n        message: \"Folder name must be 256 characters or less.\",\n      }),\n  });\n\n  const validation = addFolderSchema.safeParse({ name: folderName });\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!validation.success) {\n      toast.error(validation.error.errors[0].message);\n      return;\n    }\n\n    setLoading(true);\n    const endpointTargetType =\n      isDataroom && dataroomId ? `datarooms/${dataroomId}/folders` : \"folders\";\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: folderName.trim(),\n            path: currentFolderPath?.join(\"/\"),\n            icon: folderIcon,\n            color: folderColor,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message.error);\n        return;\n      }\n\n      const { parentFolderPath } = await response.json();\n\n      analytics.capture(\"Folder Added\", {\n        folderName: folderName.trim(),\n        icon: folderIcon,\n        color: folderColor,\n        dataroomId,\n      });\n      toast.success(`Folder added successfully!`, {\n        description: `\"${folderName.trim()}\"`,\n        action: {\n          label: \"Open folder\",\n          onClick: () => router.push(folderPath),\n        },\n        duration: 10000,\n      });\n\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}?root=true`,\n      );\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}`);\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}${parentFolderPath}`,\n      );\n    } catch (error) {\n      setLoading(false);\n      toast.error(\"Error adding folder. Please try again.\");\n      return;\n    } finally {\n      setFolderName(\"\");\n      setFolderIcon(DEFAULT_FOLDER_ICON);\n      setFolderColor(DEFAULT_FOLDER_COLOR);\n      setLoading(false);\n      setOpen(false);\n    }\n  };\n\n  // If the team is on a free plan, show the upgrade modal\n  if (isFree && !isTrial) {\n    if (children) {\n      return (\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.Pro}\n          trigger={\"add_folder_button\"}\n          highlightItem={[\"folder\", \"multi-file\"]}\n        >\n          {children}\n        </UpgradePlanModal>\n      );\n    }\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Add Folder</DialogTitle>\n          <DialogDescription>\n            Create a new folder with a custom icon and color.\n          </DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"folder-name\" className=\"opacity-80\">\n            Folder Name\n          </Label>\n          <div className=\"mb-4 mt-1 flex items-center gap-2\">\n            <FolderIconColorPicker\n              iconValue={folderIcon}\n              colorValue={folderColor}\n              onIconChange={setFolderIcon}\n              onColorChange={setFolderColor}\n            />\n            <Input\n              id=\"folder-name\"\n              placeholder=\"Choose a helpful name\"\n              className=\"flex-1\"\n              value={folderName}\n              onChange={(e) => setFolderName(e.target.value)}\n            />\n          </div>\n          <DialogFooter>\n            <Button\n              type=\"submit\"\n              className=\"h-9 w-full\"\n              disabled={!validation.success}\n              loading={loading}\n            >\n              Create\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/folders/edit-folder-modal.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport {\n  DEFAULT_FOLDER_COLOR,\n  DEFAULT_FOLDER_ICON,\n  FolderColorId,\n  FolderIconId,\n} from \"@/lib/constants/folder-constants\";\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { FolderIconColorPicker } from \"./folder-icon-picker\";\n\nexport function EditFolderModal({\n  open,\n  setOpen,\n  name,\n  folderId,\n  icon,\n  color,\n  onAddition,\n  isDataroom,\n  dataroomId,\n  children,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  name: string;\n  folderId: string;\n  icon?: string | null;\n  color?: string | null;\n  onAddition?: (folderName: string) => void;\n  isDataroom?: boolean;\n  dataroomId?: string;\n  children?: React.ReactNode;\n}) {\n  const [folderName, setFolderName] = useState<string>(name);\n  const [folderIcon, setFolderIcon] = useState<FolderIconId>(\n    (icon as FolderIconId) || DEFAULT_FOLDER_ICON,\n  );\n  const [folderColor, setFolderColor] = useState<FolderColorId>(\n    (color as FolderColorId) || DEFAULT_FOLDER_COLOR,\n  );\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n\n  // Reset state when modal opens with new folder data\n  useEffect(() => {\n    if (open) {\n      setFolderName(name);\n      setFolderIcon((icon as FolderIconId) || DEFAULT_FOLDER_ICON);\n      setFolderColor((color as FolderColorId) || DEFAULT_FOLDER_COLOR);\n    }\n  }, [open, name, icon, color]);\n\n  const editFolderSchema = z.object({\n    name: z\n      .string()\n      .min(3, {\n        message: \"Please provide a folder name with at least 3 characters.\",\n      })\n      .max(256, {\n        message: \"Folder name must be 256 characters or less.\",\n      }),\n  });\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const validation = editFolderSchema.safeParse({ name: folderName });\n    if (!validation.success) {\n      return toast.error(validation.error.errors[0].message);\n    }\n\n    setLoading(true);\n    const endpointTargetType =\n      isDataroom && dataroomId ? `datarooms/${dataroomId}/folders` : \"folders\";\n\n    // Determine what fields have changed for analytics\n    const changedFields: string[] = [];\n    if (folderName.trim() !== name) changedFields.push(\"name\");\n    if (folderIcon !== (icon || DEFAULT_FOLDER_ICON)) changedFields.push(\"icon\");\n    if (folderColor !== (color || DEFAULT_FOLDER_COLOR)) changedFields.push(\"color\");\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/manage`,\n        {\n          method: \"PUT\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            folderId: folderId,\n            name: folderName.trim(),\n            icon: folderIcon,\n            color: folderColor,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message);\n        return;\n      }\n\n      const { parentFolderPath } = await response.json();\n\n      // Track analytics\n      if (changedFields.length > 0) {\n        analytics.capture(\"folder_updated\", {\n          changed: changedFields,\n          icon: folderIcon,\n          color: folderColor,\n          isDataroom,\n        });\n      }\n\n      toast.success(\"Folder updated successfully!\");\n\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}`);\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}?root=true`,\n      );\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}${parentFolderPath}`,\n      );\n    } catch (error) {\n      setLoading(false);\n      toast.error(\"Error updating folder. Please try again.\");\n      return;\n    } finally {\n      setLoading(false);\n      setOpen(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Edit Folder</DialogTitle>\n          <DialogDescription>\n            Update your folder name, icon, and color.\n          </DialogDescription>\n        </DialogHeader>\n\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"folder-name-update\" className=\"opacity-80\">\n            Folder Name\n          </Label>\n          <div className=\"mb-1 mt-1 flex items-center gap-2\">\n            <FolderIconColorPicker\n              iconValue={folderIcon}\n              colorValue={folderColor}\n              onIconChange={setFolderIcon}\n              onColorChange={setFolderColor}\n            />\n            <Input\n              id=\"folder-name-update\"\n              value={folderName}\n              placeholder=\"Choose a helpful name\"\n              className=\"flex-1\"\n              maxLength={256}\n              onChange={(e) => setFolderName(e.target.value)}\n            />\n          </div>\n          <p className=\"mb-4 text-xs text-muted-foreground\">\n            {folderName.length}/256 characters\n          </p>\n\n          <DialogFooter>\n            <Button type=\"submit\" className=\"h-9 w-full\" loading={loading}>\n              Update folder\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/folders/folder-color-picker.tsx",
    "content": "import { useMemo } from \"react\";\n\nimport {\n  FOLDER_COLORS,\n  FolderColorId,\n  getFolderColorClasses,\n} from \"@/lib/constants/folder-constants\";\nimport { cn } from \"@/lib/utils\";\n\ninterface FolderColorPickerProps {\n  value: FolderColorId | null | undefined;\n  onChange: (colorId: FolderColorId) => void;\n}\n\nexport function FolderColorPicker({ value, onChange }: FolderColorPickerProps) {\n  const selectedColor = useMemo(() => {\n    return value || \"gray\";\n  }, [value]);\n\n  return (\n    <div className=\"flex flex-wrap gap-2\">\n      {FOLDER_COLORS.map((colorOption) => {\n        const isSelected = selectedColor === colorOption.id;\n\n        return (\n          <button\n            key={colorOption.id}\n            type=\"button\"\n            onClick={() => onChange(colorOption.id)}\n            className={cn(\n              \"flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-all\",\n              colorOption.bg,\n              colorOption.border,\n              colorOption.text,\n              isSelected ? \"ring-2 ring-offset-1\" : \"hover:scale-105\",\n            )}\n            style={\n              isSelected\n                ? ({\n                    \"--tw-ring-color\":\n                      colorOption.id === \"gray\"\n                        ? \"#6b7280\"\n                        : colorOption.id === \"red\"\n                          ? \"#ef4444\"\n                          : colorOption.id === \"orange\"\n                            ? \"#f97316\"\n                            : colorOption.id === \"yellow\"\n                              ? \"#eab308\"\n                              : colorOption.id === \"green\"\n                                ? \"#10b981\"\n                                : colorOption.id === \"blue\"\n                                  ? \"#3b82f6\"\n                                  : colorOption.id === \"black\"\n                                    ? \"#000000\"\n                                    : \"#6b7280\",\n                  } as React.CSSProperties)\n                : undefined\n            }\n            title={colorOption.label}\n          >\n            <span\n              className=\"h-3 w-3 rounded-full\"\n              style={{\n                backgroundColor:\n                  colorOption.id === \"gray\"\n                    ? \"#6b7280\"\n                    : colorOption.id === \"red\"\n                      ? \"#ef4444\"\n                      : colorOption.id === \"orange\"\n                        ? \"#f97316\"\n                        : colorOption.id === \"yellow\"\n                          ? \"#eab308\"\n                          : colorOption.id === \"green\"\n                            ? \"#10b981\"\n                            : colorOption.id === \"blue\"\n                              ? \"#3b82f6\"\n                              : colorOption.id === \"black\"\n                                ? \"#000000\"\n                                : \"#6b7280\",\n              }}\n            />\n            {colorOption.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n// Helper component to get color preview dot\ninterface FolderColorDotProps {\n  colorId: FolderColorId | null | undefined;\n  className?: string;\n}\n\nexport function FolderColorDot({ colorId, className }: FolderColorDotProps) {\n  const colorClasses = getFolderColorClasses(colorId);\n\n  const colorHex =\n    colorId === \"gray\"\n      ? \"#6b7280\"\n      : colorId === \"red\"\n        ? \"#ef4444\"\n        : colorId === \"orange\"\n          ? \"#f97316\"\n          : colorId === \"yellow\"\n            ? \"#eab308\"\n            : colorId === \"green\"\n              ? \"#10b981\"\n              : colorId === \"blue\"\n                ? \"#3b82f6\"\n                : colorId === \"black\"\n                  ? \"#000000\"\n                  : \"#6b7280\";\n\n  return (\n    <span\n      className={cn(\"h-3 w-3 rounded-full\", className)}\n      style={{ backgroundColor: colorHex }}\n    />\n  );\n}\n"
  },
  {
    "path": "components/folders/folder-icon-picker.tsx",
    "content": "import { useMemo, useState } from \"react\";\n\nimport {\n  FOLDER_COLORS,\n  FOLDER_ICONS,\n  FolderColorId,\n  FolderIconId,\n  getFolderColorClasses,\n  getFolderIcon,\n} from \"@/lib/constants/folder-constants\";\nimport { cn } from \"@/lib/utils\";\n\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\n\ninterface FolderIconPickerProps {\n  value: FolderIconId | null | undefined;\n  onChange: (iconId: FolderIconId) => void;\n  colorClass?: string;\n}\n\nexport function FolderIconPicker({\n  value,\n  onChange,\n  colorClass = \"text-gray-600\",\n}: FolderIconPickerProps) {\n  const selectedIcon = useMemo(() => {\n    return value || \"folder\";\n  }, [value]);\n\n  return (\n    <ScrollArea className=\"h-[200px] rounded-md border p-2\">\n      <div className=\"grid grid-cols-6 gap-2\">\n        {FOLDER_ICONS.map((iconOption) => {\n          const IconComponent = iconOption.icon;\n          const isSelected = selectedIcon === iconOption.id;\n\n          return (\n            <button\n              key={iconOption.id}\n              type=\"button\"\n              onClick={() => onChange(iconOption.id)}\n              className={cn(\n                \"flex h-10 w-10 items-center justify-center rounded-md border transition-all hover:bg-muted\",\n                isSelected\n                  ? \"border-primary bg-primary/10 ring-2 ring-primary\"\n                  : \"border-transparent\",\n              )}\n              title={iconOption.label}\n            >\n              <IconComponent\n                className={cn(\n                  \"h-5 w-5\",\n                  isSelected ? colorClass : \"text-muted-foreground\",\n                )}\n                strokeWidth={1.5}\n              />\n            </button>\n          );\n        })}\n      </div>\n    </ScrollArea>\n  );\n}\n\n// Preview component for displaying selected icon with color\ninterface FolderIconPreviewProps {\n  iconId: FolderIconId | null | undefined;\n  colorClass?: string;\n  className?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n\nexport function FolderIconPreview({\n  iconId,\n  colorClass = \"text-gray-600\",\n  className,\n  size = \"md\",\n}: FolderIconPreviewProps) {\n  const IconComponent = getFolderIcon(iconId);\n\n  const sizeClasses = {\n    sm: \"h-4 w-4\",\n    md: \"h-6 w-6\",\n    lg: \"h-8 w-8\",\n  };\n\n  return (\n    <IconComponent\n      className={cn(sizeClasses[size], colorClass, className)}\n      strokeWidth={1.5}\n    />\n  );\n}\n\n// Color hex values for color picker dots\nconst COLOR_HEX_VALUES: Record<FolderColorId, string> = {\n  gray: \"#6b7280\",\n  red: \"#ef4444\",\n  orange: \"#f97316\",\n  yellow: \"#eab308\",\n  green: \"#10b981\",\n  blue: \"#3b82f6\",\n  black: \"#000000\",\n};\n\n// Combined Icon and Color Picker Popover\ninterface FolderIconColorPickerProps {\n  iconValue: FolderIconId;\n  colorValue: FolderColorId;\n  onIconChange: (iconId: FolderIconId) => void;\n  onColorChange: (colorId: FolderColorId) => void;\n}\n\nexport function FolderIconColorPicker({\n  iconValue,\n  colorValue,\n  onIconChange,\n  onColorChange,\n}: FolderIconColorPickerProps) {\n  const [open, setOpen] = useState(false);\n  const colorClasses = getFolderColorClasses(colorValue);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <button\n          type=\"button\"\n          className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 transition-colors hover:bg-muted\"\n          aria-label=\"Choose folder icon and color\"\n        >\n          <FolderIconPreview\n            iconId={iconValue}\n            colorClass={colorClasses.iconClass}\n            size=\"md\"\n          />\n        </button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[280px] p-3\" align=\"start\">\n        {/* Color Picker Row */}\n        <div className=\"mb-3\">\n          <p className=\"mb-2 text-xs font-medium text-muted-foreground\">\n            Colors\n          </p>\n          <div className=\"flex flex-wrap gap-2\">\n            {FOLDER_COLORS.map((colorOption) => {\n              const isSelected = colorValue === colorOption.id;\n              const hex = COLOR_HEX_VALUES[colorOption.id as FolderColorId];\n\n              return (\n                <button\n                  key={colorOption.id}\n                  type=\"button\"\n                  onClick={() => onColorChange(colorOption.id as FolderColorId)}\n                  className={cn(\n                    \"flex h-7 w-7 items-center justify-center rounded-full transition-all\",\n                    isSelected\n                      ? \"ring-2 ring-offset-2 ring-offset-background\"\n                      : \"hover:scale-110\",\n                  )}\n                  style={{\n                    backgroundColor: hex,\n                    ...(isSelected && { '--tw-ring-color': hex }),\n                  } as React.CSSProperties}\n                  title={colorOption.label}\n                  aria-label={`Select ${colorOption.label} color`}\n                />\n              );\n            })}\n          </div>\n        </div>\n\n        {/* Icon Picker Grid */}\n        <div>\n          <p className=\"mb-2 text-xs font-medium text-muted-foreground\">\n            Icons\n          </p>\n          <ScrollArea className=\"h-[200px]\">\n            <div className=\"m-1 grid grid-cols-7 gap-1.5\">\n              {FOLDER_ICONS.map((iconOption) => {\n                const IconComponent = iconOption.icon;\n                const isSelected = iconValue === iconOption.id;\n\n                return (\n                  <button\n                    key={iconOption.id}\n                    type=\"button\"\n                    onClick={() => onIconChange(iconOption.id)}\n                    className={cn(\n                      \"flex h-8 w-8 items-center justify-center rounded-md transition-all hover:bg-muted\",\n                      isSelected && \"bg-muted ring-1 ring-primary\",\n                    )}\n                    title={iconOption.label}\n                    aria-label={`Select ${iconOption.label} icon`}\n                  >\n                    <IconComponent\n                      className={cn(\n                        \"h-5 w-5\",\n                        isSelected\n                          ? colorClasses.iconClass\n                          : \"text-muted-foreground\",\n                      )}\n                      strokeWidth={1.5}\n                    />\n                  </button>\n                );\n              })}\n            </div>\n          </ScrollArea>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "components/gtm-component.tsx",
    "content": "import { GoogleTagManager } from \"@next/third-parties/google\";\n\nconst GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;\n\nexport function GTMComponent() {\n  if (!GTM_ID) {\n    return null;\n  }\n\n  return <GoogleTagManager gtmId={GTM_ID} />;\n}\n"
  },
  {
    "path": "components/hooks/use-optimistic-update.ts",
    "content": "import { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useOptimisticUpdate<T>(\n  url: string,\n  toastCopy?: { loading: string; success: string; error: string },\n) {\n  const { data, isLoading, mutate } = useSWR<T>(url, fetcher);\n\n  return {\n    data,\n    isLoading,\n    update: async (fn: (data: T) => Promise<T>, optimisticData: T) => {\n      return toast.promise(\n        mutate(fn(data as T), {\n          optimisticData,\n          rollbackOnError: true,\n          populateCache: true,\n          revalidate: true,\n        }),\n        {\n          loading: toastCopy?.loading || \"Updating...\",\n          success: toastCopy?.success || \"Successfully updated\",\n          error: toastCopy?.error || \"Failed to update\",\n        },\n      );\n    },\n  };\n}\n"
  },
  {
    "path": "components/hooks/useLastUsed.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { localStorage } from \"@/lib/webstorage\";\n\ntype LoginType = \"passkey\" | \"google\" | \"credentials\" | \"linkedin\";\n\nexport function useLastUsed() {\n  const [lastUsed, setLastUsed] = useState<LoginType>();\n\n  useEffect(() => {\n    const storedValue = localStorage.getItem(\"last_papermark_login\");\n    if (storedValue) {\n      setLastUsed(storedValue as LoginType);\n    }\n  }, []);\n\n  useEffect(() => {\n    if (lastUsed) {\n      localStorage.setItem(\"last_papermark_login\", lastUsed);\n    } else {\n      localStorage.removeItem(\"last_papermark_login\");\n    }\n  }, [lastUsed]);\n\n  return [lastUsed, setLastUsed] as const;\n}\n\nexport const LastUsed = ({ className }: { className?: string | undefined }) => {\n  return (\n    <div className=\"absolute right-2 top-1/2 w-fit -translate-y-1/2 sm:right-[-50px]\">\n      <div\n        className={cn(\n          \"relative z-[999] rounded-md bg-input px-2 py-1 text-xs text-foreground\",\n          className,\n        )}\n      >\n        Last used\n        <div className=\"absolute -left-1 top-1/2 hidden h-0 w-0 -translate-y-1/2 border-b-4 border-l-0 border-r-4 border-t-4 border-transparent border-r-input sm:block\" />\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/layouts/app.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport Cookies from \"js-cookie\";\n\nimport { AppBreadcrumb } from \"@/components/layouts/breadcrumb\";\nimport TrialBanner from \"@/components/layouts/trial-banner\";\nimport { AppSidebar } from \"@/components/sidebar/app-sidebar\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  SIDEBAR_COOKIE_NAME,\n  SidebarInset,\n  SidebarProvider,\n  SidebarTrigger,\n} from \"@/components/ui/sidebar\";\n\n// import { usePlan } from \"@/lib/swr/use-billing\";\n// import YearlyUpgradeBanner from \"@/components/billing/yearly-upgrade-banner\";\n\nimport { BlockingModal } from \"./blocking-modal\";\n\nconst DATAROOM_SIDEBAR_COOKIE_NAME = \"sidebar:dataroom-state\";\n\n// Helper to get initial sidebar state synchronously (avoids flash)\nfunction getInitialSidebarState(isDataroom: boolean): boolean {\n  if (typeof window === \"undefined\") return false; // SSR: default closed to avoid flash\n\n  // For dataroom pages, check dataroom-specific cookie first\n  if (isDataroom) {\n    const dataroomCookie = Cookies.get(DATAROOM_SIDEBAR_COOKIE_NAME);\n    if (dataroomCookie !== undefined) {\n      return dataroomCookie === \"true\";\n    }\n    // No dataroom preference set yet - default to closed for datarooms\n    return false;\n  }\n\n  // For non-dataroom pages, use main cookie\n  const mainCookie = Cookies.get(SIDEBAR_COOKIE_NAME);\n  if (mainCookie !== undefined) {\n    return mainCookie === \"true\";\n  }\n\n  return true; // Default open for non-dataroom pages\n}\n\nexport default function AppLayout({ children }: { children: React.ReactNode }) {\n  const router = useRouter();\n  const isDataroom = router.pathname.startsWith(\"/datarooms/[id]\");\n\n  // Use lazy initializer to compute initial state synchronously (avoids flash)\n  const [sidebarOpen, setSidebarOpen] = useState(() =>\n    getInitialSidebarState(isDataroom),\n  );\n\n  // Track previous dataroom state for transitions\n  const prevIsDataroomRef = useRef<boolean>(isDataroom);\n  const isFirstRenderRef = useRef(true);\n\n  // Handle initial mount and transitions between dataroom/non-dataroom\n  useEffect(() => {\n    if (isFirstRenderRef.current) {\n      isFirstRenderRef.current = false;\n      // Set cookie on initial mount if in dataroom and no preference exists\n      if (\n        isDataroom &&\n        Cookies.get(DATAROOM_SIDEBAR_COOKIE_NAME) === undefined\n      ) {\n        Cookies.set(DATAROOM_SIDEBAR_COOKIE_NAME, \"false\", { expires: 7 });\n      }\n      return;\n    }\n\n    // Transitioning from non-dataroom to dataroom\n    if (!prevIsDataroomRef.current && isDataroom) {\n      setSidebarOpen(false);\n      Cookies.set(DATAROOM_SIDEBAR_COOKIE_NAME, \"false\", { expires: 7 });\n    }\n\n    // Transitioning from dataroom to non-dataroom\n    if (prevIsDataroomRef.current && !isDataroom) {\n      Cookies.remove(DATAROOM_SIDEBAR_COOKIE_NAME);\n      // Restore main sidebar state\n      const mainCookie = Cookies.get(SIDEBAR_COOKIE_NAME);\n      // setSidebarOpen(mainCookie === \"true\");\n      setSidebarOpen(mainCookie !== undefined ? mainCookie === \"true\" : true);\n    }\n\n    prevIsDataroomRef.current = isDataroom;\n  }, [isDataroom]);\n\n  // Handle sidebar state changes - save to appropriate cookie\n  const handleSidebarOpenChange = useCallback(\n    (open: boolean) => {\n      setSidebarOpen(open);\n      if (isDataroom) {\n        Cookies.set(DATAROOM_SIDEBAR_COOKIE_NAME, String(open), { expires: 7 });\n      }\n    },\n    [isDataroom],\n  );\n\n  // const { isAnnualPlan, isFree } = usePlan();\n  // const [showYearlyBanner, setShowYearlyBanner] = useState<boolean | null>(null);\n\n  // Show banner only for paid monthly subscribers (not free, not yearly)\n  // useEffect(() => {\n  //   // Hide banner for free users or yearly subscribers\n  //   if (isFree || isAnnualPlan) {\n  //     setShowYearlyBanner(false);\n  //     return;\n  //   }\n\n  //   // Show banner for monthly paid users (if not dismissed)\n  //   if (Cookies.get(\"hideYearlyUpgradeBanner\") !== \"yearly-upgrade-banner\") {\n  //     setShowYearlyBanner(true);\n  //   } else {\n  //     setShowYearlyBanner(false);\n  //   }\n  // }, [isFree, isAnnualPlan]);\n\n  return (\n    <SidebarProvider open={sidebarOpen} onOpenChange={handleSidebarOpenChange}>\n      <div className=\"flex flex-1 flex-col gap-x-1 bg-gray-50 dark:bg-black md:flex-row\">\n        <AppSidebar />\n        <SidebarInset className=\"ring-1 ring-gray-200 dark:ring-gray-800\">\n          <header className=\"flex h-10 shrink-0 items-center gap-2\">\n            <div className=\"flex items-center gap-2 px-4\">\n              <SidebarTrigger className=\"-ml-1\" />\n              <Separator orientation=\"vertical\" className=\"mr-1 h-4\" />\n              <AppBreadcrumb />\n            </div>\n          </header>\n          <TrialBanner />\n          <BlockingModal />\n          <main className=\"flex-1\">{children}</main>\n        </SidebarInset>\n      </div>\n      {/* {showYearlyBanner && (\n        <YearlyUpgradeBanner setShowBanner={setShowYearlyBanner} />\n      )} */}\n    </SidebarProvider>\n  );\n}\n"
  },
  {
    "path": "components/layouts/blocking-modal.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\n\nimport { initialState } from \"@/context/team-context\";\nimport { useTeam } from \"@/context/team-context\";\nimport { TeamContextType } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { InfoIcon, ShieldAlertIcon } from \"lucide-react\";\nimport { signOut, useSession } from \"next-auth/react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useGetTeam } from \"@/lib/swr/use-team\";\nimport { useTeams } from \"@/lib/swr/use-teams\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport {\n  AlertDialog,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport { AddTeamModal } from \"../teams/add-team-modal\";\n\nexport const BlockingModal = () => {\n  const { isFree, isTrial } = usePlan();\n  const { data: session } = useSession();\n  const { team } = useGetTeam()!;\n  const { setCurrentTeam }: TeamContextType = useTeam() || initialState;\n  const { teams } = useTeams();\n\n  const currentUserId = (session?.user as CustomUser)?.id;\n  const isAdmin = team?.users.some(\n    (user) => user.userId === currentUserId && user.role === \"ADMIN\",\n  );\n\n  const userTeam = teams?.find((t) => t.id !== team?.id);\n  const multipleUsers = team?.users?.length && team?.users?.length > 1;\n\n  // const shouldShowBanner = isFree && !isTrial && isAdmin && multipleUsers;\n  const [showModal, setShowModal] = useState(false);\n\n  // Check if current user is blocked due to trial expiration\n  const currentUserTeam = team?.users.find(\n    (user) => user.userId === currentUserId,\n  );\n\n  const isBlockedDueToTrial =\n    currentUserTeam?.status === \"BLOCKED_TRIAL_EXPIRED\";\n\n  const shouldShowModal =\n    (isFree && !isTrial && !isAdmin && multipleUsers && showModal) ||\n    isBlockedDueToTrial;\n\n  useEffect(() => {\n    if (\n      (isFree && !isTrial && !isAdmin && multipleUsers) ||\n      isBlockedDueToTrial\n    ) {\n      setShowModal(true);\n    }\n  }, [isFree, isTrial, isAdmin, multipleUsers, isBlockedDueToTrial]);\n\n  useEffect(() => {\n    if (shouldShowModal) {\n      const preventRightClick = (e: MouseEvent) => {\n        e.preventDefault();\n        return false;\n      };\n\n      const preventKeyboardShortcuts = (e: KeyboardEvent) => {\n        if (e.key === \"F12\") {\n          e.preventDefault();\n          return false;\n        }\n        if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === \"I\") {\n          e.preventDefault();\n          return false;\n        }\n        if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === \"C\") {\n          e.preventDefault();\n          return false;\n        }\n        if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === \"J\") {\n          e.preventDefault();\n          return false;\n        }\n        if (e.key === \"ContextMenu\") {\n          e.preventDefault();\n          return false;\n        }\n      };\n\n      document.addEventListener(\"contextmenu\", preventRightClick);\n      document.addEventListener(\"keydown\", preventKeyboardShortcuts);\n\n      return () => {\n        document.removeEventListener(\"contextmenu\", preventRightClick);\n        document.removeEventListener(\"keydown\", preventKeyboardShortcuts);\n      };\n    }\n  }, [shouldShowModal]);\n\n  const handleSwitchTeam = () => {\n    if (userTeam) {\n      localStorage.setItem(\"currentTeamId\", userTeam.id);\n      setCurrentTeam(userTeam);\n      setShowModal(false);\n    }\n  };\n\n  return (\n    <>\n      {/* <div className=\"flex w-full px-4\">\n        {shouldShowBanner && (\n          <div className=\"flex w-full items-center gap-3 rounded-md border border-yellow-200 bg-yellow-50 p-4 text-sm dark:border-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-100\">\n            <div className=\"flex-shrink-0\">\n              <InfoIcon className=\"h-5 w-5 text-yellow-600 dark:text-yellow-400\" />\n            </div>\n            <div className=\"flex-1\">\n              <p className=\"font-medium\">\n                You&apos;re now on the free solo plan\n              </p>\n              <p className=\"mt-1 text-yellow-700 dark:text-yellow-200\">\n                To continue collaborating with your team, please upgrade your\n                plan.\n              </p>\n            </div>\n            <UpgradePlanModal\n              clickedPlan={PlanEnum.Pro}\n              trigger=\"trial_end_blocking_modal\"\n            >\n              <Button type=\"button\" variant=\"orange\">\n                Upgrade\n              </Button>\n            </UpgradePlanModal>\n            <Button type=\"button\" variant=\"outline\">\n              Dismiss\n            </Button>\n          </div>\n        )}\n      </div> */}\n      <AlertDialog open={!!shouldShowModal} onOpenChange={setShowModal}>\n        <AlertDialogContent\n          className=\"w-full max-w-lg\"\n          overlayClassName=\"backdrop-blur\"\n          onContextMenu={(e) => e.preventDefault()}\n        >\n          <AlertDialogHeader className=\"flex flex-col items-center text-center\">\n            <div className=\"mb-4 rounded-full bg-red-100/80 p-4 dark:bg-red-900/30\">\n              <ShieldAlertIcon className=\"h-10 w-10 text-red-500 dark:text-red-400\" />\n            </div>\n            <AlertDialogTitle className=\"text-2xl font-semibold\">\n              Account Access Limited\n            </AlertDialogTitle>\n            <AlertDialogDescription className=\"mt-2 space-y-2 text-muted-foreground\">\n              <div>\n                Your team is now on a free solo plan. Your access to this team\n                has been limited.\n              </div>\n              <div>\n                Contact your team owner, upgrade the team or{\" \"}\n                {userTeam ? (\n                  <span>\n                    switch to another team to continue using the platform.\n                  </span>\n                ) : (\n                  <span>create a new team to continue using the platform.</span>\n                )}\n              </div>\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <Button\n              variant=\"link\"\n              className=\"w-full sm:w-auto\"\n              onClick={() => signOut()}\n            >\n              Log out\n            </Button>\n            <UpgradePlanModal\n              clickedPlan={PlanEnum.Business}\n              trigger=\"trial_end_blocking_modal_for_team_member\"\n            >\n              <Button className=\"w-full sm:w-auto\" type=\"button\">\n                Upgrade\n              </Button>\n            </UpgradePlanModal>\n            {userTeam ? (\n              <Button\n                variant=\"outline\"\n                className=\"w-full gap-0 sm:w-auto\"\n                onClick={handleSwitchTeam}\n              >\n                Switch to &quot;\n                <span className=\"w-[15ch] max-w-fit truncate\">\n                  {userTeam.name}\n                </span>\n                &quot;\n              </Button>\n            ) : (\n              <AddTeamModal setCurrentTeam={setCurrentTeam}>\n                <Button variant=\"outline\" className=\"w-full sm:w-auto\">\n                  Create New Team\n                </Button>\n              </AddTeamModal>\n            )}\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n};\n\nexport default BlockingModal;\n"
  },
  {
    "path": "components/layouts/breadcrumb.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport React, { useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDocument } from \"@/lib/swr/use-document\";\nimport { useFolderWithParents } from \"@/lib/swr/use-folders\";\nimport useViewer from \"@/lib/swr/use-viewer\";\n\nimport { BreadcrumbComponent as DataroomBreadcrumb } from \"@/components/datarooms/dataroom-breadcrumb\";\nimport {\n  Breadcrumb,\n  BreadcrumbEllipsis,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nconst FOLDERS_TO_DISPLAY = 1; // Only show the last folder in the path\n\nconst SingleDocumentBreadcrumb = () => {\n  const { document } = useDocument();\n  const { folders } = useFolderWithParents({\n    name: document?.folder?.path ? [document.folder.path] : [],\n  });\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/documents\">Documents</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        {folders && folders.length > FOLDERS_TO_DISPLAY ? (\n          <>\n            <BreadcrumbItem>\n              <DropdownMenu>\n                <DropdownMenuTrigger className=\"flex items-center gap-1\">\n                  <BreadcrumbEllipsis className=\"h-4 w-4\" />\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"start\">\n                  {folders.slice(0, -1).map((folder, index) => (\n                    <DropdownMenuItem key={`${folder.path}-${index}`}>\n                      <Link\n                        href={`/documents/tree${folder.path}`}\n                        className=\"w-full\"\n                      >\n                        {folder.name}\n                      </Link>\n                    </DropdownMenuItem>\n                  ))}\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </BreadcrumbItem>\n            <BreadcrumbSeparator />\n            <BreadcrumbItem>\n              <BreadcrumbLink asChild>\n                <Link\n                  href={`/documents/tree${folders[folders.length - 1].path}`}\n                  className=\"max-w-[200px] truncate\"\n                >\n                  {folders[folders.length - 1].name}\n                </Link>\n              </BreadcrumbLink>\n            </BreadcrumbItem>\n            <BreadcrumbSeparator key={`sep-last`} />\n          </>\n        ) : (\n          folders?.map((folder, index) => (\n            <React.Fragment key={`${folder.path}-${index}`}>\n              <BreadcrumbItem>\n                <BreadcrumbLink asChild>\n                  <Link\n                    href={`/documents/tree${folder.path}`}\n                    className=\"max-w-[200px] truncate\"\n                  >\n                    {folder.name}\n                  </Link>\n                </BreadcrumbLink>\n              </BreadcrumbItem>\n              <BreadcrumbSeparator />\n            </React.Fragment>\n          ))\n        )}\n        {document && (\n          <BreadcrumbItem>\n            <BreadcrumbPage className=\"max-w-[200px] truncate\">\n              {document.name}\n            </BreadcrumbPage>\n          </BreadcrumbItem>\n        )}\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nexport const TruncatedBreadcrumbLink = ({\n  href,\n  text,\n}: {\n  href: string;\n  text: string | undefined;\n}) => {\n  const [isTooltipVisible, setIsTooltipVisible] = useState(false);\n  const breadcrumbRef = useRef<HTMLAnchorElement>(null);\n\n  useEffect(() => {\n    if (\n      breadcrumbRef.current &&\n      breadcrumbRef.current.scrollWidth > breadcrumbRef.current.clientWidth\n    ) {\n      setIsTooltipVisible(true);\n    } else {\n      setIsTooltipVisible(false);\n    }\n  }, [text]);\n\n  const link = (\n    <BreadcrumbLink asChild>\n      <Link\n        ref={breadcrumbRef}\n        href={href}\n        className=\"max-w-32 truncate md:max-w-60\"\n      >\n        {text || \"Loading...\"}\n      </Link>\n    </BreadcrumbLink>\n  );\n\n  if (isTooltipVisible) {\n    return <BadgeTooltip content={text || \"\"}>{link}</BadgeTooltip>;\n  }\n  return link;\n};\n\nconst SingleDataroomBreadcrumb = ({ path }: { path: string }) => {\n  const { dataroom } = useDataroom();\n\n  const title = useMemo(() => {\n    switch (path) {\n      case \"/datarooms/[id]/documents\":\n        return \"Documents\";\n      case \"/datarooms/[id]/settings\":\n        return \"Settings\";\n      case \"/datarooms/[id]/branding\":\n        return \"Branding\";\n      case \"/datarooms/[id]/permissions\":\n      case \"/datarooms/[id]/groups\":\n      case \"/datarooms/[id]/groups/[groupId]\":\n      case \"/datarooms/[id]/groups/[groupId]/permissions\":\n      case \"/datarooms/[id]/groups/[groupId]/members\":\n      case \"/datarooms/[id]/groups/[groupId]/links\":\n        return \"Permissions\";\n      case \"/datarooms/[id]/analytics\":\n        return \"Analytics\";\n      case \"/datarooms/[id]/conversations/faqs\":\n        return \"FAQ\";\n      case \"/datarooms/[id]/conversations\":\n      case \"/datarooms/[id]/conversations/[conversationId]\":\n        return \"Conversations\";\n      case \"/datarooms/[id]/settings/notifications\":\n        return \"Notifications\";\n      case \"/datarooms/[id]/settings/file-permissions\":\n        return \"File Permissions\";\n      default:\n        return dataroom?.name || \"Loading...\";\n    }\n  }, [path, dataroom]);\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/datarooms\">Datarooms</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        <BreadcrumbItem>\n          <TruncatedBreadcrumbLink\n            href={`/datarooms/${dataroom?.id}/documents`}\n            text={dataroom?.name}\n          />\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        <BreadcrumbItem>\n          <BreadcrumbPage>{title}</BreadcrumbPage>\n        </BreadcrumbItem>\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nconst SettingsBreadcrumb = () => {\n  const router = useRouter();\n  const path = router.pathname;\n\n  const settingsTitle = useMemo(() => {\n    switch (path) {\n      case \"/settings/general\":\n        return \"General\";\n      case \"/settings/people\":\n        return \"Team\";\n      case \"/settings/domains\":\n        return \"Domains\";\n      case \"/settings/presets\":\n        return \"Presets\";\n      case \"/settings/billing\":\n        return \"Billing\";\n      case \"/settings/billing/invoices\":\n        return \"Invoices\";\n      case \"/settings/tokens\":\n        return \"API Tokens\";\n      case \"/settings/webhooks\":\n        return \"Webhooks\";\n      case \"/settings/slack\":\n        return \"Slack\";\n      case \"/settings/incoming-webhooks\":\n        return \"Incoming Webhooks\";\n      case \"/settings/branding\":\n        return \"Branding\";\n      default:\n        return \"Settings\";\n    }\n  }, [path]);\n\n  const isInvoicesPage = path === \"/settings/billing/invoices\";\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/settings/general\">Settings</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        {isInvoicesPage ? (\n          <>\n            <BreadcrumbItem>\n              <BreadcrumbLink asChild>\n                <Link href=\"/settings/billing\">Billing</Link>\n              </BreadcrumbLink>\n            </BreadcrumbItem>\n            <BreadcrumbSeparator />\n            <BreadcrumbItem>\n              <BreadcrumbPage>Invoices</BreadcrumbPage>\n            </BreadcrumbItem>\n          </>\n        ) : (\n          <BreadcrumbItem>\n            <BreadcrumbPage>{settingsTitle}</BreadcrumbPage>\n          </BreadcrumbItem>\n        )}\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nconst AccountBreadcrumb = () => {\n  const router = useRouter();\n  const path = router.pathname;\n\n  const accountTitle = useMemo(() => {\n    switch (path) {\n      case \"/account/general\":\n        return \"General\";\n      case \"/account/security\":\n        return \"Security\";\n      default:\n        return \"Account\";\n    }\n  }, [path]);\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/account/general\">Account</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        <BreadcrumbItem>\n          <BreadcrumbPage>{accountTitle}</BreadcrumbPage>\n        </BreadcrumbItem>\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nconst DocumentsBreadcrumb = () => {\n  const router = useRouter();\n  const { name } = router.query as { name: string[] };\n\n  const { folders } = useFolderWithParents({ name });\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/documents\">Documents</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        {folders && folders.length > 2 ? (\n          <>\n            <BreadcrumbItem>\n              <DropdownMenu>\n                <DropdownMenuTrigger className=\"flex items-center gap-1\">\n                  <BreadcrumbEllipsis className=\"h-4 w-4\" />\n                </DropdownMenuTrigger>\n                <DropdownMenuContent align=\"start\">\n                  {folders.slice(0, -2).map((folder, index) => (\n                    <DropdownMenuItem key={`${folder.path}-${index}`}>\n                      <Link\n                        href={`/documents/tree${folder.path}`}\n                        className=\"w-full\"\n                      >\n                        {folder.name}\n                      </Link>\n                    </DropdownMenuItem>\n                  ))}\n                </DropdownMenuContent>\n              </DropdownMenu>\n            </BreadcrumbItem>\n            <BreadcrumbSeparator />\n            <BreadcrumbItem>\n              <BreadcrumbLink asChild>\n                <Link\n                  href={`/documents/tree${folders[folders.length - 2].path}`}\n                  className=\"max-w-[200px] truncate\"\n                >\n                  {folders[folders.length - 2].name}\n                </Link>\n              </BreadcrumbLink>\n            </BreadcrumbItem>\n            <BreadcrumbSeparator />\n            <BreadcrumbItem>\n              <BreadcrumbPage className=\"max-w-[200px] truncate\">\n                {folders[folders.length - 1].name}\n              </BreadcrumbPage>\n            </BreadcrumbItem>\n          </>\n        ) : (\n          folders?.map((folder, index) => (\n            <React.Fragment key={`${folder.path}-${index}`}>\n              <BreadcrumbItem key={`item-${index}`}>\n                {index === folders.length - 1 ? (\n                  <BreadcrumbPage className=\"max-w-[200px] truncate\">\n                    {folder.name}\n                  </BreadcrumbPage>\n                ) : (\n                  <BreadcrumbLink asChild>\n                    <Link\n                      href={`/documents/tree${folder.path}`}\n                      className=\"max-w-[200px] truncate\"\n                    >\n                      {folder.name}\n                    </Link>\n                  </BreadcrumbLink>\n                )}\n              </BreadcrumbItem>\n              {index < folders.length - 1 && <BreadcrumbSeparator />}\n            </React.Fragment>\n          ))\n        )}\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nconst VisitorsBreadcrumb = () => {\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/visitors\">Visitors</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nconst SingleVisitorBreadcrumb = () => {\n  const { viewer } = useViewer();\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/visitors\">Visitors</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        <BreadcrumbItem>\n          <BreadcrumbPage className=\"max-w-[200px] truncate\">\n            {viewer?.email || \"Loading...\"}\n          </BreadcrumbPage>\n        </BreadcrumbItem>\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nconst AnalyticsBreadcrumb = () => {\n  const router = useRouter();\n  const { type = \"links\" } = router.query;\n\n  const title = useMemo(() => {\n    switch (type) {\n      case \"links\":\n        return \"Links\";\n      case \"documents\":\n        return \"Documents\";\n      case \"visitors\":\n        return \"Visitors\";\n      case \"views\":\n        return \"Recent Views\";\n      default:\n        return \"Analytics\";\n    }\n  }, [type]);\n\n  return (\n    <Breadcrumb>\n      <BreadcrumbList>\n        <BreadcrumbItem>\n          <BreadcrumbLink asChild>\n            <Link href=\"/dashboard?interval=7d&type=links\">Dashboard</Link>\n          </BreadcrumbLink>\n        </BreadcrumbItem>\n        <BreadcrumbSeparator />\n        <BreadcrumbItem>\n          <BreadcrumbPage>{title}</BreadcrumbPage>\n        </BreadcrumbItem>\n      </BreadcrumbList>\n    </Breadcrumb>\n  );\n};\n\nexport const AppBreadcrumb = () => {\n  const router = useRouter();\n  const path = router.pathname;\n  const { id } = router.query as {\n    id?: string;\n  };\n\n  const breadcrumb = useMemo(() => {\n    // Analytics routes\n    if (path === \"/dashboard\") {\n      return <AnalyticsBreadcrumb />;\n    }\n\n    // Settings routes\n    if (path.startsWith(\"/settings\")) {\n      return <SettingsBreadcrumb />;\n    }\n\n    // Account routes\n    if (path.startsWith(\"/account\")) {\n      return <AccountBreadcrumb />;\n    }\n\n    // Root documents route\n    if (path === \"/documents\") {\n      return (\n        <Breadcrumb>\n          <BreadcrumbList>\n            <BreadcrumbItem>\n              <BreadcrumbPage>Documents</BreadcrumbPage>\n            </BreadcrumbItem>\n          </BreadcrumbList>\n        </Breadcrumb>\n      );\n    }\n\n    // Document tree routes\n    if (path === \"/documents/tree/[...name]\") {\n      return <DocumentsBreadcrumb />;\n    }\n\n    // Single document route\n    if (path === \"/documents/[id]\" && id) {\n      return <SingleDocumentBreadcrumb />;\n    }\n\n    // Dataroom document routes\n    if (path === \"/datarooms/[id]/documents\" && id) {\n      return <DataroomBreadcrumb />;\n    }\n\n    // Dataroom document routes\n    if (path === \"/datarooms/[id]/documents/[...name]\" && id) {\n      return <DataroomBreadcrumb />;\n    }\n\n    // Single dataroom route\n    if (path.startsWith(\"/datarooms/[id]\") && id) {\n      return <SingleDataroomBreadcrumb path={path} />;\n    }\n\n    // Root datarooms route\n    if (path === \"/datarooms\") {\n      return (\n        <Breadcrumb>\n          <BreadcrumbList>\n            <BreadcrumbItem>\n              <BreadcrumbPage>Datarooms</BreadcrumbPage>\n            </BreadcrumbItem>\n          </BreadcrumbList>\n        </Breadcrumb>\n      );\n    }\n\n    // Root visitors route\n    if (path === \"/visitors\") {\n      return <VisitorsBreadcrumb />;\n    }\n\n    // Single visitor route\n    if (path === \"/visitors/[id]\" && id) {\n      return <SingleVisitorBreadcrumb />;\n    }\n\n    return null;\n  }, [path, id]);\n\n  return breadcrumb;\n};\n"
  },
  {
    "path": "components/layouts/trial-banner.tsx",
    "content": "import { Dispatch, SetStateAction, useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport Cookies from \"js-cookie\";\nimport { CrownIcon } from \"lucide-react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useDataroomsSimple from \"@/lib/swr/use-datarooms-simple\";\nimport { daysLeft } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport {\n  Alert,\n  AlertClose,\n  AlertDescription,\n  AlertTitle,\n} from \"@/components/ui/alert\";\n\nexport default function TrialBanner() {\n  const { trial } = usePlan();\n  const isTrial = !!trial;\n  const [showTrialBanner, setShowTrialBanner] = useState<boolean | null>(null);\n\n  useEffect(() => {\n    if (Cookies.get(\"hideTrialBanner\") !== \"trial-banner\" && isTrial) {\n      setShowTrialBanner(true);\n    } else {\n      setShowTrialBanner(false);\n    }\n  }, []);\n\n  if (isTrial && showTrialBanner) {\n    return <TrialBannerComponent setShowTrialBanner={setShowTrialBanner} />;\n  }\n\n  return null;\n}\n\nfunction TrialBannerComponent({\n  setShowTrialBanner,\n}: {\n  setShowTrialBanner: Dispatch<SetStateAction<boolean | null>>;\n}) {\n  const teamInfo = useTeam();\n\n  const handleHideBanner = () => {\n    setShowTrialBanner(false);\n    Cookies.set(\"hideTrialBanner\", \"trial-banner\", {\n      expires: 1,\n    });\n  };\n\n  const { datarooms } = useDataroomsSimple();\n\n  const trialDaysLeft = datarooms\n    ? daysLeft(\n        new Date(\n          datarooms[0]?.createdAt ??\n            teamInfo?.currentTeam?.createdAt ??\n            new Date(),\n        ),\n        7,\n      )\n    : 0;\n\n  const isExpired = trialDaysLeft <= 0;\n\n  return (\n    <div className=\"mx-2 my-2 mb-2\">\n      <Alert\n        variant=\"default\"\n        className={\n          isExpired ? \"border-2 border-red-500 dark:border-red-600\" : \"\"\n        }\n      >\n        <CrownIcon className=\"h-4 w-4\" />\n        <AlertTitle>\n          {isExpired\n            ? \"Your Data Room trial has expired\"\n            : `Data Room trial: ${trialDaysLeft} days left`}\n        </AlertTitle>\n        <AlertDescription>\n          {isExpired ? (\n            <>\n              <UpgradePlanModal\n                clickedPlan={PlanEnum.DataRooms}\n                trigger={\"trial_navbar\"}\n              >\n                <span className=\"cursor-pointer font-bold text-black underline underline-offset-4 hover:text-gray-700 dark:text-white dark:hover:text-gray-300\">\n                  Upgrade to keep access\n                </span>\n              </UpgradePlanModal>{\" \"}\n              to unlimited data rooms, custom domains, advanced access controls,\n              and granular file permissions ✨\n            </>\n          ) : (\n            <>\n              You are on the <span className=\"font-bold\">Data Rooms</span> plan\n              trial, you have access to advanced access controls, granular file\n              permissions, and data room. <br />\n              <UpgradePlanModal\n                clickedPlan={PlanEnum.DataRooms}\n                trigger={\"trial_navbar\"}\n              >\n                <span className=\"cursor-pointer font-bold text-orange-500 underline underline-offset-4 hover:text-orange-600\">\n                  Upgrade to keep access\n                </span>\n              </UpgradePlanModal>\n              , unlock unlimited data rooms and custom domains ✨\n            </>\n          )}\n        </AlertDescription>\n        <AlertClose onClick={handleHideBanner} />\n      </Alert>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/delete-link-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { LinkWithViews } from \"@/lib/types\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Modal } from \"@/components/ui/modal\";\n\nfunction DeleteLinkModal({\n  showDeleteLinkModal,\n  setShowDeleteLinkModal,\n  link,\n  targetType,\n}: {\n  showDeleteLinkModal: boolean;\n  setShowDeleteLinkModal: Dispatch<SetStateAction<boolean>>;\n  link: LinkWithViews | null;\n  targetType: \"DOCUMENT\" | \"DATAROOM\";\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const analytics = useAnalytics();\n\n  const [deleting, setDeleting] = useState(false);\n  const [isValid, setIsValid] = useState(false);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  // Reset validation and clear input when modal opens\n  useEffect(() => {\n    if (showDeleteLinkModal) {\n      setIsValid(false);\n      if (inputRef.current) {\n        inputRef.current.value = \"\";\n      }\n    }\n  }, [showDeleteLinkModal]);\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const { value } = e.target;\n    // Check if the input matches the pattern\n    if (value === \"permanently delete\") {\n      setIsValid(true);\n    } else {\n      setIsValid(false);\n    }\n  };\n\n  async function deleteLink(linkId: string) {\n    setDeleting(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/links/${linkId}`,\n        {\n          method: \"DELETE\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(\n          error.error || error.message || \"Failed to delete link\",\n        );\n      }\n\n      analytics.capture(\"Link Deleted\", {\n        team: teamInfo?.currentTeam?.id,\n        linkId,\n        linkType: link?.linkType,\n        viewCount: link?._count.views || 0,\n      });\n\n      // Mutate the links cache based on target type\n      const endpointTargetType = `${targetType.toLowerCase()}s`; // \"documents\" or \"datarooms\"\n      const targetId = link?.documentId || link?.dataroomId;\n\n      await mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n          targetId ?? \"\",\n        )}/links`,\n      );\n\n      // If this is a group link, also mutate the group-specific links cache\n      if (link?.groupId) {\n        await mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n            targetId ?? \"\",\n          )}/groups/${link.groupId}/links`,\n        );\n      }\n\n      setShowDeleteLinkModal(false);\n    } finally {\n      setDeleting(false);\n    }\n  }\n\n  if (!link) return null;\n\n  const viewCount = link._count.views || 0;\n\n  return (\n    <Modal\n      showModal={showDeleteLinkModal}\n      setShowModal={setShowDeleteLinkModal}\n      noBackdropBlur\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <DialogTitle className=\"text-2xl\">Delete Link</DialogTitle>\n        <DialogDescription className=\"space-y-2\">\n          <p>\n            <strong>Warning</strong>: This will delete the link{\" \"}\n            <span className=\"font-semibold\">\n              {link.name || `Link #${link.id.slice(-5)}`}\n            </span>\n            . The link will no longer be accessible.\n          </p>\n          <p>\n            All link data including{\" \"}\n            <strong>\n              {viewCount} view{viewCount !== 1 ? \"s\" : \"\"}\n            </strong>{\" \"}\n            and visitor analytics will be preserved for historical reporting.\n            The link will be marked as deleted and archived.\n          </p>\n        </DialogDescription>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          toast.promise(deleteLink(link.id), {\n            loading: \"Deleting link...\",\n            success: \"Link deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-muted-foreground\"\n          >\n            To confirm deletion, type{\" \"}\n            <span className=\"font-semibold text-foreground\">\n              permanently delete\n            </span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              ref={inputRef}\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"permanently delete\"\n              required\n              autoComplete=\"off\"\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n              onInput={handleInputChange}\n            />\n          </div>\n        </div>\n\n        <Button\n          variant=\"destructive\"\n          type=\"submit\"\n          loading={deleting}\n          disabled={!isValid}\n        >\n          Confirm delete link\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteLinkModal({\n  link,\n  targetType,\n}: {\n  link: LinkWithViews | null;\n  targetType: \"DOCUMENT\" | \"DATAROOM\";\n}) {\n  const [showDeleteLinkModal, setShowDeleteLinkModal] = useState(false);\n\n  const DeleteLinkModalCallback = useCallback(() => {\n    return (\n      <DeleteLinkModal\n        showDeleteLinkModal={showDeleteLinkModal}\n        setShowDeleteLinkModal={setShowDeleteLinkModal}\n        link={link}\n        targetType={targetType}\n      />\n    );\n  }, [showDeleteLinkModal, setShowDeleteLinkModal, link, targetType]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteLinkModal,\n      DeleteLinkModal: DeleteLinkModalCallback,\n    }),\n    [setShowDeleteLinkModal, DeleteLinkModalCallback],\n  );\n}\n"
  },
  {
    "path": "components/links/embed-code-modal.tsx",
    "content": "import { useState } from \"react\";\n\nimport { Check, Copy } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\ninterface EmbedCodeModalProps {\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n  linkId: string;\n  linkName: string;\n}\n\nexport default function EmbedCodeModal({\n  isOpen,\n  setIsOpen,\n  linkId,\n  linkName,\n}: EmbedCodeModalProps) {\n  const [copied, setCopied] = useState(false);\n\n  const embedCode = `<iframe\n  src=\"${process.env.NEXT_PUBLIC_BASE_URL}/view/${linkId}/embed\"\n  style=\"width: 100%; height: 100%; border: none; border-radius: 8px;\"\n  allow=\"fullscreen\"\n  loading=\"lazy\">\n</iframe>`;\n\n  const copyToClipboard = () => {\n    navigator.clipboard.writeText(embedCode);\n    toast.success(\"Embed code copied to clipboard\");\n    setCopied(true);\n    setTimeout(() => setCopied(false), 2000);\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogContent className=\"sm:max-w-xl\">\n        <DialogHeader>\n          <DialogTitle className=\"font-normal\">\n            Embed Code for <span className=\"font-bold\">{linkName}</span>\n          </DialogTitle>\n        </DialogHeader>\n        <div className=\"space-y-4\">\n          <div className=\"relative\">\n            <pre className=\"max-h-[200px] overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-secondary p-4 font-mono text-sm text-secondary-foreground\">\n              <code className=\"block w-full pr-8\">{embedCode}</code>\n            </pre>\n            <Button\n              size=\"sm\"\n              variant=\"ghost\"\n              className=\"absolute right-2 top-2\"\n              onClick={copyToClipboard}\n            >\n              {copied ? (\n                <Check className=\"h-4 w-4\" />\n              ) : (\n                <Copy className=\"h-4 w-4\" />\n              )}\n            </Button>\n          </div>\n          <p className=\"text-sm text-muted-foreground\">\n            Copy and paste this code into your website to embed the document\n            link.\n          </p>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/links/link-active-controls.tsx",
    "content": "import { LinkWithViews } from \"@/lib/types\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ntype LinkSetting = {\n  key: keyof LinkWithViews;\n  label: string;\n  isActive: (link: LinkWithViews) => boolean;\n};\n\nconst LINK_SETTINGS: LinkSetting[] = [\n  {\n    key: \"emailProtected\",\n    label: \"Require email to view\",\n    isActive: (link) => !!link.emailProtected,\n  },\n  {\n    key: \"enableNotification\",\n    label: \"Receive email notification\",\n    isActive: (link) => !!link.enableNotification,\n  },\n  {\n    key: \"enableConversation\",\n    label: \"Show visitor statistics\",\n    isActive: (link) => !!link.enableConversation,\n  },\n  {\n    key: \"password\",\n    label: \"Password protection\",\n    isActive: (link) => !!link.password,\n  },\n  {\n    key: \"enableScreenshotProtection\",\n    label: \"Screenshot protection\",\n    isActive: (link) => !!link.enableScreenshotProtection,\n  },\n  {\n    key: \"enableWatermark\",\n    label: \"Watermark enabled\",\n    isActive: (link) => !!link.enableWatermark,\n  },\n  {\n    key: \"enableFeedback\",\n    label: \"Collect feedback\",\n    isActive: (link) => !!link.enableFeedback,\n  },\n  {\n    key: \"allowDownload\",\n    label: \"Allow downloads\",\n    isActive: (link) => !!link.allowDownload,\n  },\n  {\n    key: \"enableQuestion\",\n    label: \"Custom question\",\n    isActive: (link) => !!link.enableQuestion,\n  },\n  {\n    key: \"enableAgreement\",\n    label: \"Requires agreement\",\n    isActive: (link) => !!link.enableAgreement,\n  },\n  {\n    key: \"enableUpload\",\n    label: \"Allow uploads\",\n    isActive: (link) => !!link.enableUpload,\n  },\n];\n\n// Helper function to count active settings for a link\nexport function countActiveSettings(link: LinkWithViews): number {\n  return LINK_SETTINGS.filter((setting) => setting.isActive(link)).length;\n}\n\ninterface LinkActiveControlsProps {\n  link: LinkWithViews;\n  onEditClick?: (e: React.MouseEvent) => void;\n}\n\nexport default function LinkActiveControls({\n  link,\n  onEditClick,\n}: LinkActiveControlsProps) {\n  const activeSettings = LINK_SETTINGS.filter((setting) =>\n    setting.isActive(link),\n  );\n\n  return (\n    <Card className=\"p-0\">\n      <CardHeader className=\"p-2\">\n        <CardTitle className=\"text-sm font-medium\">\n          Active Link Controls\n        </CardTitle>\n      </CardHeader>\n      <CardContent className=\"p-2 pt-0\">\n        <ul className=\"space-y-1 text-sm\">\n          {activeSettings.length > 0 ? (\n            activeSettings.map((setting) => (\n              <li\n                key={setting.key.toString()}\n                className=\"flex items-center gap-2 text-foreground\"\n              >\n                <span className=\"inline-block h-2 w-2 rounded-full bg-green-400\" />\n                {setting.label}\n              </li>\n            ))\n          ) : (\n            <li className=\"text-muted-foreground\">No active settings</li>\n          )}\n        </ul>\n        {onEditClick && (\n          <div className=\"mt-4\">\n            <Button\n              className=\"h-7 w-full focus-visible:ring-0 focus-visible:ring-offset-0\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={onEditClick}\n            >\n              Configure link\n            </Button>\n          </div>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/agreement-panel/index.tsx",
    "content": "import {\n  Dispatch,\n  FormEvent,\n  SetStateAction,\n  useEffect,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport {\n  DocumentData,\n  createAgreementDocument,\n} from \"@/lib/documents/create-document\";\nimport { putFile } from \"@/lib/files/put-file\";\nimport { getSupportedContentType } from \"@/lib/utils/get-content-type\";\n\nimport DocumentUpload from \"@/components/document-upload\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\n\nimport LinkItem from \"../link-item\";\n\n// Add the validation schema\nconst agreementUrlSchema = z\n  .string()\n  .min(1, \"URL is required\")\n  .url(\"Please enter a valid URL\")\n  .refine((url) => url.startsWith(\"https://\"), {\n    message: \"URL must start with https://\",\n  });\n\nexport default function AgreementSheet({\n  defaultData,\n  isOpen,\n  setIsOpen,\n  isOnlyView = false,\n  onClose,\n}: {\n  defaultData?: { name: string; link: string; requireName: boolean; contentType?: string; textContent?: string } | null;\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  isOnlyView?: boolean;\n  onClose?: () => void;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const [data, setData] = useState({ \n    name: \"\", \n    link: \"\", \n    textContent: \"\",\n    contentType: \"LINK\",\n    requireName: true \n  });\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [currentFile, setCurrentFile] = useState<File | null>(null);\n\n  // Add validation state\n  const [urlError, setUrlError] = useState<string>(\"\");\n  const [isUrlValid, setIsUrlValid] = useState<boolean>(true);\n\n  useEffect(() => {\n    if (defaultData) {\n      setData({\n        name: defaultData?.name || \"\",\n        link: defaultData?.link || \"\",\n        textContent: defaultData?.textContent || \"\",\n        contentType: defaultData?.contentType || \"LINK\",\n        requireName: defaultData?.requireName || true,\n      });\n    }\n  }, [defaultData]);\n\n  const handleClose = (open: boolean) => {\n    setIsOpen(open);\n    setData({ \n      name: \"\", \n      link: \"\", \n      textContent: \"\",\n      contentType: \"LINK\",\n      requireName: true \n    });\n    setCurrentFile(null);\n    setIsLoading(false);\n    if (onClose) {\n      onClose();\n    }\n  };\n\n  const handleBrowserUpload = async () => {\n    // event.preventDefault();\n    // event.stopPropagation();\n    if (isOnlyView) {\n      handleClose(false);\n      toast.error(\"Cannot upload file in view mode!\");\n      return;\n    }\n    // Check if the file is chosen\n    if (!currentFile) {\n      toast.error(\"Please select a file to upload.\");\n      return; // prevent form from submitting\n    }\n\n    try {\n      setIsLoading(true);\n\n      const contentType = currentFile.type;\n      const supportedFileType = getSupportedContentType(contentType);\n\n      if (\n        !supportedFileType ||\n        (supportedFileType !== \"pdf\" && supportedFileType !== \"docs\")\n      ) {\n        toast.error(\n          \"Unsupported file format. Please upload a PDF or Word file.\",\n        );\n        return;\n      }\n\n      const { type, data, numPages, fileSize } = await putFile({\n        file: currentFile,\n        teamId: teamId!,\n      });\n\n      const documentData: DocumentData = {\n        name: currentFile.name,\n        key: data!,\n        storageType: type!,\n        contentType: contentType,\n        supportedFileType: supportedFileType,\n        fileSize: fileSize,\n      };\n      // create a document in the database\n      const response = await createAgreementDocument({\n        documentData,\n        teamId: teamId!,\n        numPages,\n      });\n\n      if (response) {\n        const document = await response.json();\n        const linkId = document.links[0].id;\n        setData((prevData) => ({\n          ...prevData,\n          link: \"https://www.papermark.com/view/\" + linkId,\n        }));\n      }\n    } catch (error) {\n      console.error(\"An error occurred while uploading the file: \", error);\n    } finally {\n      setCurrentFile(null);\n      setIsLoading(false);\n    }\n  };\n\n  // Add URL validation function\n  const validateUrl = (url: string) => {\n    if (!url.trim()) {\n      setUrlError(\"\");\n      setIsUrlValid(true);\n      return;\n    }\n\n    try {\n      agreementUrlSchema.parse(url);\n      setUrlError(\"\");\n      setIsUrlValid(true);\n    } catch (error) {\n      if (error instanceof z.ZodError) {\n        const firstError = error.errors[0];\n        setUrlError(firstError?.message || \"Invalid URL\");\n        setIsUrlValid(false);\n      }\n    }\n  };\n\n  // Modify handleSubmit to include validation\n  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n    e.stopPropagation();\n\n    if (isOnlyView) {\n      handleClose(false);\n      toast.error(\"Agreement cannot be created in view mode\");\n      return;\n    }\n\n    // Validate based on content type\n    if (data.contentType === \"LINK\") {\n      // Validate URL\n      try {\n        agreementUrlSchema.parse(data.link);\n      } catch (error) {\n        if (error instanceof z.ZodError) {\n          const firstError = error.errors[0];\n          toast.error(firstError?.message || \"Please enter a valid URL\");\n          return;\n        }\n      }\n    } else if (data.contentType === \"TEXT\") {\n      // Validate text content\n      if (!data.textContent.trim()) {\n        toast.error(\"Please enter agreement text content\");\n        return;\n      }\n    }\n\n    setIsLoading(true);\n\n    try {\n      const submitData = {\n        name: data.name,\n        contentType: data.contentType,\n        content: data.contentType === \"LINK\" ? data.link : data.textContent,\n        requireName: data.requireName,\n      };\n\n      const response = await fetch(`/api/teams/${teamId}/agreements`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(submitData),\n      });\n\n      if (!response.ok) {\n        toast.error(\"Error creating agreement\");\n        return;\n      }\n\n      mutate(`/api/teams/${teamId}/agreements`);\n    } catch (error) {\n      console.error(error);\n      toast.error(\"An error occurred. Please try again.\");\n    } finally {\n      setIsLoading(false);\n      setIsOpen(false);\n      setData({ \n        name: \"\", \n        link: \"\", \n        textContent: \"\",\n        contentType: \"LINK\",\n        requireName: true \n      });\n    }\n  };\n\n  useEffect(() => {\n    if (currentFile) {\n      handleBrowserUpload();\n    }\n  }, [currentFile]);\n\n  return (\n    <Sheet open={isOpen} onOpenChange={handleClose}>\n      <SheetContent className=\"flex h-full w-[85%] flex-col justify-between bg-background px-4 text-foreground sm:w-[500px] md:px-5\">\n        <SheetHeader className=\"text-start\">\n          <SheetTitle>\n            {isOnlyView ? \"View Agreement\" : \"Create a new agreement\"}\n          </SheetTitle>\n          <SheetDescription>\n            {isOnlyView\n              ? \"View the details of this agreement.\"\n              : \"An agreement is a special document that visitors must accept before accessing your link. You can create a new agreement here.\"}\n          </SheetDescription>\n        </SheetHeader>\n\n        <ScrollArea className=\"flex-1\">\n          <form className=\"flex grow flex-col\" onSubmit={handleSubmit}>\n            <div className=\"flex-grow space-y-6\">\n              <div className=\"w-full space-y-2\">\n                <Label htmlFor=\"name\">Display name</Label>\n                <Input\n                  className=\"flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6\"\n                  id=\"name\"\n                  type=\"text\"\n                  name=\"name\"\n                  required\n                  autoComplete=\"off\"\n                  data-1p-ignore\n                  placeholder=\"Standard NDA\"\n                  value={data.name || \"\"}\n                  onChange={(e) =>\n                    setData({\n                      ...data,\n                      name: e.target.value,\n                    })\n                  }\n                  disabled={isOnlyView}\n                />\n              </div>\n\n              <div>\n                <LinkItem\n                  title=\"Require viewer's name\"\n                  enabled={data.requireName}\n                  action={() =>\n                    setData({ ...data, requireName: !data.requireName })\n                  }\n                  isAllowed={!isOnlyView}\n                />\n              </div>\n\n              <div className=\"space-y-4\">\n                {/* Content Type Selection */}\n                <div className=\"w-full space-y-2\">\n                  <Label>Agreement Content Type</Label>\n                  <RadioGroup \n                    value={data.contentType} \n                    onValueChange={(value) => setData({...data, contentType: value})}\n                    disabled={isOnlyView}\n                    className=\"flex flex-col space-y-2\"\n                  >\n                    <div className=\"flex items-center space-x-2\">\n                      <RadioGroupItem value=\"LINK\" id=\"link-type\" />\n                      <Label htmlFor=\"link-type\">Link to agreement document</Label>\n                    </div>\n                    <div className=\"flex items-center space-x-2\">\n                      <RadioGroupItem value=\"TEXT\" id=\"text-type\" />\n                      <Label htmlFor=\"text-type\">Text content</Label>\n                    </div>\n                  </RadioGroup>\n                </div>\n\n                {/* Link Content */}\n                {data.contentType === \"LINK\" && (\n                  <div className=\"w-full space-y-2\">\n                    <Label htmlFor=\"link\">Link to an agreement</Label>\n                    <Input\n                      className={`flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset placeholder:text-muted-foreground focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 ${\n                        !isUrlValid\n                          ? \"ring-red-500 focus:ring-red-500\"\n                          : \"ring-input focus:ring-gray-400\"\n                      }`}\n                      id=\"link\"\n                      type=\"text\"\n                      name=\"link\"\n                      required={data.contentType === \"LINK\"}\n                      autoComplete=\"off\"\n                      data-1p-ignore\n                      placeholder=\"https://www.papermark.com/nda\"\n                      value={data.link || \"\"}\n                      onChange={(e) => {\n                        const newValue = e.target.value;\n                        setData({\n                          ...data,\n                          link: newValue,\n                        });\n                        validateUrl(newValue);\n                      }}\n                      onBlur={(e) => {\n                        validateUrl(e.target.value);\n                      }}\n                      disabled={isOnlyView}\n                    />\n                    {urlError && (\n                      <p className=\"mt-1 text-sm text-red-500\">{urlError}</p>\n                    )}\n\n                    {!isOnlyView && (\n                      <div className=\"space-y-12\">\n                        <div className=\"space-y-2 pb-6\">\n                          <Label>Or upload an agreement</Label>\n                          <div className=\"grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6\">\n                            <DocumentUpload\n                              currentFile={currentFile}\n                              setCurrentFile={setCurrentFile}\n                            />\n                          </div>\n                        </div>\n                      </div>\n                    )}\n                  </div>\n                )}\n\n                {/* Text Content */}\n                {data.contentType === \"TEXT\" && (\n                  <div className=\"w-full space-y-2\">\n                    <Label htmlFor=\"textContent\">Agreement Text</Label>\n                    <Textarea\n                      className=\"flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6\"\n                      id=\"textContent\"\n                      name=\"textContent\"\n                      required={data.contentType === \"TEXT\"}\n                      placeholder=\"By accessing this document, you agree to maintain confidentiality of all information contained herein and not to share, copy, or distribute any content without prior written consent...\"\n                      value={data.textContent || \"\"}\n                      onChange={(e) =>\n                        setData({\n                          ...data,\n                          textContent: e.target.value,\n                        })\n                      }\n                      disabled={isOnlyView}\n                      rows={6}\n                      maxLength={1500}\n                    />\n                    <div className=\"flex justify-between text-xs\">\n                      <p className=\"text-muted-foreground\">\n                        This text will be displayed to users as a compliance agreement before they can access the content.\n                      </p>\n                      <p className={`${data.textContent.length > 1400 ? 'text-orange-500' : 'text-muted-foreground'} ${data.textContent.length >= 1500 ? 'text-red-500 font-semibold' : ''}`}>\n                        {data.textContent.length}/1500\n                      </p>\n                    </div>\n                  </div>\n                )}\n              </div>\n            </div>\n            <SheetFooter\n              className={`flex-shrink-0 ${isOnlyView ? \"mt-6\" : \"\"}`}\n            >\n              <div className=\"flex items-center\">\n                {isOnlyView ? (\n                  <Button type=\"button\" onClick={() => handleClose(false)}>\n                    Close\n                  </Button>\n                ) : (\n                  <Button\n                    type=\"submit\"\n                    loading={isLoading}\n                    disabled={\n                      (data.contentType === \"LINK\" && !isUrlValid && data.link.trim() !== \"\") ||\n                      (data.contentType === \"TEXT\" && !data.textContent.trim())\n                    }\n                  >\n                    Create Agreement\n                  </Button>\n                )}\n              </div>\n            </SheetFooter>\n          </form>\n        </ScrollArea>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/agreement-section.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\n\nimport { Agreement, LinkPreset } from \"@prisma/client\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { useAgreements } from \"@/lib/swr/use-agreements\";\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport AgreementSheet from \"./agreement-panel\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function AgreementSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { agreements } = useAgreements();\n  const { enableAgreement, agreementId, emailProtected } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [isAgreementSheetVisible, setIsAgreementSheetVisible] =\n    useState<boolean>(false);\n\n  const filteredAgreements = useMemo(\n    () =>\n      agreements.filter(\n        (agreement: Agreement) =>\n          !agreement.deletedAt || agreement.id === agreementId,\n      ),\n    [agreements, agreementId],\n  );\n\n  useEffect(() => {\n    setEnabled(enableAgreement!);\n  }, [enableAgreement]);\n\n  const handleAgreement = async () => {\n    const updatedAgreement = !enabled;\n\n    setData({\n      ...data,\n      enableAgreement: updatedAgreement,\n      emailProtected: updatedAgreement ? true : emailProtected,\n    });\n    setEnabled(updatedAgreement);\n  };\n\n  const handleAgreementChange = (value: string) => {\n    if (value === \"add_agreement\") {\n      // Open the agreement sheet\n      setIsAgreementSheetVisible(true);\n      return;\n    }\n\n    setData({ ...data, agreementId: value });\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Require NDA to view\"\n        link=\"https://www.papermark.com/help/article/require-nda-to-view\"\n        tooltipContent=\"Users must acknowledge an agreement to access the content.\"\n        enabled={enabled}\n        action={handleAgreement}\n        isAllowed={isAllowed}\n        requiredPlan=\"datarooms\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_agreement_section\",\n            plan: \"Data Rooms\",\n            highlightItem: [\"nda\"],\n          })\n        }\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 space-y-3\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <div className=\"flex w-full flex-col items-start gap-6 overflow-x-visible pb-4 pt-0\">\n            <div className=\"w-full space-y-2\">\n              <Select\n                onValueChange={handleAgreementChange}\n                defaultValue={agreementId ?? \"\"}\n              >\n                <SelectTrigger className=\"focus:ring-offset-3 flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-gray-400 sm:text-sm sm:leading-6\">\n                  <SelectValue placeholder=\"Select an agreement\" />\n                </SelectTrigger>\n                <SelectContent>\n                  {filteredAgreements &&\n                    filteredAgreements.map(({ id, name }) => (\n                      <SelectItem key={id} value={id}>\n                        {name}\n                      </SelectItem>\n                    ))}\n                  <SelectItem key=\"add_agreement\" value=\"add_agreement\">\n                    Add new agreement\n                  </SelectItem>\n                </SelectContent>\n              </Select>\n            </div>\n          </div>\n        </motion.div>\n      )}\n\n      <AgreementSheet\n        isOpen={isAgreementSheetVisible}\n        setIsOpen={setIsAgreementSheetVisible}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/ai-agents-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\n\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport LinkItem from \"@/components/links/link-sheet/link-item\";\n\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function AIAgentsSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { isFeatureEnabled, isLoading: featuresLoading } = useFeatureFlags();\n  const isAIFeatureEnabled = isFeatureEnabled(\"ai\");\n\n  const { enableAIAgents } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n\n  useEffect(() => {\n    setEnabled(enableAIAgents);\n  }, [enableAIAgents]);\n\n  const handleToggle = async () => {\n    const updatedState = !enabled;\n\n    setData({\n      ...data,\n      enableAIAgents: updatedState,\n    });\n    setEnabled(updatedState);\n  };\n\n  // Don't render if feature flags are still loading or AI feature is not enabled\n  if (featuresLoading || !isAIFeatureEnabled) {\n    return null;\n  }\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"AI Agents\"\n        enabled={enabled}\n        action={handleToggle}\n        isAllowed={isAllowed}\n        requiredPlan=\"Business\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_ai_agents\",\n            plan: \"Business\",\n          })\n        }\n        tooltipContent=\"Allow visitors to chat with AI about this document or dataroom. Requires AI to be enabled at the team and document/dataroom level.\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/allow-download-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nexport default function AllowDownloadSection({\n  data,\n  setData,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n}) {\n  const { allowDownload } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n\n  useEffect(() => {\n    setEnabled(allowDownload);\n  }, [allowDownload]);\n\n  const handleAllowDownload = () => {\n    const updatedAllowDownload = !enabled;\n    setData({ ...data, allowDownload: updatedAllowDownload });\n    setEnabled(updatedAllowDownload);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Allow downloading\"\n        enabled={enabled}\n        link=\"https://www.papermark.com/help/article/link-settings\"\n        action={handleAllowDownload}\n        tooltipContent=\"Allow visitors to download the content.\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/allow-list-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { LinkPreset } from \"@prisma/client\";\nimport { CheckIcon, UsersIcon, XIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport useVisitorGroups from \"@/lib/swr/use-visitor-groups\";\nimport { sanitizeList } from \"@/lib/utils\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function AllowListSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n  presets,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n  presets: LinkPreset | null;\n}) {\n  const { emailProtected, allowList, visitorGroupIds } = data;\n  const { visitorGroups } = useVisitorGroups();\n\n  // Initialize enabled state based on whether allowList is not null and not empty\n  // or if visitor groups are selected\n  const [enabled, setEnabled] = useState<boolean>(\n    (!!allowList && allowList.length > 0) ||\n      (!!visitorGroupIds && visitorGroupIds.length > 0),\n  );\n  const [allowListInput, setAllowListInput] = useState<string>(\n    allowList?.join(\"\\n\") || \"\",\n  );\n\n  useEffect(() => {\n    if (!emailProtected && enabled) {\n      setEnabled(false);\n      setData((prevData) => ({\n        ...prevData,\n        allowList: [],\n        visitorGroupIds: [],\n      }));\n    }\n  }, [emailProtected, enabled, setData]);\n\n  useEffect(() => {\n    if (isAllowed && presets?.allowList && presets.allowList.length > 0) {\n      setEnabled(true);\n      setAllowListInput(presets.allowList.join(\"\\n\") || \"\");\n    }\n  }, [presets, isAllowed]);\n\n  const handleEnableAllowList = () => {\n    const updatedEnabled = !enabled;\n    setEnabled(updatedEnabled);\n\n    if (updatedEnabled) {\n      setData((prevData) => ({\n        ...prevData,\n        allowList: updatedEnabled ? sanitizeList(allowListInput) : [],\n        emailAuthenticated: true, // Turn on email authentication\n        emailProtected: true, // Turn on email protection\n      }));\n    } else {\n      setData((prevData) => ({\n        ...prevData,\n        allowList: [],\n        visitorGroupIds: [],\n      }));\n    }\n  };\n\n  const handleAllowListChange = (\n    event: React.ChangeEvent<HTMLTextAreaElement>,\n  ) => {\n    const updatedAllowListInput = event.target.value;\n    setAllowListInput(updatedAllowListInput);\n\n    if (emailProtected && enabled) {\n      setData((prevData) => ({\n        ...prevData,\n        allowList: sanitizeList(updatedAllowListInput),\n      }));\n    }\n  };\n\n  const toggleVisitorGroup = (groupId: string) => {\n    setData((prevData) => {\n      const currentIds = prevData.visitorGroupIds || [];\n      const newIds = currentIds.includes(groupId)\n        ? currentIds.filter((id) => id !== groupId)\n        : [...currentIds, groupId];\n      return { ...prevData, visitorGroupIds: newIds };\n    });\n  };\n\n  const removeVisitorGroup = (groupId: string) => {\n    setData((prevData) => ({\n      ...prevData,\n      visitorGroupIds: (prevData.visitorGroupIds || []).filter(\n        (id) => id !== groupId,\n      ),\n    }));\n  };\n\n  const selectedGroups =\n    visitorGroups?.filter((g) => visitorGroupIds?.includes(g.id)) || [];\n\n  return (\n    <div className=\"pb-5\">\n      <div className=\"flex flex-col space-y-4\">\n        <LinkItem\n          title=\"Allow specified viewers\"\n          link=\"https://www.papermark.com/help/article/allow-list\"\n          tooltipContent={`Restrict access to a selected group of viewers. Enter allowed emails or domains${visitorGroups && visitorGroups.length > 0 ? \", or select visitor groups\" : \"\"}.`}\n          enabled={enabled}\n          isAllowed={isAllowed}\n          action={handleEnableAllowList}\n          requiredPlan=\"business\"\n          upgradeAction={() =>\n            handleUpgradeStateChange({\n              state: true,\n              trigger: \"link_sheet_allowlist_section\",\n              plan: \"Business\",\n              highlightItem: [\"allow-block\"],\n            })\n          }\n        />\n\n        {enabled && (\n          <motion.div\n            className=\"mt-1 block w-full space-y-3\"\n            {...FADE_IN_ANIMATION_SETTINGS}\n          >\n            {/* Visitor Groups Selector */}\n            {visitorGroups && visitorGroups.length > 0 && (\n              <div>\n                <div className=\"mb-1.5 flex items-center gap-1.5\">\n                  <UsersIcon className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                  <span className=\"text-xs font-medium text-muted-foreground\">\n                    Visitor Groups\n                  </span>\n                </div>\n\n                {/* Selected groups as badges */}\n                {selectedGroups.length > 0 && (\n                  <div className=\"mb-2 flex flex-wrap gap-1.5\">\n                    {selectedGroups.map((group) => (\n                      <Badge\n                        key={group.id}\n                        variant=\"secondary\"\n                        className=\"gap-1 pr-1\"\n                      >\n                        {group.name}\n                        <span className=\"text-muted-foreground\">\n                          ({group.emails.length})\n                        </span>\n                        <button\n                          type=\"button\"\n                          onClick={() => removeVisitorGroup(group.id)}\n                          className=\"ml-0.5 rounded-full p-0.5 hover:bg-muted\"\n                        >\n                          <XIcon className=\"h-3 w-3\" />\n                        </button>\n                      </Badge>\n                    ))}\n                  </div>\n                )}\n\n                <Popover>\n                  <PopoverTrigger asChild>\n                    <Button\n                      type=\"button\"\n                      variant=\"outline\"\n                      size=\"sm\"\n                      className=\"w-full justify-start text-muted-foreground\"\n                    >\n                      <UsersIcon className=\"mr-2 h-3.5 w-3.5\" />\n                      {selectedGroups.length > 0\n                        ? `${selectedGroups.length} group${selectedGroups.length > 1 ? \"s\" : \"\"} selected`\n                        : \"Select visitor groups...\"}\n                    </Button>\n                  </PopoverTrigger>\n                  <PopoverContent className=\"w-72 p-1\" align=\"start\">\n                    <div className=\"max-h-60 overflow-y-auto\">\n                      {visitorGroups.map((group) => {\n                        const isSelected = visitorGroupIds?.includes(group.id);\n                        return (\n                          <button\n                            key={group.id}\n                            type=\"button\"\n                            className=\"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted\"\n                            onClick={() => toggleVisitorGroup(group.id)}\n                          >\n                            <div\n                              className={`flex h-4 w-4 items-center justify-center rounded-sm border ${\n                                isSelected\n                                  ? \"border-primary bg-primary text-primary-foreground\"\n                                  : \"border-muted-foreground/30\"\n                              }`}\n                            >\n                              {isSelected && (\n                                <CheckIcon className=\"h-3 w-3\" />\n                              )}\n                            </div>\n                            <div className=\"min-w-0 flex-1\">\n                              <div className=\"truncate font-medium\">\n                                {group.name}\n                              </div>\n                              <div className=\"text-xs text-muted-foreground\">\n                                {group.emails.length}{\" \"}\n                                {group.emails.length === 1\n                                  ? \"entry\"\n                                  : \"entries\"}\n                              </div>\n                            </div>\n                          </button>\n                        );\n                      })}\n                    </div>\n                  </PopoverContent>\n                </Popover>\n\n                <div className=\"my-2 flex items-center gap-2\">\n                  <div className=\"h-px flex-1 bg-border\" />\n                  <span className=\"text-xs text-muted-foreground\">\n                    plus individual emails\n                  </span>\n                  <div className=\"h-px flex-1 bg-border\" />\n                </div>\n              </div>\n            )}\n\n            <Textarea\n              className=\"focus:ring-inset\"\n              rows={5}\n              placeholder={`Enter allowed emails/domains, one per line, e.g.\nmarc@papermark.com\n@example.org`}\n              value={allowListInput}\n              onChange={handleAllowListChange}\n            />\n          </motion.div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/allow-notification-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nexport default function AllowNotificationSection({\n  data,\n  setData,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n}) {\n  const { enableNotification } = data;\n  const [enabled, setEnabled] = useState<boolean>(true);\n\n  useEffect(() => {\n    setEnabled(enableNotification);\n  }, [enableNotification]);\n\n  const handleEnableNotification = () => {\n    const updatedEnableNotification = !enabled;\n    setData({ ...data, enableNotification: updatedEnableNotification });\n    setEnabled(updatedEnableNotification);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Receive email notification\"\n        link=\"https://www.papermark.com/help/article/link-settings\"\n        enabled={enabled}\n        action={handleEnableNotification}\n        tooltipContent=\"Get notified via email when someone views your content.\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/conversation-section.tsx",
    "content": "import ConversationSection from \"@/ee/features/conversations/components/dashboard/link-option-conversation-section\";\n\nexport default ConversationSection;\n"
  },
  {
    "path": "components/links/link-sheet/custom-fields-panel/custom-field.tsx",
    "content": "import { memo, useEffect, useState } from \"react\";\n\nimport { Trash2 } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Switch } from \"@/components/ui/switch\";\n\nimport { type CustomFieldData } from \".\";\n\ninterface CustomFieldProps {\n  field: CustomFieldData;\n  onUpdate: (field: CustomFieldData) => void;\n  onDelete: () => void;\n  onMoveUp?: () => void;\n  onMoveDown?: () => void;\n  isFirst?: boolean;\n  isLast?: boolean;\n}\n\nexport default memo(function CustomField({\n  field,\n  onUpdate,\n  onDelete,\n  onMoveUp,\n  onMoveDown,\n  isFirst,\n  isLast,\n}: CustomFieldProps) {\n  const [localField, setLocalField] = useState<CustomFieldData>(field);\n\n  useEffect(() => {\n    onUpdate(localField);\n  }, [localField]);\n\n  const handleInputChange = (\n    e: React.ChangeEvent<HTMLInputElement>,\n    key: keyof CustomFieldData,\n  ) => {\n    if (key === \"label\") {\n      setLocalField((prev) => ({\n        ...prev,\n        [key]: e.target.value,\n        identifier: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, \"-\"),\n      }));\n    } else {\n      setLocalField((prev) => ({\n        ...prev,\n        [key]: e.target.value,\n      }));\n    }\n  };\n\n  const handleSelectChange = (value: string, key: keyof CustomFieldData) => {\n    setLocalField((prev) => ({\n      ...prev,\n      [key]: value,\n    }));\n  };\n\n  const handleSwitchChange = (checked: boolean, key: keyof CustomFieldData) => {\n    setLocalField((prev) => ({\n      ...prev,\n      [key]: checked,\n    }));\n  };\n\n  return (\n    <div className=\"group relative flex flex-col space-y-4 rounded-lg border border-border bg-card p-4\">\n      <div className=\"absolute -right-2 -top-2 hidden group-hover:block\">\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-8 w-8 bg-background text-muted-foreground hover:bg-background hover:text-destructive\"\n          onClick={onDelete}\n        >\n          <Trash2 className=\"h-4 w-4\" />\n        </Button>\n      </div>\n\n      <div className=\"grid gap-4\">\n        <div className=\"grid gap-2\">\n          <Label htmlFor=\"type\">Input Type</Label>\n          <Select\n            value={localField.type}\n            onValueChange={(value) => handleSelectChange(value, \"type\")}\n          >\n            <SelectTrigger>\n              <SelectValue placeholder=\"Select field type\" />\n            </SelectTrigger>\n            <SelectContent>\n              <SelectItem value=\"SHORT_TEXT\">Short Text</SelectItem>\n              <SelectItem value=\"LONG_TEXT\">Long Text</SelectItem>\n              <SelectItem value=\"NUMBER\">Number</SelectItem>\n              <SelectItem value=\"PHONE_NUMBER\">Phone</SelectItem>\n              <SelectItem value=\"URL\">URL</SelectItem>\n              <SelectItem value=\"CHECKBOX\">Checkbox</SelectItem>\n            </SelectContent>\n          </Select>\n        </div>\n\n        <div className=\"grid gap-2\">\n          <Label htmlFor=\"label\">Label</Label>\n          <Input\n            id=\"label\"\n            type=\"text\"\n            required\n            value={localField.label}\n            onChange={(e) => handleInputChange(e, \"label\")}\n            placeholder=\"e.g., Company Name\"\n          />\n        </div>\n\n        {/* <div className=\"grid gap-2\">\n          <Label htmlFor=\"identifier\">Identifier</Label>\n          <Input\n            id=\"identifier\"\n            value={localField.identifier}\n            type=\"text\"\n            disabled\n            onChange={(e) => {\n              const value = e.target.value\n                .toLowerCase()\n                .replace(/[^a-z0-9-]/g, \"-\");\n              handleInputChange(\n                { ...e, target: { ...e.target, value } },\n                \"identifier\",\n              );\n            }}\n            placeholder=\"e.g., company-name\"\n          />\n        </div> */}\n\n        {localField.type !== \"CHECKBOX\" && (\n          <div className=\"grid gap-2\">\n            <Label htmlFor=\"placeholder\">Placeholder</Label>\n            <Input\n              id=\"placeholder\"\n              type=\"text\"\n              value={localField.placeholder || \"\"}\n              onChange={(e) => handleInputChange(e, \"placeholder\")}\n              placeholder=\"e.g., Enter your company name\"\n            />\n          </div>\n        )}\n\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex flex-col space-y-1\">\n            <Label>Required Field</Label>\n            <span className=\"text-sm text-muted-foreground\">\n              Make this field mandatory\n            </span>\n          </div>\n          <Switch\n            checked={localField.required}\n            onCheckedChange={(checked) =>\n              handleSwitchChange(checked, \"required\")\n            }\n          />\n        </div>\n      </div>\n    </div>\n  );\n});\n"
  },
  {
    "path": "components/links/link-sheet/custom-fields-panel/index.tsx",
    "content": "import { useCallback } from \"react\";\n\nimport { CustomField, CustomFieldType } from \"@prisma/client\";\nimport { Plus } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport useLimits from \"@/lib/swr/use-limits\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\n\nimport CustomFieldComponent from \"./custom-field\";\n\nexport type CustomFieldData = Omit<\n  CustomField,\n  \"id\" | \"createdAt\" | \"updatedAt\" | \"linkId\"\n> & {\n  type: Omit<CustomFieldType, \"CHECKBOX\" | \"SELECT\" | \"MULTI_SELECT\">;\n};\n\nexport default function CustomFieldsPanel({\n  fields,\n  onChange,\n  isConfigOpen,\n  setIsConfigOpen,\n}: {\n  fields: CustomFieldData[];\n  onChange: (fields: CustomFieldData[]) => void;\n  isConfigOpen: boolean;\n  setIsConfigOpen: (open: boolean) => void;\n}) {\n  const { limits } = useLimits();\n\n  const fieldLimit = limits?.linkCustomFields ?? 0;\n\n  const addField = useCallback(() => {\n    if (fields.length >= fieldLimit) {\n      toast.error(\n        `You can only add up to ${fieldLimit} custom field${fieldLimit === 1 ? \"\" : \"s\"} on your current plan`,\n      );\n      return;\n    }\n\n    const newField: CustomFieldData = {\n      type: \"SHORT_TEXT\",\n      identifier: \"\",\n      label: \"\",\n      placeholder: \"\",\n      required: false,\n      disabled: false,\n      orderIndex: fields.length,\n    };\n    onChange([...fields, newField]);\n  }, [fields, fieldLimit, onChange]);\n\n  const updateField = useCallback(\n    (index: number, updatedField: CustomFieldData) => {\n      const newFields = [...fields];\n      newFields[index] = updatedField;\n      onChange(newFields);\n    },\n    [fields, onChange],\n  );\n\n  const removeField = useCallback(\n    (index: number) => {\n      const newFields = fields.filter((_, i) => i !== index);\n      // Update orderIndex for remaining fields\n      newFields.forEach((field, i) => {\n        field.orderIndex = i;\n      });\n      onChange(newFields);\n    },\n    [fields, onChange],\n  );\n\n  const moveField = useCallback(\n    (index: number, direction: \"up\" | \"down\") => {\n      if (\n        (direction === \"up\" && index === 0) ||\n        (direction === \"down\" && index === fields.length - 1)\n      )\n        return;\n\n      const newFields = [...fields];\n      const newIndex = direction === \"up\" ? index - 1 : index + 1;\n      [newFields[index], newFields[newIndex]] = [\n        newFields[newIndex],\n        newFields[index],\n      ];\n\n      // Update orderIndex for all fields\n      newFields.forEach((field, i) => {\n        field.orderIndex = i;\n      });\n\n      onChange(newFields);\n    },\n    [fields, onChange],\n  );\n\n  return (\n    <Sheet open={isConfigOpen} onOpenChange={setIsConfigOpen}>\n      <SheetContent className=\"flex h-full flex-col\">\n        <SheetHeader>\n          <SheetTitle>Configure Custom Form Fields</SheetTitle>\n          <SheetDescription>\n            Configure the custom fields that will be shown to viewers.\n            {fieldLimit > 0 && (\n              <span className=\"mt-1 block text-sm text-muted-foreground\">\n                You can add up to {fieldLimit} custom field\n                {fieldLimit === 1 ? \"\" : \"s\"} on your current plan.\n              </span>\n            )}\n          </SheetDescription>\n        </SheetHeader>\n\n        <div className=\"flex items-center justify-between\">\n          <div className=\"text-sm text-muted-foreground\">\n            {fields.length} of {fieldLimit} custom field\n            {fields.length === 1 ? \"\" : \"s\"}\n          </div>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={addField}\n            className=\"flex items-center gap-2\"\n            disabled={fields.length >= fieldLimit}\n          >\n            <Plus className=\"h-4 w-4\" />\n            Add Field\n          </Button>\n        </div>\n\n        <Separator />\n\n        <ScrollArea className=\"flex-1\">\n          <div className=\"space-y-4\">\n            {fields.map((field, index) => (\n              <CustomFieldComponent\n                key={index}\n                field={field}\n                onUpdate={(updatedField) => updateField(index, updatedField)}\n                onDelete={() => removeField(index)}\n                onMoveUp={() => moveField(index, \"up\")}\n                onMoveDown={() => moveField(index, \"down\")}\n                isFirst={index === 0}\n                isLast={index === fields.length - 1}\n              />\n            ))}\n          </div>\n        </ScrollArea>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/custom-fields-section.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from \"react\";\n\nimport { LinkPreset, Prisma } from \"@prisma/client\";\nimport { SettingsIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\n\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { CustomFieldData } from \"./custom-fields-panel\";\nimport CustomFieldsPanel from \"./custom-fields-panel\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function CustomFieldsSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n  presets,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: (options: LinkUpgradeOptions) => void;\n  presets: LinkPreset | null;\n}) {\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [isConfigOpen, setIsConfigOpen] = useState<boolean>(false);\n\n  useEffect(() => {\n    const hasCustomFields = data.customFields.length > 0;\n    if (enabled !== hasCustomFields) {\n      setEnabled(hasCustomFields);\n    }\n  }, [data.customFields, enabled]);\n\n  useEffect(() => {\n    if (isAllowed && presets?.enableCustomFields && presets?.customFields) {\n      console.log(\"presets\", presets.customFields);\n      setEnabled(true);\n      setData((prevData) => ({\n        ...prevData,\n        customFields: presets.customFields\n          ? (presets.customFields as CustomFieldData[])\n          : [],\n      }));\n    }\n  }, [presets, isAllowed]);\n\n  const handleCustomFieldsToggle = useCallback(() => {\n    const updatedEnabled = !enabled;\n    setData((prevData) => ({\n      ...prevData,\n      customFields: updatedEnabled\n        ? [\n            {\n              type: \"SHORT_TEXT\",\n              identifier: \"\",\n              label: \"\",\n              placeholder: \"\",\n              required: false,\n              disabled: false,\n              orderIndex: 0,\n            },\n          ]\n        : [],\n    }));\n    setEnabled(updatedEnabled);\n  }, [enabled, setData]);\n\n  const handleConfigSave = useCallback(\n    (fields: CustomFieldData[]) => {\n      setData((prevData) => ({\n        ...prevData,\n        customFields: fields,\n      }));\n    },\n    [setData],\n  );\n\n  const memoizedFields = useMemo(\n    () => data.customFields || [],\n    [data.customFields],\n  );\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Custom Form Fields\"\n        tooltipContent=\"Add custom fields to collect additional information from viewers\"\n        link=\"https://www.papermark.com/help/article/custom-fields\"\n        enabled={enabled}\n        action={handleCustomFieldsToggle}\n        isAllowed={isAllowed}\n        requiredPlan=\"business\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"custom_fields\",\n            plan: \"Business\",\n          })\n        }\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 space-y-3\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <div className=\"mt-2 flex w-full items-center justify-between\">\n            <div className=\"space-y-1\">\n              {memoizedFields.map((field) => (\n                <p\n                  key={field.identifier || field.orderIndex}\n                  className=\"text-sm text-muted-foreground\"\n                >\n                  {field.orderIndex + 1}. {field.label || \"Untitled Field\"}\n                  {field.required && (\n                    <span className=\"italic\"> (required)</span>\n                  )}\n                </p>\n              ))}\n            </div>\n            <Button\n              onClick={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                setIsConfigOpen(true);\n              }}\n              variant=\"outline\"\n              className=\"h-8\"\n              size=\"sm\"\n            >\n              <SettingsIcon className=\"mr-2 h-4 w-4\" />\n              Configure\n            </Button>\n          </div>\n        </motion.div>\n      )}\n\n      <CustomFieldsPanel\n        fields={memoizedFields}\n        onChange={handleConfigSave}\n        isConfigOpen={isConfigOpen}\n        setIsConfigOpen={setIsConfigOpen}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/dataroom-link-sheet.tsx",
    "content": "\"use client\";\n\nimport {\n  DataroomLinkSheet as DataroomLinkSheetEE,\n  type ItemPermission as ItemPermissionEE,\n} from \"@/ee/features/permissions/components/dataroom-link-sheet\";\n\nexport type ItemPermission = ItemPermissionEE;\n\nexport function DataroomLinkSheet(props: any) {\n  return <DataroomLinkSheetEE {...props} />;\n}\n"
  },
  {
    "path": "components/links/link-sheet/deny-list-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { LinkPreset } from \"@prisma/client\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { sanitizeList } from \"@/lib/utils\";\n\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function DenyListSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n  presets,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n  presets: LinkPreset | null;\n}) {\n  const { emailProtected, denyList } = data;\n  // Initialize enabled state based on whether denyList is not null and not empty\n  const [enabled, setEnabled] = useState<boolean>(\n    !!denyList && denyList.length > 0,\n  );\n  const [denyListInput, setDenyListInput] = useState<string>(\n    denyList?.join(\"\\n\") || \"\",\n  );\n\n  useEffect(() => {\n    if (!emailProtected && enabled) {\n      setEnabled(false);\n      setData((prevData) => ({\n        ...prevData,\n        denyList: [],\n      }));\n    }\n  }, [emailProtected, enabled, setData]);\n\n  useEffect(() => {\n    if (isAllowed && presets?.denyList && presets.denyList.length > 0) {\n      setEnabled(true);\n      setDenyListInput(presets.denyList?.join(\"\\n\") || \"\");\n    }\n  }, [presets, isAllowed]);\n\n  const handleEnableDenyList = () => {\n    const updatedEnabled = !enabled;\n    setEnabled(updatedEnabled);\n\n    if (updatedEnabled) {\n      setData((prevData) => ({\n        ...prevData,\n        denyList: updatedEnabled ? sanitizeList(denyListInput) : [],\n        emailAuthenticated: true, // Turn on email authentication\n        emailProtected: true, // Turn on email protection\n      }));\n    } else {\n      setData((prevData) => ({\n        ...prevData,\n        denyList: [],\n      }));\n    }\n  };\n\n  const handleDenyListChange = (\n    event: React.ChangeEvent<HTMLTextAreaElement>,\n  ) => {\n    const updatedDenyListInput = event.target.value;\n    setDenyListInput(updatedDenyListInput);\n\n    if (emailProtected && enabled) {\n      setData((prevData) => ({\n        ...prevData,\n        denyList: sanitizeList(updatedDenyListInput),\n      }));\n    }\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <div className=\"flex flex-col space-y-4\">\n        <LinkItem\n          title=\"Block specified viewers\"\n          tooltipContent=\"Prevent certain users from accessing the content. Enter blocked emails or domains.\"\n          enabled={enabled}\n          link=\"https://www.papermark.com/help/article/block-list\"\n          action={handleEnableDenyList}\n          isAllowed={isAllowed}\n          requiredPlan=\"business\"\n          upgradeAction={() =>\n            handleUpgradeStateChange({\n              state: true,\n              trigger: \"link_sheet_denylist_section\",\n              plan: \"Business\",\n              highlightItem: [\"allow-block\"],\n            })\n          }\n        />\n\n        {enabled && (\n          <motion.div\n            className=\"mt-1 block w-full\"\n            {...FADE_IN_ANIMATION_SETTINGS}\n          >\n            <Textarea\n              className=\"focus:ring-inset\"\n              rows={5}\n              placeholder={`Enter blocked emails/domains, one per line, e.g.\nmarc@papermark.com\n@example.org`}\n              value={denyListInput}\n              onChange={handleDenyListChange}\n            />\n          </motion.div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/domain-section.tsx",
    "content": "import Link from \"next/link\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useEffect,\n  useState,\n} from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { Domain, LinkType } from \"@prisma/client\";\nimport { ShuffleIcon } from \"lucide-react\";\nimport { customAlphabet } from \"nanoid\";\nimport { mutate } from \"swr\";\n\nimport { BLOCKED_PATHNAMES } from \"@/lib/constants\";\nimport { BasePlan, usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { cn } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { AddDomainModal } from \"@/components/domains/add-domain-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { ButtonTooltip } from \"@/components/ui/tooltip\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\n\n// Unambiguous alphabet: excludes easily confused characters (0/O, 1/l/I)\nconst generateRandomSlug = customAlphabet(\n  \"23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz\",\n  10,\n);\n\nexport default function DomainSection({\n  data,\n  setData,\n  domains,\n  linkType,\n  editLink,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: Dispatch<SetStateAction<DEFAULT_LINK_TYPE>>;\n  domains?: Domain[];\n  linkType: Omit<LinkType, \"WORKFLOW_LINK\">;\n  editLink?: boolean;\n}) {\n  const [isModalOpen, setModalOpen] = useState(false);\n  const [isUpgradeModalOpen, setUpgradeModalOpen] = useState(false);\n  // Initialize displayValue from data.domain when editing, otherwise \"papermark.com\"\n  const [displayValue, setDisplayValue] = useState<string>(\n    editLink && data.domain ? data.domain : \"papermark.com\",\n  );\n  const teamInfo = useTeam();\n  const { limits } = useLimits();\n\n  const { isBusiness, isDatarooms, isDataroomsPlus } = usePlan();\n\n  // Check plan eligibility for custom domains\n  const canUseCustomDomainForDocument =\n    isBusiness || isDatarooms || isDataroomsPlus || limits?.customDomainOnPro;\n  const canUseCustomDomainForDataroom =\n    isDatarooms || isDataroomsPlus || limits?.customDomainInDataroom;\n\n  // Check if we're editing a link with a custom domain\n  const isEditingCustomDomain =\n    editLink && data.domain && data.domain !== \"papermark.com\" ? true : false;\n\n  const generateAndSetSlug = useCallback(() => {\n    const newSlug = generateRandomSlug();\n    setData((prev) => ({ ...prev, slug: newSlug }));\n  }, [setData]);\n\n  const handleDomainChange = (value: string) => {\n    const canChangeCustomDomain =\n      linkType === \"DOCUMENT_LINK\"\n        ? canUseCustomDomainForDocument\n        : canUseCustomDomainForDataroom;\n\n    if (isEditingCustomDomain && !canChangeCustomDomain) {\n      setDisplayValue(data.domain ?? \"papermark.com\");\n      return;\n    }\n\n    // Handle opening the add domain modal\n    if (value === \"add_domain\" || value === \"add_dataroom_domain\") {\n      setModalOpen(true);\n      setData((prev) => ({ ...prev, domain: \"papermark.com\" }));\n      setDisplayValue(\"papermark.com\");\n      return;\n    }\n\n    // Check if this is a custom domain selection (not papermark.com)\n    if (value !== \"papermark.com\") {\n      // Show upgrade modal if user doesn't have the right plan\n      if (\n        (linkType === \"DOCUMENT_LINK\" && !canUseCustomDomainForDocument) ||\n        (linkType === \"DATAROOM_LINK\" && !canUseCustomDomainForDataroom)\n      ) {\n        setUpgradeModalOpen(true);\n        setData((prev) => ({ ...prev, domain: \"papermark.com\" }));\n        setDisplayValue(\"papermark.com\");\n        return;\n      }\n\n      // Auto-generate a slug if there isn't one yet\n      setData((prev) => ({\n        ...prev,\n        domain: value,\n        ...(!prev.slug && { slug: generateRandomSlug() }),\n      }));\n      setDisplayValue(value);\n      return;\n    }\n\n    // Update domain normally if allowed\n    setData((prev) => ({ ...prev, domain: value }));\n    setDisplayValue(value);\n  };\n\n  const handleSelectFocus = () => {\n    // Assuming your fetcher key for domains is '/api/teams/:teamId/domains'\n    mutate(`/api/teams/${teamInfo?.currentTeam?.id}/domains`);\n  };\n\n  useEffect(() => {\n    if (domains && !editLink) {\n      const defaultDomain = domains.find((domain) => domain.isDefault);\n\n      // Only set a custom domain if the plan allows it\n      const canUseCustomDomain =\n        (linkType === \"DOCUMENT_LINK\" && canUseCustomDomainForDocument) ||\n        (linkType === \"DATAROOM_LINK\" && canUseCustomDomainForDataroom);\n\n      const domainValue = canUseCustomDomain\n        ? (defaultDomain?.slug ?? \"papermark.com\")\n        : \"papermark.com\";\n\n      // Auto-generate a slug when a custom domain is auto-selected as default\n      const isCustomDomain =\n        domainValue !== \"papermark.com\" && canUseCustomDomain;\n\n      setData((prev) => ({\n        ...prev,\n        domain: domainValue,\n        ...(isCustomDomain && !prev.slug && { slug: generateRandomSlug() }),\n      }));\n\n      setDisplayValue(domainValue);\n    }\n  }, [\n    domains,\n    editLink,\n    linkType,\n    isBusiness,\n    isDatarooms,\n    isDataroomsPlus,\n    limits,\n  ]);\n\n  // Set defaultDomain based on plan type and link type\n  const defaultDomain = editLink\n    ? (data.domain ?? \"papermark.com\")\n    : (linkType === \"DOCUMENT_LINK\" && canUseCustomDomainForDocument) ||\n        (linkType === \"DATAROOM_LINK\" && canUseCustomDomainForDataroom)\n      ? (domains?.find((domain) => domain.isDefault)?.slug ?? \"papermark.com\")\n      : \"papermark.com\";\n\n  // Set the initial display value when component mounts\n  useEffect(() => {\n    setDisplayValue(defaultDomain);\n  }, [defaultDomain, editLink]);\n\n  const currentDomain = domains?.find((domain) => domain.slug === data.domain);\n  const isDomainVerified = currentDomain?.verified;\n\n  const isSlugInvalid =\n    !!data.slug &&\n    (!/^[a-zA-Z0-9-]+$/.test(data.slug) ||\n      BLOCKED_PATHNAMES.includes(`/${data.slug}`));\n\n  const isDisabled =\n    linkType === \"DOCUMENT_LINK\"\n      ? isEditingCustomDomain && !canUseCustomDomainForDocument\n      : isEditingCustomDomain && !canUseCustomDomainForDataroom;\n\n  return (\n    <>\n      <Label htmlFor=\"link-domain\">Domain</Label>\n      <div className=\"flex\">\n        <Select\n          value={displayValue}\n          onValueChange={handleDomainChange}\n          onOpenChange={handleSelectFocus}\n          disabled={isDisabled}\n        >\n          <SelectTrigger\n            className={cn(\n              \"flex h-10 w-full rounded-none rounded-l-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\",\n              data.domain && data.domain !== \"papermark.com\"\n                ? \"\"\n                : \"border-r-1 rounded-r-md\",\n            )}\n          >\n            <SelectValue placeholder=\"Select a domain\" />\n          </SelectTrigger>\n          <SelectContent className=\"flex w-full rounded-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\">\n            <SelectItem value=\"papermark.com\" className=\"hover:bg-muted\">\n              papermark.com\n            </SelectItem>\n            {linkType === \"DOCUMENT_LINK\" && (\n              <>\n                {domains?.map(({ slug }) => (\n                  <SelectItem\n                    key={slug}\n                    value={slug}\n                    className={cn(\n                      \"hover:bg-muted hover:dark:bg-gray-700\",\n                      !canUseCustomDomainForDocument && \"opacity-50\",\n                    )}\n                  >\n                    {slug}\n                    {canUseCustomDomainForDocument || isEditingCustomDomain\n                      ? \"\"\n                      : \" (upgrade to use)\"}\n                  </SelectItem>\n                ))}\n              </>\n            )}\n            {linkType === \"DATAROOM_LINK\" && (\n              <>\n                {domains?.map(({ slug }) => (\n                  <SelectItem\n                    key={slug}\n                    value={slug}\n                    className={cn(\n                      \"hover:bg-muted hover:dark:bg-gray-700\",\n                      !canUseCustomDomainForDataroom && \"opacity-50\",\n                    )}\n                  >\n                    {slug}\n                    {canUseCustomDomainForDataroom || isEditingCustomDomain\n                      ? \"\"\n                      : \" (upgrade to use)\"}\n                  </SelectItem>\n                ))}\n              </>\n            )}\n            <SelectItem\n              className=\"hover:bg-muted hover:dark:bg-gray-700\"\n              value={\n                linkType === \"DOCUMENT_LINK\"\n                  ? \"add_domain\"\n                  : \"add_dataroom_domain\"\n              }\n            >\n              Add a custom domain ✨\n            </SelectItem>\n          </SelectContent>\n        </Select>\n\n        {data.domain && data.domain !== \"papermark.com\" ? (\n          <>\n            <Input\n              type=\"text\"\n              name=\"key\"\n              required\n              value={data.slug || \"\"}\n              disabled={isDisabled}\n              pattern=\"^[a-zA-Z0-9-]+$\"\n              onKeyDown={(e) => {\n                // Allow navigation keys, backspace, delete, etc.\n                if (e.key.length === 1 && !/^[a-zA-Z0-9-]$/.test(e.key)) {\n                  e.preventDefault();\n                }\n              }}\n              onInvalid={(e) => {\n                const currentValue = e.currentTarget.value;\n                const isBlocked = BLOCKED_PATHNAMES.includes(\n                  `/${currentValue}`,\n                );\n\n                if (isBlocked) {\n                  e.currentTarget.setCustomValidity(\n                    \"This pathname is blocked. Please choose another one.\",\n                  );\n                } else {\n                  e.currentTarget.setCustomValidity(\n                    \"Only letters, numbers, and '-' are allowed.\",\n                  );\n                }\n              }}\n              autoComplete=\"off\"\n              className={cn(\n                \"hidden rounded-none focus:ring-inset\",\n                data.domain && data.domain !== \"papermark.com\" ? \"flex\" : \"\",\n                isDisabled ? \"opacity-50\" : \"\",\n              )}\n              placeholder=\"deck\"\n              onChange={(e) => {\n                if (isDisabled) return;\n\n                const currentValue = e.target.value.replace(\n                  /[^a-zA-Z0-9-]/g,\n                  \"\",\n                );\n                const isBlocked = BLOCKED_PATHNAMES.includes(\n                  `/${currentValue}`,\n                );\n\n                if (isBlocked) {\n                  e.currentTarget.setCustomValidity(\n                    \"This pathname is blocked. Please choose another one.\",\n                  );\n                } else {\n                  e.currentTarget.setCustomValidity(\"\");\n                }\n                setData((prev) => ({ ...prev, slug: currentValue }));\n              }}\n              aria-invalid={isSlugInvalid}\n            />\n            <ButtonTooltip content=\"Generate random slug\">\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                size=\"icon\"\n                className=\"h-10 min-w-10 rounded-l-none border-l-0\"\n                disabled={isDisabled}\n                onClick={(e) => {\n                  e.preventDefault();\n                  generateAndSetSlug();\n                }}\n              >\n                <ShuffleIcon className=\"h-4 w-4\" />\n              </Button>\n            </ButtonTooltip>\n          </>\n        ) : null}\n      </div>\n\n      {isDisabled && (\n        <div\n          className=\"mt-2 text-sm text-muted-foreground\"\n          onClick={() => {\n            setUpgradeModalOpen(true);\n          }}\n        >\n          Custom domain and path cannot be changed on an unsupported plan.\n        </div>\n      )}\n\n      {data.domain && data.domain !== \"papermark.com\" && !isDomainVerified ? (\n        <div className=\"mt-4 text-sm text-red-500\">\n          Your domain is not verified yet!{\" \"}\n          <Link\n            className=\"underline hover:text-red-500/80\"\n            href=\"/settings/domains\"\n            target=\"_blank\"\n          >\n            Verify now\n          </Link>\n        </div>\n      ) : null}\n\n      {/* Add domain modal for custom domains */}\n      <AddDomainModal\n        open={isModalOpen}\n        setOpen={setModalOpen}\n        linkType={linkType}\n      />\n\n      {/* Upgrade plan modal when trying to use custom domains without the right plan */}\n      <UpgradePlanModal\n        clickedPlan={\n          linkType === \"DATAROOM_LINK\" ? PlanEnum.DataRooms : PlanEnum.Business\n        }\n        open={isUpgradeModalOpen}\n        setOpen={setUpgradeModalOpen}\n        trigger={\n          linkType === \"DATAROOM_LINK\"\n            ? \"select_custom_domain_dataroom\"\n            : \"select_custom_domain_document\"\n        }\n        highlightItem={[\"custom-domain\"]}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/email-authentication-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function EmailAuthenticationSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { emailProtected, emailAuthenticated, enableConversation } = data;\n  const [enabled, setEnabled] = useState<boolean>(emailAuthenticated);\n\n  useEffect(() => {\n    setEnabled(emailAuthenticated);\n  }, [emailAuthenticated]);\n\n  const handleEnableAuthentication = () => {\n    const updatedEmailAuthentication = !enabled;\n    setData({\n      ...data,\n      emailProtected: updatedEmailAuthentication ? true : emailProtected,\n      emailAuthenticated: updatedEmailAuthentication,\n      enableConversation: updatedEmailAuthentication\n        ? enableConversation\n        : false,\n    });\n    setEnabled(updatedEmailAuthentication);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Require email verification\"\n        link=\"https://www.papermark.com/help/article/require-email-verification\"\n        tooltipContent=\"Users must verify their email before accessing the content.\"\n        enabled={enabled}\n        action={handleEnableAuthentication}\n        isAllowed={isAllowed}\n        requiredPlan=\"business\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_email_auth_section\",\n            plan: \"Business\",\n            highlightItem: [\"email-verify\"],\n          })\n        }\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/email-protection-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nexport default function EmailProtectionSection({\n  data,\n  setData,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n}) {\n  const { emailProtected } = data;\n  const [enabled, setEnabled] = useState<boolean>(emailProtected);\n\n  useEffect(() => {\n    setEnabled(emailProtected);\n  }, [emailProtected]);\n\n  const handleEnableProtection = () => {\n    const updatedEmailProtection = !enabled;\n    setData({\n      ...data,\n      emailProtected: updatedEmailProtection,\n      emailAuthenticated: !updatedEmailProtection && false,\n      enableConversation: !updatedEmailProtection && false,\n      enableAgreement: !updatedEmailProtection && false,\n      allowList: updatedEmailProtection ? data.allowList : [],\n      denyList: updatedEmailProtection ? data.denyList : [],\n    });\n    setEnabled(updatedEmailProtection);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Require email to view\"\n        link=\"https://www.papermark.com/help/article/require-email-to-view-document\"\n        enabled={enabled}\n        action={handleEnableProtection}\n        tooltipContent=\"Users must provide an email to access this content\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/expiration-section.tsx",
    "content": "import { useCallback, useEffect, useState } from \"react\";\n\nimport { LinkPreset } from \"@prisma/client\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { formatExpirationTime } from \"@/lib/utils\";\n\nimport { SmartDateTimePicker } from \"@/components/ui/smart-date-time-picker\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nexport default function ExpirationSection({\n  data,\n  setData,\n  presets,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  presets: LinkPreset | null;\n}) {\n  const { expiresAt } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [error, setError] = useState<string | null>(null);\n  const [customDate, setCustomDate] = useState<Date | null>(null);\n\n  useEffect(() => {\n    setEnabled(!!expiresAt);\n    setCustomDate(expiresAt);\n  }, [expiresAt]);\n\n  useEffect(() => {\n    if (presets?.expiresIn) {\n      setEnabled(true);\n      handlePresetChange(presets.expiresIn);\n    }\n  }, [presets]);\n\n  const handleEnableExpiration = () => {\n    if (enabled) {\n      // if expiration is currently set and we're toggling it off\n      setData({ ...data, expiresAt: null });\n    }\n    setEnabled(!enabled);\n  };\n\n  const handlePresetChange = (value: number) => {\n    setError(null);\n\n    const seconds = value;\n    if (!isNaN(seconds)) {\n      const newExpiresAt = new Date();\n      newExpiresAt.setSeconds(newExpiresAt.getSeconds() + seconds);\n      setData({ ...data, expiresAt: newExpiresAt });\n      setCustomDate(newExpiresAt);\n    }\n  };\n\n  const handleCustomDateChange = (date: Date | null) => {\n    if (!date) {\n      setError(\"Please select a valid date\");\n      return;\n    }\n\n    const now = new Date();\n    if (date <= now) {\n      console.log(\"date <= now\", now, date);\n\n      // Always add 1 day (86400000 milliseconds) to current time\n      const newDate = new Date(now.getTime() + 86400 * 1000);\n\n      setData({ ...data, expiresAt: newDate });\n      setCustomDate(newDate);\n      setError(\"Expiration time must be in the future. Set to tomorrow.\");\n      return;\n    }\n\n    setData({ ...data, expiresAt: date });\n    setCustomDate(date);\n    setError(null);\n  };\n\n  const expirationTime = useCallback((date: Date | string) => {\n    const now = new Date();\n    const dateObj = typeof date === \"string\" ? new Date(date) : date;\n    const diffInSeconds = Math.floor(\n      (dateObj.getTime() - now.getTime()) / 1000,\n    );\n    return formatExpirationTime(diffInSeconds);\n  }, []);\n\n  const formatNaturalExpiration = useCallback((date: Date | string) => {\n    const now = new Date();\n    const dateObj = typeof date === \"string\" ? new Date(date) : date;\n\n    const formattedDate = dateObj.toLocaleDateString(undefined, {\n      weekday: \"long\",\n      month: \"long\",\n      day: \"numeric\",\n    });\n\n    const formattedTime = dateObj.toLocaleTimeString(undefined, {\n      hour: \"numeric\",\n      minute: \"2-digit\",\n      hour12: true,\n    });\n\n    if (dateObj.toDateString() === now.toDateString()) {\n      return `today at ${formattedTime}`;\n    }\n\n    const tomorrow = new Date(now);\n    tomorrow.setDate(tomorrow.getDate() + 1);\n    if (dateObj.toDateString() === tomorrow.toDateString()) {\n      return `tomorrow at ${formattedTime}`;\n    }\n\n    return `on ${formattedDate} at ${formattedTime}`;\n  }, []);\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Expiration Date\"\n        enabled={enabled}\n        link=\"https://www.papermark.com/help/article/expiration-date\"\n        action={handleEnableExpiration}\n        tooltipContent=\"Set a date after which the link will no longer be accessible.\"\n      />\n\n      {enabled && (\n        <motion.div className=\"mt-3 space-y-3\" {...FADE_IN_ANIMATION_SETTINGS}>\n          <div className=\"flex flex-col space-y-2\">\n            <SmartDateTimePicker\n              value={customDate}\n              onChange={handleCustomDateChange}\n              placeholder='e.g. \"in 2 days\", \"next Friday at 3pm\"'\n            />\n\n            {expiresAt && (\n              <div className=\"text-xs text-muted-foreground\">\n                <div>\n                  Link will expire {formatNaturalExpiration(expiresAt)} (in{\" \"}\n                  {expirationTime(expiresAt)})\n                </div>\n              </div>\n            )}\n            {error && <div className=\"text-xs text-red-500\">{error}</div>}\n          </div>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/expirationIn-section.tsx",
    "content": "import { useCallback, useEffect, useState } from \"react\";\n\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { formatExpirationTime } from \"@/lib/utils\";\n\nimport { SmartDateTimePicker } from \"@/components/ui/smart-date-time-picker\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nexport default function ExpirationInSection({\n  data,\n  setData,\n}: {\n  data: DEFAULT_LINK_TYPE & {\n    expiresIn?: number | null;\n  };\n  setData: React.Dispatch<\n    React.SetStateAction<\n      DEFAULT_LINK_TYPE & {\n        expiresIn?: number | null;\n      }\n    >\n  >;\n}) {\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [error, setError] = useState<string | null>(null);\n  const [customDate, setCustomDate] = useState<Date | null>(null);\n\n  const resetStates = useCallback(() => {\n    setError(null);\n    setCustomDate(null);\n  }, []);\n\n  // Initialize state based on existing expiresIn data\n  useEffect(() => {\n    if (!data.expiresIn) {\n      setEnabled(false);\n      resetStates();\n      return;\n    }\n\n    setEnabled(true);\n\n    if (data.expiresIn) {\n      const futureDate = new Date();\n      futureDate.setSeconds(futureDate.getSeconds() + data.expiresIn);\n      setCustomDate(futureDate);\n    }\n  }, [data.expiresIn]);\n\n  const handleEnableExpiration = useCallback(() => {\n    if (enabled) {\n      setData({\n        ...data,\n        expiresIn: null,\n      });\n      resetStates();\n    } else {\n      // Enable with default 7 days expiration\n      setData({\n        ...data,\n        expiresIn: 604800,\n      });\n      setError(null);\n      setCustomDate(null);\n    }\n    setEnabled(!enabled);\n  }, [enabled, data, resetStates]);\n\n  resetStates;\n\n  const handleCustomDateChange = useCallback(\n    (date: Date | null) => {\n      if (!date) {\n        return;\n      }\n\n      const now = new Date();\n      const diffMs = date.getTime() - now.getTime();\n      const diffSeconds = Math.ceil(diffMs / 1000);\n\n      if (diffSeconds <= 0) {\n        setError(\"Please enter a future duration\");\n        return;\n      }\n\n      setData({\n        ...data,\n        expiresIn: diffSeconds,\n      });\n      setCustomDate(date);\n      setError(null);\n    },\n    [data, setData],\n  );\n\n  // Custom formatter to show only the duration\n  const formatValue = useCallback((date: Date | null) => {\n    if (!date) return \"\";\n    const now = new Date();\n    const diffMs = date.getTime() - now.getTime();\n    const diffSeconds = Math.ceil(diffMs / 1000);\n    return \"in \" + formatExpirationTime(diffSeconds);\n  }, []);\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Link Expires After\"\n        enabled={enabled}\n        link=\"https://www.papermark.com/help/article/expiration-date\"\n        action={handleEnableExpiration}\n        tooltipContent=\"Set how long the link will remain active after creation.\"\n      />\n\n      {enabled && (\n        <motion.div className=\"mt-3 space-y-3\" {...FADE_IN_ANIMATION_SETTINGS}>\n          <div className=\"flex flex-col space-y-2\">\n            <SmartDateTimePicker\n              value={customDate}\n              onChange={handleCustomDateChange}\n              placeholder='E.g. \"in 2 hours\" or \"in 2 days\"'\n              formatValue={formatValue}\n              showCalendarIcon={false}\n            />\n\n            {data.expiresIn && (\n              <div className=\"text-xs text-muted-foreground\">\n                <div>\n                  Links will expire in {formatExpirationTime(data.expiresIn)}{\" \"}\n                  after creation\n                </div>\n              </div>\n            )}\n\n            {error && <div className=\"text-xs text-red-500\">{error}</div>}\n          </div>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/feedback-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nexport default function FeedbackSection({\n  data,\n  setData,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n}) {\n  const { enableFeedback } = data;\n  const [enabled, setEnabled] = useState<boolean>(true);\n\n  useEffect(() => {\n    setEnabled(enableFeedback);\n  }, [enableFeedback]);\n\n  const handleEnableFeedback = () => {\n    const updatedEnableFeedback = !enabled;\n    setData({ ...data, enableFeedback: updatedEnableFeedback });\n    setEnabled(updatedEnableFeedback);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Enable feedback from visitors\"\n        enabled={enabled}\n        tooltipContent=\"Allow viewers to provide feedback on your content.\"\n        action={handleEnableFeedback}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/index-file-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function IndexFileSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { enableIndexFile } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n\n  useEffect(() => {\n    setEnabled(enableIndexFile);\n  }, [enableIndexFile]);\n\n  const handleEnableIndexFile = () => {\n    const updatedEnableIndexFile = !enabled;\n    setData({ ...data, enableIndexFile: updatedEnableIndexFile });\n    setEnabled(updatedEnableIndexFile);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Enable index file generation\"\n        enabled={enabled}\n        link=\"https://www.papermark.com/help/article/link-settings\"\n        action={handleEnableIndexFile}\n        isAllowed={isAllowed}\n        requiredPlan=\"data rooms plus\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_index_file_section\",\n            plan: \"Data Rooms Plus\",\n          })\n        }\n        tooltipContent=\"Allow visitors to generate an index file of all documents in the dataroom.\"\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/index.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { Dispatch, SetStateAction, useEffect, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LinkAudienceType, LinkPreset, LinkType } from \"@prisma/client\";\nimport { RefreshCwIcon } from \"lucide-react\";\nimport { useHotkeys } from \"react-hotkeys-hook\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport useSWR from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useDataroomGroups from \"@/lib/swr/use-dataroom-groups\";\nimport { useDomains } from \"@/lib/swr/use-domains\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { LinkWithViews, WatermarkConfig } from \"@/lib/types\";\nimport { convertDataUrlToFile, fetcher, uploadImage } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { ButtonTooltip } from \"@/components/ui/tooltip\";\n\nimport { CustomFieldData } from \"./custom-fields-panel\";\nimport { type ItemPermission } from \"./dataroom-link-sheet\";\nimport DomainSection from \"./domain-section\";\nimport { LinkOptions } from \"./link-options\";\nimport TagSection from \"./tags/tag-section\";\n\nexport const DEFAULT_LINK_PROPS = (\n  linkType: Omit<LinkType, \"WORKFLOW_LINK\">,\n  groupId: string | null = null,\n  showBanner: boolean = true,\n) => ({\n  id: null,\n  name: null,\n  domain: null,\n  slug: null,\n  expiresAt: null,\n  password: null,\n  emailProtected: true,\n  emailAuthenticated: false,\n  allowDownload: false,\n  allowList: [],\n  denyList: [],\n  visitorGroupIds: [],\n  enableNotification: true,\n  enableFeedback: false,\n  enableScreenshotProtection: false,\n    enableCustomMetatag: false,\n  metaTitle: null,\n  metaDescription: null,\n  metaImage: null,\n  metaFavicon: null,\n  welcomeMessage: null,\n  enableQuestion: false,\n  questionText: null,\n  questionType: null,\n  enableAgreement: false,\n  agreementId: null,\n  showBanner: linkType === LinkType.DOCUMENT_LINK ? showBanner : false,\n  enableWatermark: false,\n  watermarkConfig: null,\n  audienceType: groupId ? LinkAudienceType.GROUP : LinkAudienceType.GENERAL,\n  groupId: groupId,\n  customFields: [],\n  tags: [],\n  enableConversation: false,\n  enableAIAgents: false,\n  enableUpload: false,\n  isFileRequestOnly: false,\n  uploadFolderId: null,\n  uploadFolderName: \"Home\",\n  enableIndexFile: false,\n  permissions: {},\n  permissionGroupId: null,\n});\n\nexport type DEFAULT_LINK_TYPE = {\n  id: string | null;\n  name: string | null;\n  domain: string | null;\n  slug: string | null;\n  expiresAt: Date | null;\n  password: string | null;\n  emailProtected: boolean;\n  emailAuthenticated: boolean;\n  allowDownload: boolean;\n  allowList: string[];\n  denyList: string[];\n  visitorGroupIds: string[];\n  enableNotification: boolean;\n  enableFeedback: boolean;\n  enableScreenshotProtection: boolean;\n  enableCustomMetatag: boolean; // metatags\n  metaTitle: string | null; // metatags\n  metaDescription: string | null; // metatags\n  metaImage: string | null; // metatags\n  metaFavicon: string | null; // metaFavicon\n  welcomeMessage: string | null; // custom welcome message\n  enableQuestion?: boolean; // feedback question\n  questionText: string | null;\n  questionType: string | null;\n  enableAgreement: boolean; // agreement\n  agreementId: string | null;\n  showBanner: boolean;\n  enableWatermark: boolean;\n  watermarkConfig: WatermarkConfig | null;\n  audienceType: LinkAudienceType;\n  groupId: string | null;\n  customFields: CustomFieldData[];\n  tags: string[];\n  enableConversation: boolean;\n  enableAIAgents: boolean;\n  enableUpload: boolean;\n  isFileRequestOnly: boolean;\n  uploadFolderId: string | null;\n  uploadFolderName: string;\n  enableIndexFile: boolean;\n  permissions?: ItemPermission | null; // For dataroom links file permissions\n  permissionGroupId?: string | null;\n};\n\nexport default function LinkSheet({\n  isOpen,\n  setIsOpen,\n  linkType,\n  currentLink,\n  existingLinks,\n}: {\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  linkType: Omit<LinkType, \"WORKFLOW_LINK\">;\n  currentLink?: DEFAULT_LINK_TYPE;\n  existingLinks?: LinkWithViews[];\n}) {\n  const router = useRouter();\n  const { id: targetId, groupId } = router.query as {\n    id: string;\n    groupId?: string;\n  };\n\n  const { domains } = useDomains({ enabled: isOpen });\n\n  const {\n    viewerGroups,\n    loading: isLoadingGroups,\n    mutate: mutateGroups,\n  } = useDataroomGroups();\n  const teamInfo = useTeam();\n  const { isFree, isPro, isBusiness, isDatarooms, isDataroomsPlus, isTrial } =\n    usePlan();\n  const { limits } = useLimits();\n  const analytics = useAnalytics();\n  const [data, setData] = useState<DEFAULT_LINK_TYPE>(\n    DEFAULT_LINK_PROPS(linkType, groupId, !isDatarooms),\n  );\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [isSaving, setIsSaving] = useState<boolean>(false);\n  const [currentPreset, setCurrentPreset] = useState<LinkPreset | null>(null);\n  const formRef = useRef<HTMLFormElement>(null);\n\n  const isPresetsAllowed =\n    isTrial ||\n    (isPro && limits?.advancedLinkControlsOnPro) ||\n    isBusiness ||\n    isDatarooms ||\n    isDataroomsPlus;\n\n  // Presets\n  const { data: presets } = useSWR<LinkPreset[]>(\n    teamInfo?.currentTeam?.id\n      ? `/api/teams/${teamInfo.currentTeam.id}/presets`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  useEffect(() => {\n    setData(currentLink || DEFAULT_LINK_PROPS(linkType, groupId, !isDatarooms));\n  }, [currentLink]);\n\n  // Handle Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) to submit the form\n  useHotkeys(\n    \"mod+enter\",\n    (e) => {\n      e.preventDefault();\n      if (!isSaving && formRef.current) {\n        formRef.current.requestSubmit();\n      }\n    },\n    { enabled: isOpen, enableOnFormTags: true },\n    [isSaving],\n  );\n\n  const handlePreviewLink = async (link: LinkWithViews) => {\n    if (link.domainId && isFree) {\n      toast.error(\"You need to upgrade to preview this link\");\n      return;\n    }\n\n    setIsLoading(true);\n    const response = await fetch(`/api/links/${link.id}/preview`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    });\n\n    if (!response.ok) {\n      toast.error(\"Failed to generate preview link\");\n      setIsLoading(false);\n      return;\n    }\n\n    const { previewToken } = await response.json();\n    const previewLink = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}?previewToken=${previewToken}`;\n    setIsLoading(false);\n    const linkElement = document.createElement(\"a\");\n    linkElement.href = previewLink;\n    linkElement.target = \"_blank\";\n    document.body.appendChild(linkElement);\n    linkElement.click();\n\n    setTimeout(() => {\n      document.body.removeChild(linkElement);\n    }, 100);\n  };\n\n  const applyPreset = (presetId: string) => {\n    const preset = presets?.find((p) => p.id === presetId);\n    if (!preset) return;\n\n    setData((prev) => {\n      const isGroupLink = prev.audienceType === LinkAudienceType.GROUP;\n\n      return {\n        ...prev,\n        name: prev.name, // Keep existing name\n        domain: prev.domain, // Keep existing domain\n        slug: prev.slug, // Keep existing slug\n        emailProtected: preset.emailProtected ?? prev.emailProtected,\n        emailAuthenticated:\n          preset.emailAuthenticated ?? prev.emailAuthenticated,\n        // For group links, ignore allow/deny lists from presets as access is controlled by group membership\n        allowList: isGroupLink\n          ? prev.allowList\n          : preset.allowList || prev.allowList,\n        denyList: isGroupLink\n          ? prev.denyList\n          : preset.denyList || prev.denyList,\n        password: preset.password || prev.password,\n        enableCustomMetatag:\n          preset.enableCustomMetaTag ?? prev.enableCustomMetatag,\n        metaTitle: preset.metaTitle || prev.metaTitle,\n        metaDescription: preset.metaDescription || prev.metaDescription,\n        metaImage: preset.metaImage || prev.metaImage,\n        metaFavicon: preset.metaFavicon || prev.metaFavicon,\n        welcomeMessage: preset.welcomeMessage || prev.welcomeMessage,\n        allowDownload: preset.allowDownload || prev.allowDownload,\n        enableAgreement: preset.enableAgreement || prev.enableAgreement,\n        agreementId: preset.agreementId || prev.agreementId,\n        enableScreenshotProtection:\n          preset.enableScreenshotProtection || prev.enableScreenshotProtection,\n        enableNotification: !!preset.enableNotification,\n        showBanner: preset.showBanner ?? prev.showBanner,\n      };\n    });\n\n    setCurrentPreset(preset);\n  };\n\n  const handleSubmit = async (event: any, shouldPreview: boolean = false) => {\n    event.preventDefault();\n\n    setIsSaving(true);\n\n    // Upload the image if it's a data URL\n    let blobUrl: string | null =\n      data.metaImage && data.metaImage.startsWith(\"data:\")\n        ? null\n        : data.metaImage;\n    if (data.metaImage && data.metaImage.startsWith(\"data:\")) {\n      // Convert the data URL to a blob\n      const blob = convertDataUrlToFile({ dataUrl: data.metaImage });\n      // Upload the blob to vercel storage\n      blobUrl = await uploadImage(blob);\n      setData({ ...data, metaImage: blobUrl });\n    }\n\n    // Upload meta favicon if it's a data URL\n    let blobUrlFavicon: string | null =\n      data.metaFavicon && data.metaFavicon.startsWith(\"data:\")\n        ? null\n        : data.metaFavicon;\n    if (data.metaFavicon && data.metaFavicon.startsWith(\"data:\")) {\n      const blobFavicon = convertDataUrlToFile({ dataUrl: data.metaFavicon });\n      blobUrlFavicon = await uploadImage(blobFavicon);\n      setData({\n        ...data,\n        metaFavicon: blobUrlFavicon,\n      });\n    }\n\n    let endpoint = \"/api/links\";\n    let method = \"POST\";\n\n    if (currentLink) {\n      // Assuming that your endpoint to update links appends the link's ID to the URL\n      endpoint = `/api/links/${currentLink.id}`;\n      method = \"PUT\";\n    }\n\n    const response = await fetch(endpoint, {\n      method: method,\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...data,\n        metaImage: blobUrl,\n        metaFavicon: blobUrlFavicon,\n        targetId: targetId,\n        linkType: linkType,\n        teamId: teamInfo?.currentTeam?.id,\n      }),\n    });\n\n    if (!response.ok) {\n      // handle error with toast message\n      const { error } = await response.json();\n      toast.error(error);\n      setIsSaving(false);\n      return;\n    }\n\n    const returnedLink = await response.json();\n    const endpointTargetType = `${linkType.replace(\"_LINK\", \"\").toLowerCase()}s`; // \"documents\" or \"datarooms\"\n\n    if (currentLink) {\n      setIsOpen(false);\n      // Update the link in the list of links\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n          targetId,\n        )}/links`,\n        (existingLinks || []).map((link) =>\n          link.id === currentLink.id ? returnedLink : link,\n        ),\n        false,\n      );\n\n      // Handle group changes\n      if (!!groupId && returnedLink.audienceType === LinkAudienceType.GROUP) {\n        // If we're viewing a group page\n        if (currentLink.groupId !== returnedLink.groupId) {\n          // If the link's group has changed\n          if (currentLink.groupId === groupId) {\n            // If the link was in the current group but is now in a different group\n            // Remove it from the current group's view\n            const groupLinks =\n              existingLinks?.filter(\n                (link) =>\n                  link.id !== currentLink.id && link.groupId === groupId,\n              ) || [];\n\n            mutate(\n              `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n                targetId,\n              )}/groups/${groupId}/links`,\n              groupLinks,\n              false,\n            );\n          } else if (returnedLink.groupId === groupId) {\n            // If the link was in a different group but is now in the current group\n            // Add it to the current group's view\n            const groupLinks =\n              existingLinks?.filter((link) => link.groupId === groupId) || [];\n\n            mutate(\n              `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n                targetId,\n              )}/groups/${groupId}/links`,\n              [returnedLink, ...groupLinks],\n              false,\n            );\n          }\n        } else if (returnedLink.groupId === groupId) {\n          // If the link's group hasn't changed and it's in the current group\n          // Update it in the current group's view\n          const groupLinks =\n            existingLinks?.filter((link) => link.groupId === groupId) || [];\n\n          mutate(\n            `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n              targetId,\n            )}/groups/${groupId}/links`,\n            groupLinks.map((link) =>\n              link.id === currentLink.id ? returnedLink : link,\n            ),\n            false,\n          );\n        }\n      }\n\n      // Track what changed for analytics\n      const changedFields: Record<string, { from: unknown; to: unknown }> = {};\n      const trackableFields: (keyof DEFAULT_LINK_TYPE)[] = [\n        \"name\",\n        \"domain\",\n        \"slug\",\n        \"expiresAt\",\n        \"emailProtected\",\n        \"emailAuthenticated\",\n        \"allowDownload\",\n        \"allowList\",\n        \"denyList\",\n        \"enableNotification\",\n        \"enableFeedback\",\n        \"enableScreenshotProtection\",\n        \"enableCustomMetatag\",\n        \"metaTitle\",\n        \"metaDescription\",\n        \"welcomeMessage\",\n        \"enableQuestion\",\n        \"questionText\",\n        \"questionType\",\n        \"enableAgreement\",\n        \"agreementId\",\n        \"showBanner\",\n        \"enableWatermark\",\n        \"audienceType\",\n        \"groupId\",\n        \"enableConversation\",\n        \"enableAIAgents\",\n        \"enableUpload\",\n        \"isFileRequestOnly\",\n        \"uploadFolderId\",\n        \"enableIndexFile\",\n        \"permissionGroupId\",\n        \"tags\",\n      ];\n\n      for (const field of trackableFields) {\n        if (\n          JSON.stringify(currentLink[field]) !== JSON.stringify(data[field])\n        ) {\n          changedFields[field] = {\n            from: currentLink[field],\n            to: data[field],\n          };\n        }\n      }\n\n      // Password: log set/unset/changed status only, not actual values\n      if (!!currentLink.password !== !!data.password) {\n        changedFields.password = {\n          from: currentLink.password ? \"set\" : \"unset\",\n          to: data.password ? \"set\" : \"unset\",\n        };\n      } else if (\n        currentLink.password &&\n        data.password &&\n        currentLink.password !== data.password\n      ) {\n        changedFields.password = { from: \"set\", to: \"changed\" };\n      }\n\n      // Image fields: log set/unset status only, not URLs\n      if (currentLink.metaImage !== data.metaImage) {\n        changedFields.metaImage = {\n          from: currentLink.metaImage ? \"set\" : \"unset\",\n          to: data.metaImage ? \"set\" : \"unset\",\n        };\n      }\n      if (currentLink.metaFavicon !== data.metaFavicon) {\n        changedFields.metaFavicon = {\n          from: currentLink.metaFavicon ? \"set\" : \"unset\",\n          to: data.metaFavicon ? \"set\" : \"unset\",\n        };\n      }\n\n      // Watermark config: log configured/unset status\n      if (\n        JSON.stringify(currentLink.watermarkConfig) !==\n        JSON.stringify(data.watermarkConfig)\n      ) {\n        changedFields.watermarkConfig = {\n          from: currentLink.watermarkConfig ? \"configured\" : \"unset\",\n          to: data.watermarkConfig ? \"configured\" : \"unset\",\n        };\n      }\n\n      // Custom fields: log count change\n      if (\n        JSON.stringify(currentLink.customFields) !==\n        JSON.stringify(data.customFields)\n      ) {\n        changedFields.customFields = {\n          from: currentLink.customFields?.length ?? 0,\n          to: data.customFields?.length ?? 0,\n        };\n      }\n\n      analytics.capture(\"Link Updated\", {\n        linkId: currentLink.id,\n        targetId,\n        linkType,\n        teamId: teamInfo?.currentTeam?.id,\n        customDomain: returnedLink.domainSlug ?? null,\n        changes: changedFields,\n        changedProperties: Object.keys(changedFields),\n      });\n\n      toast.success(\"Link updated successfully\");\n    } else {\n      setIsOpen(false);\n\n      // Add the new link to the list of links\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n          targetId,\n        )}/links`,\n        [returnedLink, ...(existingLinks || [])],\n        false,\n      );\n\n      // Also update the group-specific links cache if this is a group link\n      if (\n        !!groupId &&\n        returnedLink.audienceType === LinkAudienceType.GROUP &&\n        returnedLink.groupId === groupId\n      ) {\n        const groupLinks =\n          existingLinks?.filter((link) => link.groupId === groupId) || [];\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(\n            targetId,\n          )}/groups/${groupId}/links`,\n          [returnedLink, ...groupLinks],\n          false,\n        );\n      }\n\n      analytics.capture(\"Link Added\", {\n        linkId: returnedLink.id,\n        targetId,\n        linkType,\n        customDomain: returnedLink.domainSlug,\n      });\n\n      toast.success(\"Link created successfully\");\n    }\n\n    setData(DEFAULT_LINK_PROPS(linkType, groupId));\n    setIsSaving(false);\n\n    if (shouldPreview) {\n      await handlePreviewLink(returnedLink);\n    }\n  };\n\n  return (\n    <Sheet open={isOpen} onOpenChange={(open: boolean) => setIsOpen(open)}>\n      <SheetContent className=\"flex w-[90%] flex-col justify-between border-l border-gray-200 bg-background px-4 text-foreground dark:border-gray-800 dark:bg-gray-900 sm:w-[800px] sm:max-w-4xl md:px-5\">\n        <SheetHeader className=\"text-start\">\n          <SheetTitle>\n            {currentLink\n              ? `Edit ${currentLink.audienceType === LinkAudienceType.GROUP ? \"group\" : \"\"} link`\n              : \"Create a new link\"}\n          </SheetTitle>\n        </SheetHeader>\n\n        <form\n          ref={formRef}\n          className=\"flex grow flex-col\"\n          onSubmit={(e) => handleSubmit(e, false)}\n        >\n          <ScrollArea className=\"flex-grow\">\n            <div className=\"h-0 flex-1\">\n              <div className=\"flex flex-1 flex-col justify-between pb-6\">\n                <div className=\"divide-y divide-gray-200\">\n                  <Tabs\n                    value={data.audienceType}\n                    onValueChange={(value) =>\n                      setData({\n                        ...data,\n                        audienceType: value as LinkAudienceType,\n                      })\n                    }\n                  >\n                    {linkType === LinkType.DATAROOM_LINK && !!!currentLink ? (\n                      <TabsList className=\"grid w-full grid-cols-2\">\n                        <TabsTrigger value={LinkAudienceType.GENERAL}>\n                          General\n                        </TabsTrigger>\n                        {isDatarooms || isDataroomsPlus || isTrial ? (\n                          <TabsTrigger value={LinkAudienceType.GROUP}>\n                            Group\n                          </TabsTrigger>\n                        ) : (\n                          <UpgradePlanModal\n                            clickedPlan={PlanEnum.DataRooms}\n                            trigger=\"add_group_link\"\n                          >\n                            <div className=\"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all\">\n                              Group\n                            </div>\n                          </UpgradePlanModal>\n                        )}\n                      </TabsList>\n                    ) : null}\n\n                    <TabsContent value={LinkAudienceType.GENERAL}>\n                      {/* GENERAL LINK */}\n                      <div className=\"space-y-6 pt-2\">\n                        <div className=\"space-y-2\">\n                          <Label htmlFor=\"link-name\">Link Name</Label>\n                          <Input\n                            type=\"text\"\n                            name=\"link-name\"\n                            id=\"link-name\"\n                            placeholder=\"Recipient's Organization\"\n                            value={data.name || \"\"}\n                            className=\"focus:ring-inset\"\n                            onChange={(e) =>\n                              setData({ ...data, name: e.target.value })\n                            }\n                          />\n                        </div>\n\n                        <div className=\"space-y-2\">\n                          <DomainSection\n                            {...{ data, setData, domains }}\n                            linkType={linkType}\n                            editLink={!!currentLink}\n                          />\n                        </div>\n\n                        {/* Preset Selector - only show when creating a new link */}\n                        {!currentLink &&\n                          isPresetsAllowed &&\n                          presets &&\n                          presets.length > 0 && (\n                            <div className=\"space-y-2\">\n                              <div className=\"flex items-center justify-between\">\n                                <Label htmlFor=\"preset\">Link Preset</Label>\n                                <Link\n                                  href=\"/settings/presets\"\n                                  className=\"text-xs text-muted-foreground hover:text-foreground hover:underline\"\n                                >\n                                  Manage\n                                </Link>\n                              </div>\n                              <Select onValueChange={applyPreset}>\n                                <SelectTrigger className=\"w-full\">\n                                  <SelectValue placeholder=\"Select a preset\" />\n                                </SelectTrigger>\n                                <SelectContent>\n                                  {presets.map((preset) => (\n                                    <SelectItem\n                                      key={preset.id}\n                                      value={preset.id}\n                                    >\n                                      {preset.name}\n                                    </SelectItem>\n                                  ))}\n                                </SelectContent>\n                              </Select>\n                              <p className=\"text-xs text-muted-foreground\">\n                                Apply a preset to quickly configure link\n                                settings\n                              </p>\n                            </div>\n                          )}\n\n                        <div className=\"relative flex items-center\">\n                          <Separator className=\"absolute bg-muted-foreground\" />\n                          <div className=\"relative mx-auto\">\n                            <span className=\"bg-background px-2 text-sm text-muted-foreground dark:bg-gray-900\">\n                              Link Options\n                            </span>\n                          </div>\n                        </div>\n\n                        <LinkOptions\n                          data={data}\n                          setData={setData}\n                          targetId={targetId}\n                          linkType={linkType}\n                          editLink={!!currentLink}\n                          currentPreset={currentPreset}\n                        />\n                      </div>\n                    </TabsContent>\n\n                    <TabsContent value={LinkAudienceType.GROUP}>\n                      {/* GROUP LINK */}\n                      <div className=\"space-y-6 pt-2\">\n                        <div className=\"space-y-2\">\n                          <div className=\"flex w-full items-center justify-between\">\n                            <Label htmlFor=\"group-id\">Group </Label>\n                            <ButtonTooltip content=\"Refresh groups\">\n                              <Button\n                                size=\"icon\"\n                                variant=\"ghost\"\n                                className=\"h-6\"\n                                onClick={async (e) => {\n                                  e.stopPropagation();\n                                  e.preventDefault();\n                                  await mutateGroups();\n                                }}\n                              >\n                                <RefreshCwIcon className=\"h-4 w-4\" />\n                              </Button>\n                            </ButtonTooltip>\n                          </div>\n                          <Select\n                            onValueChange={(value) => {\n                              if (value === \"add_group\") {\n                                // Open the group sheet\n                                console.log(\"add_group redirect\");\n                                return;\n                              }\n\n                              setData({ ...data, groupId: value });\n                            }}\n                            defaultValue={data.groupId ?? undefined}\n                          >\n                            <SelectTrigger className=\"focus:ring-offset-3 flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-gray-400 sm:text-sm sm:leading-6\">\n                              <SelectValue placeholder=\"Select an group\" />\n                            </SelectTrigger>\n                            <SelectContent>\n                              {isLoadingGroups ? (\n                                <SelectItem value=\"loading\" disabled>\n                                  Loading groups...\n                                </SelectItem>\n                              ) : viewerGroups && viewerGroups.length > 0 ? (\n                                viewerGroups.map(({ id, name, _count }) => (\n                                  <SelectItem key={id} value={id}>\n                                    {name}{\" \"}\n                                    <span className=\"text-muted-foreground\">\n                                      ({_count.members} members)\n                                    </span>\n                                  </SelectItem>\n                                ))\n                              ) : (\n                                <SelectItem value=\"no-groups\" disabled>\n                                  No groups available\n                                </SelectItem>\n                              )}\n                            </SelectContent>\n                          </Select>\n                        </div>\n\n                        <div className=\"space-y-2\">\n                          <Label htmlFor=\"link-name\">Link Name</Label>\n\n                          <Input\n                            type=\"text\"\n                            name=\"link-name\"\n                            id=\"link-name\"\n                            placeholder={\n                              viewerGroups?.find(\n                                (group) => group.id === data.groupId,\n                              )?.name\n                                ? `${\n                                    viewerGroups?.find(\n                                      (group) => group.id === data.groupId,\n                                    )?.name\n                                  } Link`\n                                : \"Group Link\"\n                            }\n                            value={data.name || \"\"}\n                            className=\"focus:ring-inset\"\n                            onChange={(e) =>\n                              setData({ ...data, name: e.target.value })\n                            }\n                          />\n                        </div>\n\n                        <div className=\"space-y-2\">\n                          <DomainSection\n                            {...{ data, setData, domains }}\n                            linkType={linkType}\n                            editLink={!!currentLink}\n                          />\n                        </div>\n\n                        {/* Preset Selector for Group links - only show when creating a new link */}\n                        {!currentLink &&\n                          isPresetsAllowed &&\n                          presets &&\n                          presets.length > 0 && (\n                            <div className=\"space-y-2\">\n                              <div className=\"flex items-center justify-between\">\n                                <Label htmlFor=\"preset\">Link Preset</Label>\n                                <Link\n                                  href=\"/settings/presets\"\n                                  className=\"text-xs text-muted-foreground hover:text-foreground hover:underline\"\n                                >\n                                  Manage\n                                </Link>\n                              </div>\n                              <Select onValueChange={applyPreset}>\n                                <SelectTrigger className=\"w-full\">\n                                  <SelectValue placeholder=\"Select a preset\" />\n                                </SelectTrigger>\n                                <SelectContent>\n                                  {presets.map((preset) => (\n                                    <SelectItem\n                                      key={preset.id}\n                                      value={preset.id}\n                                    >\n                                      {preset.name}\n                                    </SelectItem>\n                                  ))}\n                                </SelectContent>\n                              </Select>\n                              <p className=\"text-xs text-muted-foreground\">\n                                Apply a preset to quickly configure link\n                                settings\n                              </p>\n                            </div>\n                          )}\n\n                        <div className=\"relative flex items-center\">\n                          <Separator className=\"absolute bg-muted-foreground\" />\n                          <div className=\"relative mx-auto\">\n                            <span className=\"bg-background px-2 text-sm text-muted-foreground dark:bg-gray-900\">\n                              Link Options\n                            </span>\n                          </div>\n                        </div>\n\n                        <LinkOptions\n                          data={data}\n                          setData={setData}\n                          targetId={targetId}\n                          linkType={linkType}\n                          editLink={!!currentLink}\n                          currentPreset={currentPreset}\n                        />\n                      </div>\n                    </TabsContent>\n                  </Tabs>\n                </div>\n\n                <Separator className=\"mb-6 mt-2\" />\n\n                <div className=\"space-y-2\">\n                  <TagSection\n                    {...{ data, setData }}\n                    teamId={teamInfo?.currentTeam?.id as string}\n                  />\n                </div>\n              </div>\n            </div>\n          </ScrollArea>\n\n          <SheetFooter>\n            <div className=\"flex flex-row-reverse items-center gap-2 pt-2\">\n              <Button\n                type=\"submit\"\n                loading={isSaving}\n                onClick={(e) => handleSubmit(e, false)}\n              >\n                {currentLink ? \"Update Link\" : \"Save Link\"}\n              </Button>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                loading={isLoading}\n                onClick={(e) => handleSubmit(e, true)}\n              >\n                {currentLink ? \"Update & Preview\" : \"Save & Preview\"}\n              </Button>\n            </div>\n          </SheetFooter>\n        </form>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/link-item.tsx",
    "content": "import { CircleHelpIcon, RotateCcwIcon } from \"lucide-react\";\n\n\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { cn } from \"@/lib/utils\";\n\n\n\nimport PlanBadge from \"@/components/billing/plan-badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { BadgeTooltip, ButtonTooltip } from \"@/components/ui/tooltip\";\n\nexport default function LinkItem({\n  title,\n  enabled,\n  action,\n  isAllowed = true,\n  requiredPlan,\n  upgradeAction,\n  resetAction,\n  link,\n  tooltipContent,\n}: {\n  title: string;\n  enabled: boolean;\n  action: () => void;\n  isAllowed?: boolean;\n  requiredPlan?: string;\n  upgradeAction?: () => void;\n  link?: string;\n  resetAction?: () => void;\n  tooltipContent?: string;\n}) {\n  const { isTrial } = usePlan();\n  const showBadge =\n    isTrial && requiredPlan?.toLowerCase() === \"data rooms plus\";\n\n  return (\n    <div className=\"flex items-center justify-between gap-x-2\">\n      <div className=\"flex w-full items-center justify-between space-x-2\">\n        <h2\n          className={cn(\n            \"flex flex-1 cursor-pointer flex-row items-center gap-2 text-sm font-medium leading-6\",\n            enabled ? \"text-foreground\" : \"text-muted-foreground\",\n          )}\n          onClick={isAllowed ? action : () => upgradeAction?.()}\n        >\n          <span>{title}</span>\n          {!!tooltipContent && (\n            <BadgeTooltip\n              content={tooltipContent}\n              key=\"link_tooltip\"\n              link={link}\n            >\n              <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n            </BadgeTooltip>\n          )}\n          {(!isAllowed && requiredPlan) || showBadge ? (\n            <PlanBadge plan={requiredPlan} />\n          ) : null}\n        </h2>\n        {enabled && resetAction && (\n          <ButtonTooltip content=\"Reset to defaults\">\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"h-6\"\n              onClick={(e) => {\n                e.stopPropagation();\n                e.preventDefault();\n                resetAction();\n              }}\n            >\n              <RotateCcwIcon className=\"h-4 w-4\" />\n            </Button>\n          </ButtonTooltip>\n        )}\n      </div>\n      <Switch\n        checked={enabled}\n        onClick={isAllowed ? undefined : () => upgradeAction?.()}\n        className={isAllowed ? undefined : \"opacity-50\"}\n        onCheckedChange={isAllowed ? action : undefined}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/link-options.tsx",
    "content": "import { useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LinkAudienceType, LinkType } from \"@prisma/client\";\nimport { LinkPreset } from \"@prisma/client\";\nimport { ChevronDown } from \"lucide-react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { cn } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport AllowDownloadSection from \"@/components/links/link-sheet/allow-download-section\";\nimport AllowListSection from \"@/components/links/link-sheet/allow-list-section\";\nimport AllowNotificationSection from \"@/components/links/link-sheet/allow-notification-section\";\nimport DenyListSection from \"@/components/links/link-sheet/deny-list-section\";\nimport EmailAuthenticationSection from \"@/components/links/link-sheet/email-authentication-section\";\nimport EmailProtectionSection from \"@/components/links/link-sheet/email-protection-section\";\nimport ExpirationSection from \"@/components/links/link-sheet/expiration-section\";\nimport FeedbackSection from \"@/components/links/link-sheet/feedback-section\";\nimport OGSection from \"@/components/links/link-sheet/og-section\";\nimport PasswordSection from \"@/components/links/link-sheet/password-section\";\nimport { ProBannerSection } from \"@/components/links/link-sheet/pro-banner-section\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\n\nimport AgreementSection from \"./agreement-section\";\nimport AIAgentsSection from \"./ai-agents-section\";\nimport ConversationSection from \"./conversation-section\";\nimport CustomFieldsSection from \"./custom-fields-section\";\nimport IndexFileSection from \"./index-file-section\";\nimport QuestionSection from \"./question-section\";\nimport ScreenshotProtectionSection from \"./screenshot-protection-section\";\nimport UploadSection from \"./upload-section\";\nimport WatermarkSection from \"./watermark-section\";\nimport { WelcomeMessageSection } from \"./welcome-message-section\";\n\nexport type LinkUpgradeOptions = {\n  state: boolean;\n  trigger: string;\n  plan?: \"Pro\" | \"Business\" | \"Data Rooms\" | \"Data Rooms Plus\";\n  highlightItem?: string[];\n};\n\n// Collapsible Section Component\nconst CollapsibleSection = ({\n  title,\n  children,\n  defaultOpen = false,\n}: {\n  title: string;\n  children: React.ReactNode;\n  defaultOpen?: boolean;\n}) => {\n  const [isOpen, setIsOpen] = useState(defaultOpen);\n\n  return (\n    <Collapsible open={isOpen} onOpenChange={setIsOpen}>\n      <CollapsibleTrigger className=\"mb-5 flex w-full items-center justify-between rounded-t-md border-b border-border bg-muted/50 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-muted/70\">\n        <span>{title}</span>\n        <ChevronDown\n          className={cn(\n            \"h-4 w-4 transition-transform duration-200\",\n            isOpen ? \"rotate-180\" : \"\",\n          )}\n        />\n      </CollapsibleTrigger>\n      <CollapsibleContent className=\"overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\">\n        <div className=\"pt-2\">{children}</div>\n      </CollapsibleContent>\n    </Collapsible>\n  );\n};\n\nexport const LinkOptions = ({\n  data,\n  setData,\n  targetId,\n  linkType,\n  editLink,\n  currentPreset = null,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  targetId?: string;\n  linkType: Omit<LinkType, \"WORKFLOW_LINK\">;\n  editLink?: boolean;\n  currentPreset?: LinkPreset | null;\n}) => {\n  const {\n    isStarter,\n    isPro,\n    isBusiness,\n    isDatarooms,\n    isDataroomsPlus,\n    isTrial,\n  } = usePlan();\n  const { limits } = useLimits();\n  const allowAdvancedLinkControls = limits\n    ? limits?.advancedLinkControlsOnPro\n    : false;\n  const allowWatermarkOnBusiness = limits?.watermarkOnBusiness ?? false;\n  const allowAgreementOnBusiness = limits?.agreementOnBusiness ?? false;\n\n  const [openUpgradeModal, setOpenUpgradeModal] = useState<boolean>(false);\n  const [trigger, setTrigger] = useState<string>(\"\");\n  const [upgradePlan, setUpgradePlan] = useState<PlanEnum>(PlanEnum.Business);\n  const [highlightItem, setHighlightItem] = useState<string[]>([]);\n\n  const handleUpgradeStateChange = ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => {\n    setOpenUpgradeModal(state);\n    setTrigger(trigger);\n    if (plan) {\n      setUpgradePlan(plan as PlanEnum);\n    }\n    setHighlightItem(highlightItem || []);\n  };\n\n  return (\n    <div>\n      {/* Basic Options - Always visible */}\n      <AllowNotificationSection {...{ data, setData }} />\n      <EmailProtectionSection {...{ data, setData }} />\n      <EmailAuthenticationSection\n        {...{ data, setData }}\n        isAllowed={\n          isTrial ||\n          (isPro && allowAdvancedLinkControls) ||\n          isBusiness ||\n          isDatarooms ||\n          isDataroomsPlus\n        }\n        handleUpgradeStateChange={handleUpgradeStateChange}\n      />\n      <AllowDownloadSection {...{ data, setData }} />\n\n      {data.audienceType === LinkAudienceType.GENERAL ? (\n        <>\n          <AllowListSection\n            key={`allow-list-${data.id ?? \"new\"}`}\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              (isPro && allowAdvancedLinkControls) ||\n              isBusiness ||\n              isDatarooms ||\n              isDataroomsPlus\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n            presets={currentPreset}\n          />\n          <DenyListSection\n            key={`deny-list-${data.id ?? \"new\"}`}\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              (isPro && allowAdvancedLinkControls) ||\n              isBusiness ||\n              isDatarooms ||\n              isDataroomsPlus\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n            presets={currentPreset}\n          />\n        </>\n      ) : null}\n\n      {/* Security Section */}\n      <CollapsibleSection title=\"Security Controls\" defaultOpen={true}>\n        <div>\n          <PasswordSection {...{ data, setData }} />\n          <ExpirationSection {...{ data, setData }} presets={currentPreset} />\n          <ScreenshotProtectionSection\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              (isPro && allowAdvancedLinkControls) ||\n              isBusiness ||\n              isDatarooms ||\n              isDataroomsPlus\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n          />\n          <WatermarkSection\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              isDatarooms ||\n              isDataroomsPlus ||\n              allowWatermarkOnBusiness\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n            presets={currentPreset}\n          />\n          <AgreementSection\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              isDatarooms ||\n              isDataroomsPlus ||\n              allowAgreementOnBusiness\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n          />\n          <CustomFieldsSection\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              isBusiness ||\n              isDatarooms ||\n              isDataroomsPlus ||\n              (limits?.linkCustomFields ?? 0) > 0\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n            presets={currentPreset}\n          />\n        </div>\n      </CollapsibleSection>\n\n      {/* Custom Branding Section */}\n      <CollapsibleSection title=\"Custom Branding\" defaultOpen={true}>\n        <div>\n          <WelcomeMessageSection {...{ data, setData }} />\n          <OGSection\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              (isPro && allowAdvancedLinkControls) ||\n              isBusiness ||\n              isDatarooms ||\n              isDataroomsPlus\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n            editLink={editLink ?? false}\n            presets={currentPreset}\n          />\n          <ProBannerSection\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              isPro ||\n              isBusiness ||\n              isDatarooms ||\n              isDataroomsPlus ||\n              isStarter\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n          />\n        </div>\n      </CollapsibleSection>\n\n      {/* Advanced Section */}\n      <CollapsibleSection title=\"Advanced Controls\" defaultOpen={true}>\n        <div>\n          {/* AI Agents - Available for both document and dataroom links */}\n          <AIAgentsSection\n            {...{ data, setData }}\n            isAllowed={isTrial || isBusiness || isDatarooms || isDataroomsPlus}\n            handleUpgradeStateChange={handleUpgradeStateChange}\n          />\n\n          {/* Dataroom-specific options */}\n          {linkType === LinkType.DATAROOM_LINK ? (\n            <>\n              {targetId ? (\n                <UploadSection\n                  {...{ data, setData }}\n                  isAllowed={\n                    isTrial ||\n                    isDataroomsPlus ||\n                    (isDatarooms && limits?.dataroomUpload === true)\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  targetId={targetId}\n                />\n              ) : null}\n\n              <IndexFileSection\n                {...{ data, setData }}\n                isAllowed={isTrial || isDataroomsPlus}\n                handleUpgradeStateChange={handleUpgradeStateChange}\n              />\n\n              {limits?.conversationsInDataroom ? (\n                <ConversationSection\n                  {...{ data, setData }}\n                  isAllowed={\n                    isDataroomsPlus ||\n                    ((isBusiness || isDatarooms) &&\n                      limits?.conversationsInDataroom)\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n              ) : null}\n            </>\n          ) : null}\n        </div>\n      </CollapsibleSection>\n\n      <UpgradePlanModal\n        clickedPlan={upgradePlan}\n        open={openUpgradeModal}\n        setOpen={setOpenUpgradeModal}\n        trigger={trigger}\n        highlightItem={highlightItem}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/links/link-sheet/link-success-sheet.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport {\n  Check,\n  ClockIcon,\n  Copy,\n  DownloadIcon,\n  ExternalLink,\n  FolderLockIcon,\n  KeyRoundIcon,\n  Mail,\n  MailCheckIcon,\n  MailIcon,\n  ShieldIcon,\n  Users,\n} from \"lucide-react\";\n\nimport { LinkWithViews } from \"@/lib/types\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\n\ninterface LinkSuccessSheetProps {\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n  link: LinkWithViews;\n  hasCustomPermissions: boolean;\n  onCreateAnother: () => void;\n}\n\nexport default function LinkSuccessSheet({\n  isOpen,\n  setIsOpen,\n  link,\n  hasCustomPermissions,\n  onCreateAnother,\n}: LinkSuccessSheetProps) {\n  const [copied, setCopied] = useState(false);\n\n  const linkUrl =\n    link.domainId && link.slug\n      ? `https://${link.domainSlug}/${link.slug}`\n      : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;\n\n  const copyToClipboard = async () => {\n    try {\n      await navigator.clipboard.writeText(linkUrl);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } catch (err) {\n      console.error(\"Failed to copy: \", err);\n    }\n  };\n\n  const openLink = () => {\n    window.open(linkUrl, \"_blank\");\n  };\n\n  return (\n    <Sheet open={isOpen} onOpenChange={setIsOpen}>\n      <SheetContent className=\"flex w-[90%] flex-col justify-between border-l border-gray-200 bg-background px-4 text-foreground dark:border-gray-800 dark:bg-gray-900 sm:w-[600px] sm:max-w-2xl md:px-5\">\n        <SheetHeader className=\"text-start\">\n          <SheetTitle className=\"flex items-center gap-2\">\n            <Check className=\"h-5 w-5 text-green-600\" />\n            Link Created Successfully\n          </SheetTitle>\n          <p className=\"text-sm text-muted-foreground\">\n            Your dataroom link has been created and is ready to share.\n          </p>\n        </SheetHeader>\n\n        <div className=\"flex-1 space-y-6 py-6\">\n          {/* Link Section */}\n          <div className=\"space-y-3\">\n            <h3 className=\"text-sm font-medium\">Share Link</h3>\n            <div className=\"flex items-center gap-2 rounded-lg border bg-muted/30 p-3\">\n              <div className=\"flex-1 truncate font-mono text-sm\">{linkUrl}</div>\n              <Button\n                variant=\"default\"\n                size=\"sm\"\n                onClick={copyToClipboard}\n                className=\"shrink-0\"\n              >\n                {copied ? (\n                  <Check className=\"h-4 w-4\" />\n                ) : (\n                  <Copy className=\"h-4 w-4\" />\n                )}\n                {copied ? \"Copied\" : \"Copy\"}\n              </Button>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={openLink}\n                className=\"shrink-0\"\n              >\n                <ExternalLink className=\"h-4 w-4\" />\n                Open\n              </Button>\n            </div>\n          </div>\n\n          <Separator />\n\n          {/* Link Configuration Summary */}\n          <div className=\"space-y-4\">\n            <h3 className=\"text-sm font-medium\">Link Configuration</h3>\n\n            <div className=\"grid gap-3\">\n              <div className=\"flex items-center justify-between text-sm\">\n                <span className=\"flex items-center gap-2 text-muted-foreground\">\n                  <FolderLockIcon className=\"h-4 w-4\" />\n                  File Permissions\n                </span>\n                <Badge variant={hasCustomPermissions ? \"secondary\" : \"default\"}>\n                  {hasCustomPermissions ? \"Custom Permissions\" : \"Full Access\"}\n                </Badge>\n              </div>\n\n              <div className=\"flex items-center justify-between text-sm\">\n                <span className=\"flex items-center gap-2 text-muted-foreground\">\n                  <Users className=\"h-4 w-4\" />\n                  Viewer Access\n                </span>\n                <Badge\n                  variant={link.allowList.length > 0 ? \"default\" : \"secondary\"}\n                >\n                  {link.allowList.length > 0\n                    ? \"Specified viewers\"\n                    : \"Everyone with link\"}\n                </Badge>\n              </div>\n\n              {link.emailProtected && (\n                <div className=\"flex items-center justify-between text-sm\">\n                  <span className=\"flex items-center gap-2 text-muted-foreground\">\n                    <MailIcon className=\"h-4 w-4\" />\n                    Email Protection\n                  </span>\n                  <Badge variant=\"default\">Enabled</Badge>\n                </div>\n              )}\n\n              {link.emailProtected && (\n                <div className=\"flex items-center justify-between text-sm\">\n                  <span className=\"flex items-center gap-2 text-muted-foreground\">\n                    <MailCheckIcon className=\"h-4 w-4\" />\n                    Email Verification\n                  </span>\n                  <Badge\n                    variant={link.emailAuthenticated ? \"default\" : \"secondary\"}\n                  >\n                    {link.emailAuthenticated ? \"Enabled\" : \"Disabled\"}\n                  </Badge>\n                </div>\n              )}\n\n              {link.password && (\n                <div className=\"flex items-center justify-between text-sm\">\n                  <span className=\"flex items-center gap-2 text-muted-foreground\">\n                    <KeyRoundIcon className=\"h-4 w-4\" />\n                    Password Protection\n                  </span>\n                  <Badge variant=\"secondary\">Enabled</Badge>\n                </div>\n              )}\n\n              {link.expiresAt && (\n                <div className=\"flex items-center justify-between text-sm\">\n                  <span className=\"flex items-center gap-2 text-muted-foreground\">\n                    <ClockIcon className=\"h-4 w-4\" />\n                    Expiration\n                  </span>\n                  <Badge variant=\"outline\">\n                    {new Date(link.expiresAt).toLocaleDateString()}\n                  </Badge>\n                </div>\n              )}\n\n              <div className=\"flex items-center justify-between text-sm\">\n                <span className=\"flex items-center gap-2 text-muted-foreground\">\n                  <DownloadIcon className=\"h-4 w-4\" />\n                  Downloads\n                </span>\n                <Badge variant={link.allowDownload ? \"default\" : \"secondary\"}>\n                  {link.allowDownload ? \"Enabled\" : \"Disabled\"}\n                </Badge>\n              </div>\n\n              <div className=\"flex items-center justify-between text-sm\">\n                <span className=\"flex items-center gap-2 text-muted-foreground\">\n                  <ShieldIcon className=\"h-4 w-4\" />\n                  Additional Security\n                </span>\n                <Badge\n                  variant={\n                    link.enableAgreement || link.enableWatermark\n                      ? \"default\"\n                      : \"secondary\"\n                  }\n                >\n                  {link.enableAgreement || link.enableWatermark\n                    ? \"Enabled\"\n                    : \"Disabled\"}\n                </Badge>\n              </div>\n            </div>\n          </div>\n\n          <Separator />\n\n          {/* Future: Invite Members Section */}\n          <div className=\"space-y-3\">\n            <h3 className=\"text-sm font-medium text-muted-foreground\">\n              Invite Members (Coming Soon)\n            </h3>\n            <div className=\"rounded-lg border border-dashed border-muted-foreground/25 p-6 text-center\">\n              <Mail className=\"mx-auto h-8 w-8 text-muted-foreground/50\" />\n              <p className=\"mt-2 text-sm text-muted-foreground\">\n                Soon you&apos;ll be able to invite team members directly via\n                email\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <SheetFooter>\n          <div className=\"flex flex-row-reverse items-center gap-2 pt-2\">\n            <Button type=\"button\" variant=\"outline\" onClick={onCreateAnother}>\n              Create Another Link\n            </Button>\n          </div>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/og-section.tsx",
    "content": "import { ChangeEvent, useCallback, useEffect, useState } from \"react\";\n\nimport { LinkPreset } from \"@prisma/client\";\nimport { Label } from \"@radix-ui/react-label\";\nimport { Upload as ArrowUpTrayIcon, PlusIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { cn, fetcher, validateImageDimensions } from \"@/lib/utils\";\nimport { resizeImage } from \"@/lib/utils/resize-image\";\n\nimport { Input } from \"@/components/ui/input\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function OGSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n  editLink,\n  presets,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n  editLink: boolean;\n  presets: LinkPreset | null;\n}) {\n  const {\n    enableCustomMetatag,\n    metaTitle,\n    metaDescription,\n    metaImage,\n    metaFavicon,\n  } = data;\n\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [fileError, setFileError] = useState<string | null>(null);\n  const [dragActive, setDragActive] = useState(false);\n  const [faviconFileError, setFaviconFileError] = useState<string | null>(null);\n  const [faviconDragActive, setFaviconDragActive] = useState(false);\n\n  const onChangePicture = useCallback(\n    async (e: any) => {\n      setFileError(null);\n      const file = e.target.files[0];\n      if (file) {\n        if (file.size / 1024 / 1024 > 5) {\n          setFileError(\"File size too big (max 5MB)\");\n        } else if (file.type !== \"image/png\" && file.type !== \"image/jpeg\") {\n          setFileError(\"File type not supported (.png or .jpg only)\");\n        } else {\n          const image = await resizeImage(file);\n          setData((prev) => ({\n            ...prev,\n            metaImage: image,\n          }));\n        }\n      }\n    },\n    [setData],\n  );\n\n  useEffect(() => {\n    setEnabled(enableCustomMetatag);\n  }, [enableCustomMetatag]);\n\n  const handleCustomMetatag = async () => {\n    const updatedCustomMetatag = !enabled;\n\n    setData({ ...data, enableCustomMetatag: updatedCustomMetatag });\n    setEnabled(updatedCustomMetatag);\n  };\n\n  const resetMetatags = () => {\n    setData({\n      ...data,\n      metaImage: presets?.metaImage ?? null,\n      metaTitle: presets?.metaTitle ?? null,\n      metaDescription: presets?.metaDescription ?? null,\n    });\n  };\n\n  const onChangeFavicon = useCallback(\n    async (e: ChangeEvent<HTMLInputElement>) => {\n      setFaviconFileError(null);\n      const file = e.target.files && e.target.files[0];\n      if (file) {\n        if (file.size / 1024 / 1024 > 1) {\n          setFaviconFileError(\"File size too big (max 1MB)\");\n        } else if (\n          file.type !== \"image/png\" &&\n          file.type !== \"image/x-icon\" &&\n          file.type !== \"image/svg+xml\"\n        ) {\n          setFaviconFileError(\n            \"File type not supported (.png, .ico, .svg only)\",\n          );\n        } else {\n          const image = await resizeImage(file, {\n            width: 36,\n            height: 36,\n            quality: 1,\n          });\n          const isValidDimensions = await validateImageDimensions(\n            image,\n            16,\n            48,\n          );\n          if (!isValidDimensions) {\n            setFaviconFileError(\n              \"Image dimensions must be between 16x16 and 48x48\",\n            );\n          } else {\n            setData((prev) => ({\n              ...prev,\n              metaFavicon: image,\n            }));\n          }\n        }\n      }\n    },\n    [setData],\n  );\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        tooltipContent=\"Customize how your links look when shared.\"\n        title=\"Custom Link Preview\"\n        link=\"https://www.papermark.com/help/article/change-social-media-cards\"\n        enabled={enableCustomMetatag}\n        action={handleCustomMetatag}\n        isAllowed={isAllowed}\n        requiredPlan=\"Business\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_og_section\",\n            plan: \"Business\",\n            highlightItem: [\"custom-social-cards\"],\n          })\n        }\n        resetAction={resetMetatags}\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 space-y-3 rounded-md shadow-sm\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <p className=\"block text-sm font-medium text-foreground\">Image</p>\n              {fileError ? (\n                <p className=\"text-sm text-red-500\">{fileError}</p>\n              ) : null}\n            </div>\n            <label\n              htmlFor=\"image\"\n              className=\"group relative mt-1 flex aspect-[1200/630] h-fit cursor-pointer flex-col items-center justify-center rounded-md border border-input bg-white shadow-sm transition-all hover:border-muted-foreground hover:bg-gray-50 hover:ring-muted-foreground dark:bg-gray-800 hover:dark:bg-transparent\"\n            >\n              {false && (\n                <div className=\"absolute z-[5] flex h-full w-full items-center justify-center rounded-md bg-white\">\n                  <LoadingSpinner />\n                </div>\n              )}\n              <div\n                className=\"absolute z-[5] h-full w-full rounded-md\"\n                onDragOver={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setDragActive(true);\n                }}\n                onDragEnter={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setDragActive(true);\n                }}\n                onDragLeave={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setDragActive(false);\n                }}\n                onDrop={async (e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setDragActive(false);\n                  setFileError(null);\n                  const file = e.dataTransfer.files && e.dataTransfer.files[0];\n                  if (file) {\n                    if (file.size / 1024 / 1024 > 5) {\n                      setFileError(\"File size too big (max 5MB)\");\n                    } else if (\n                      file.type !== \"image/png\" &&\n                      file.type !== \"image/jpeg\" &&\n                      file.type !== \"image/jpg\"\n                    ) {\n                      setFileError(\n                        \"File type not supported (.png or .jpg only)\",\n                      );\n                    } else {\n                      const image = await resizeImage(file);\n                      setData((prev) => ({\n                        ...prev,\n                        metaImage: image,\n                      }));\n                    }\n                  }\n                }}\n              />\n              <div\n                className={cn(\n                  \"absolute z-[3] flex h-full w-full flex-col items-center justify-center rounded-md transition-all\",\n                  dragActive &&\n                    \"cursor-copy border-2 border-black bg-gray-50 opacity-100 dark:bg-transparent\",\n                  metaImage\n                    ? \"opacity-0 group-hover:opacity-100\"\n                    : \"group-hover:bg-gray-50 group-hover:dark:bg-transparent\",\n                )}\n              >\n                <ArrowUpTrayIcon\n                  className={cn(\n                    \"h-7 w-7 text-gray-500 transition-all duration-75 group-hover:scale-110 group-active:scale-95\",\n                    dragActive ? \"scale-110\" : \"scale-100\",\n                  )}\n                />\n                <p className=\"mt-2 text-center text-sm text-gray-500\">\n                  Drag and drop or click to upload.\n                </p>\n                <p className=\"mt-2 text-center text-sm text-gray-500\">\n                  Recommended: 1200 x 630 pixels (max 5MB)\n                </p>\n                <span className=\"sr-only\">OG image upload</span>\n              </div>\n              {metaImage && (\n                <img\n                  src={metaImage}\n                  alt=\"Preview\"\n                  className=\"h-full w-full rounded-md object-cover\"\n                />\n              )}\n            </label>\n            <div className=\"mt-1 flex rounded-md shadow-sm\">\n              <input\n                id=\"image\"\n                name=\"image\"\n                type=\"file\"\n                accept=\"image/png,image/jpeg,image/jpg\"\n                className=\"sr-only\"\n                onChange={onChangePicture}\n              />\n            </div>\n          </div>\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor=\"faviconIcon\">\n                <p className=\"block text-sm font-medium text-foreground\">\n                  Favicon Icon{\" \"}\n                  <span className=\"text-sm italic text-muted-foreground\">\n                    (max 1 MB)\n                  </span>\n                </p>\n              </Label>\n              {faviconFileError ? (\n                <p className=\"text-sm text-red-500\">{faviconFileError}</p>\n              ) : null}\n            </div>\n            <label\n              htmlFor=\"faviconIcon\"\n              className=\"group relative mt-1 flex size-14 cursor-pointer flex-col items-center justify-center rounded-md border border-gray-300 bg-white shadow-sm transition-all hover:bg-gray-50\"\n              style={{\n                backgroundImage:\n                  \"linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(135deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(135deg, transparent 75%, #ccc 75%)\",\n                backgroundSize: \"20px 20px\",\n                backgroundPosition: \"0 0, 10px 0, 10px -10px, 0px 10px\",\n              }}\n            >\n              {false && (\n                <div className=\"absolute z-[5] flex h-full w-full items-center justify-center rounded-md bg-white\">\n                  <LoadingSpinner />\n                </div>\n              )}\n              <div\n                className=\"absolute z-[5] h-full w-full rounded-md\"\n                onDragOver={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setFaviconDragActive(true);\n                }}\n                onDragEnter={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setFaviconDragActive(true);\n                }}\n                onDragLeave={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setFaviconDragActive(false);\n                }}\n                onDrop={async (e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  setFaviconDragActive(false);\n                  setFaviconFileError(null);\n                  const file = e.dataTransfer.files && e.dataTransfer.files[0];\n                  if (file) {\n                    if (file.size / 1024 / 1024 > 1) {\n                      setFaviconFileError(\"File size too big (max 1MB)\");\n                    } else if (\n                      file.type !== \"image/png\" &&\n                      file.type !== \"image/x-icon\" &&\n                      file.type !== \"image/svg+xml\"\n                    ) {\n                      setFaviconFileError(\n                        \"File type not supported (.png, .ico, .svg only)\",\n                      );\n                    } else {\n                      const image = await resizeImage(file, {\n                        width: 36,\n                        height: 36,\n                        quality: 1,\n                      });\n                      const isValidDimensions = await validateImageDimensions(\n                        image,\n                        16,\n                        48,\n                      );\n                      if (!isValidDimensions) {\n                        setFaviconFileError(\n                          \"Image dimensions must be between 16x16 and 48x48\",\n                        );\n                      } else {\n                        setData((prev) => ({\n                          ...prev,\n                          metaFavicon: image,\n                        }));\n                      }\n                    }\n                  }\n                }}\n              />\n              <div\n                className={`${\n                  faviconDragActive\n                    ? \"cursor-copy border-2 border-black bg-gray-50 opacity-100\"\n                    : \"\"\n                } absolute z-[3] flex h-full w-full flex-col items-center justify-center rounded-md bg-white transition-all ${\n                  metaFavicon\n                    ? \"opacity-0 group-hover:opacity-100\"\n                    : \"group-hover:bg-gray-50\"\n                }`}\n              >\n                <PlusIcon\n                  className={`${\n                    faviconDragActive ? \"scale-110\" : \"scale-100\"\n                  } h-7 w-7 text-gray-500 transition-all duration-75 group-hover:scale-110 group-active:scale-95`}\n                />\n                <span className=\"sr-only\">OG image upload</span>\n              </div>\n              {metaFavicon && (\n                <img\n                  src={metaFavicon}\n                  alt=\"Preview\"\n                  className=\"h-full w-full rounded-md object-contain\"\n                />\n              )}\n            </label>\n            <div className=\"mt-1 hidden rounded-md shadow-sm\">\n              <input\n                id=\"faviconIcon\"\n                name=\"favicon\"\n                type=\"file\"\n                accept=\"image/png,image/x-icon,image/svg+xml\"\n                className=\"sr-only\"\n                onChange={onChangeFavicon}\n              />\n            </div>\n          </div>\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <p className=\"block text-sm font-medium text-foreground\">Title</p>\n              <p className=\"text-sm text-muted-foreground\">\n                {metaTitle?.length || 0}/120\n              </p>\n            </div>\n            <div className=\"relative mt-1 flex rounded-md shadow-sm\">\n              {false && (\n                <div className=\"absolute flex h-full w-full items-center justify-center rounded-md border border-gray-300 bg-white\">\n                  <LoadingSpinner />\n                </div>\n              )}\n              <Input\n                name=\"title\"\n                id=\"title\"\n                maxLength={120}\n                className=\"focus:ring-inset\"\n                placeholder={`Papermark - open-source document sharing infrastructure.`}\n                value={metaTitle || \"\"}\n                onChange={(e) => {\n                  setData({ ...data, metaTitle: e.target.value });\n                }}\n                aria-invalid=\"true\"\n              />\n            </div>\n          </div>\n\n          <div>\n            <div className=\"flex items-center justify-between\">\n              <p className=\"block text-sm font-medium text-foreground\">\n                Description\n              </p>\n              <p className=\"text-sm text-muted-foreground\">\n                {metaDescription?.length || 0}/240\n              </p>\n            </div>\n            <div className=\"relative mt-1 flex rounded-md shadow-sm\">\n              {false && (\n                <div className=\"absolute flex h-full w-full items-center justify-center rounded-md border border-gray-300 bg-white\">\n                  <LoadingSpinner />\n                </div>\n              )}\n              <Textarea\n                name=\"description\"\n                id=\"description\"\n                rows={3}\n                maxLength={240}\n                className=\"focus:ring-inset\"\n                placeholder={`Papermark is an open-source document sharing infrastructure for modern teams.`}\n                value={metaDescription || \"\"}\n                onChange={(e) => {\n                  setData({\n                    ...data,\n                    metaDescription: e.target.value,\n                  });\n                }}\n                aria-invalid=\"true\"\n              />\n            </div>\n          </div>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/password-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { motion } from \"motion/react\";\n\nimport Eye from \"@/components/shared/icons/eye\";\nimport EyeOff from \"@/components/shared/icons/eye-off\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nexport default function PasswordSection({\n  data,\n  setData,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n}) {\n  const { password } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [showPassword, setShowPassword] = useState<boolean>(false);\n\n  useEffect(() => {\n    setEnabled(!!password);\n  }, [password]);\n\n  const handleEnablePassword = () => {\n    if (enabled) {\n      // if password protection is currently enabled and we're toggling it off\n      setData({ ...data, password: null });\n    }\n    setEnabled(!enabled);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Require password to view\"\n        enabled={enabled}\n        action={handleEnablePassword}\n        tooltipContent=\"Users must enter a password to access the content.\"\n        link=\"https://www.papermark.com/password-protection\"\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 rounded-md shadow-sm\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <Input\n            name=\"password\"\n            id=\"password\"\n            autoComplete=\"off\"\n            data-1p-ignore\n            type={showPassword ? \"text\" : \"password\"}\n            className=\"focus:ring-inset\"\n            // className=\"flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6\"\n            value={password || \"\"}\n            placeholder=\"Enter password\"\n            onChange={(e) => {\n              setData({ ...data, password: e.target.value });\n            }}\n            aria-invalid=\"true\"\n          />\n          <button\n            type=\"button\"\n            onClick={() => setShowPassword(!showPassword)}\n            className=\"absolute inset-y-0 right-0 flex items-center pr-3\"\n          >\n            {showPassword ? (\n              <Eye\n                className=\"h-4 w-4 text-muted-foreground\"\n                aria-hidden=\"true\"\n              />\n            ) : (\n              <EyeOff\n                className=\"h-4 w-4 text-muted-foreground\"\n                aria-hidden=\"true\"\n              />\n            )}\n          </button>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/permissions-sheet.tsx",
    "content": "\"use client\";\n\nimport { PermissionsSheet as PermissionsSheetEE } from \"@/ee/features/permissions/components/permissions-sheet\";\n\nexport function PermissionsSheet(props: any) {\n  return <PermissionsSheetEE {...props} />;\n}\n"
  },
  {
    "path": "components/links/link-sheet/pro-banner-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport function ProBannerSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { showBanner } = data;\n  const [enabled, setEnabled] = useState<boolean>(showBanner);\n\n  useEffect(() => {\n    setEnabled(showBanner);\n  }, [showBanner]);\n\n  const handleShowBanner = () => {\n    const updatedShowBanner = !enabled;\n    setData({ ...data, showBanner: updatedShowBanner });\n    setEnabled(updatedShowBanner);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Show Secured by Papermark\"\n        tooltipContent=\"Display 'Secured by Papermark' branding on your shared documents\"\n        link=\"https://www.papermark.com/help/article/remove-papermark-branding\"\n        enabled={enabled}\n        action={handleShowBanner}\n        isAllowed={isAllowed}\n        requiredPlan=\"pro\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_hide_pro_banner_section\",\n            plan: \"Pro\",\n            highlightItem: [\"branding\"],\n          })\n        }\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/question-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { motion } from \"motion/react\";\n\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function QuestionSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { enableQuestion, questionText, questionType } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n\n  useEffect(() => {\n    setEnabled(enableQuestion!);\n  }, [enableQuestion]);\n\n  const handleQuestion = async () => {\n    const updatedQuestion = !enabled;\n\n    setData({ ...data, enableQuestion: updatedQuestion });\n    setEnabled(updatedQuestion);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Feedback Question\"\n        tooltipContent=\"Create a concise question for visitor feedback.\"\n        enabled={enabled}\n        action={handleQuestion}\n        isAllowed={isAllowed}\n        requiredPlan=\"business\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_question_section\",\n            plan: \"Business\",\n          })\n        }\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 space-y-3\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <div className=\"flex w-full flex-col items-start gap-6 overflow-x-visible pb-4 pt-0\">\n            <div className=\"w-full space-y-2\">\n              <Label>Question Type</Label>\n              <Select defaultValue=\"yes-no\">\n                <SelectTrigger className=\"flex w-full rounded-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\">\n                  <SelectValue placeholder=\"Select a question type\" />\n                </SelectTrigger>\n                <SelectContent className=\"z-50 flex w-full rounded-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-gray-900 sm:text-sm\">\n                  <SelectItem value=\"yes-no\">Yes / No</SelectItem>\n                </SelectContent>\n              </Select>\n            </div>\n            <div className=\"w-full space-y-2\">\n              <Label htmlFor=\"question\">Question</Label>\n              <Input\n                className=\"focus:ring-inset\"\n                id=\"question\"\n                type=\"text\"\n                name=\"question\"\n                required\n                placeholder=\"Are you interested?\"\n                value={questionText || \"\"}\n                onChange={(e) =>\n                  setData({\n                    ...data,\n                    questionText: e.target.value,\n                    questionType: \"YES_NO\",\n                  })\n                }\n              />\n            </div>\n          </div>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/screenshot-protection-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\n\nexport default function ScreenshotProtectionSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { enableScreenshotProtection } = data;\n  const [enabled, setEnabled] = useState<boolean>(true);\n\n  useEffect(() => {\n    setEnabled(enableScreenshotProtection);\n  }, [enableScreenshotProtection]);\n\n  const handleEnableScreenshotProtection = () => {\n    const updatedEnableScreenshotProtection = !enabled;\n    setData({\n      ...data,\n      enableScreenshotProtection: updatedEnableScreenshotProtection,\n    });\n    setEnabled(updatedEnableScreenshotProtection);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Enable screenshot protection\"\n        tooltipContent=\"Prevent users from taking screenshots of your content.\"\n        link=\"https://www.papermark.com/screenshot-protection\"\n        enabled={enabled}\n        action={handleEnableScreenshotProtection}\n        isAllowed={isAllowed}\n        requiredPlan=\"business\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_screenshot_protection_section\",\n            plan: \"Business\",\n            highlightItem: [\"screenshot\"],\n          })\n        }\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/tags/tag-badge.tsx",
    "content": "import { TagIcon } from \"lucide-react\";\n\nimport { TagColorProps } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nexport default function TagBadge({\n  name,\n  color,\n  withIcon,\n  plus,\n  className,\n  isSelected,\n}: {\n  name?: string;\n  color: TagColorProps;\n  withIcon?: boolean;\n  plus?: number;\n  className?: string;\n  isSelected?: boolean;\n}) {\n  return (\n    <span\n      className={cn(\n        \"my-auto block whitespace-nowrap rounded-md border px-2 py-0.5 text-sm\",\n        (withIcon || plus) &&\n          \"flex items-center gap-x-1.5 p-1.5 sm:rounded-md sm:px-2 sm:py-0.5\",\n        COLORS_LIST.find((c) => c.color === color)?.css,\n        isSelected && \"border-2 bg-transparent\",\n        className,\n      )}\n    >\n      {withIcon && (\n        <TagIcon\n          className={cn(\n            \"!size-5 shrink-0 p-0.5 dark:text-primary-foreground\",\n            isSelected && `bg-${color}-100 rounded-sm border border-gray-200`,\n          )}\n        />\n      )}\n      {name && (\n        <p {...(withIcon && { className: \"hidden sm:inline-block\" })}>\n          {name || \"\"}\n        </p>\n      )}\n      {!!plus && (\n        <span className=\"hidden sm:block\">\n          <span className=\"pr-1.5 opacity-30 md:pl-1 md:pr-2.5\">|</span>+{plus}\n        </span>\n      )}\n    </span>\n  );\n}\n\nexport const COLORS_LIST: { color: TagColorProps; css: string }[] = [\n  {\n    color: \"red\",\n    css: \"border-red-300 bg-red-100 text-red-500\",\n  },\n  {\n    color: \"yellow\",\n    css: \"border-yellow-300 bg-yellow-100 text-yellow-500\",\n  },\n  {\n    color: \"green\",\n    css: \"border-emerald-300 bg-emerald-100 text-emerald-500\",\n  },\n  {\n    color: \"blue\",\n    css: \"border-blue-300 bg-blue-100 text-blue-500\",\n  },\n  {\n    color: \"purple\",\n    css: \"border-purple-300 bg-purple-100 text-purple-500\",\n  },\n  {\n    color: \"slate\",\n    css: \"border-stone-300 bg-stone-100 text-stone-500\",\n  },\n  {\n    color: \"fuchsia\",\n    css: \"border-fuchsia-300 bg-fuchsia-100 text-fuchsia-500\",\n  },\n];\n\nexport function randomBadgeColor() {\n  const randomIndex = Math.floor(Math.random() * COLORS_LIST.length);\n  return COLORS_LIST[randomIndex].color;\n}\n"
  },
  {
    "path": "components/links/link-sheet/tags/tag-details.tsx",
    "content": "import { useSearchParams } from \"next/navigation\";\n\nimport { PropsWithChildren, useMemo, useRef } from \"react\";\nimport { useQueryState } from \"nuqs\";\n\nimport { LinkWithViews, TagColorProps, TagProps } from \"@/lib/types\";\n\n\n\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport TagBadge from \"./tag-badge\";\n\n\nfunction useOrganizedTags(tags: LinkWithViews[\"tags\"]) {\n  const searchParams = useSearchParams();\n\n  const [primaryTag, additionalTags] = useMemo(() => {\n    const filteredTagNames =\n      searchParams?.get(\"tags\")?.split(\",\")?.filter(Boolean) ?? [];\n\n    const sortedTags =\n      filteredTagNames.length > 0\n        ? [...tags].sort(\n            (a, b) =>\n              filteredTagNames.indexOf(b.name) -\n              filteredTagNames.indexOf(a.name),\n          )\n        : tags;\n\n    return [sortedTags?.[0], sortedTags.slice(1)];\n  }, [tags, searchParams]);\n\n  return { primaryTag, additionalTags };\n}\n\nexport function TagColumn({\n  link,\n  onClose,\n}: {\n  link: LinkWithViews;\n  onClose?: () => void;\n}) {\n  const { tags } = link;\n\n  const ref = useRef<HTMLDivElement>(null);\n\n  const { primaryTag, additionalTags } = useOrganizedTags(tags);\n\n  return (\n    <div ref={ref} className=\"flex items-center gap-2 sm:gap-5\">\n      {primaryTag ? (\n        <TagsTooltip additionalTags={additionalTags} onClose={onClose}>\n          <TagButton\n            tag={primaryTag}\n            plus={additionalTags.length}\n            onClose={onClose}\n          />\n        </TagsTooltip>\n      ) : (\n        <p>-</p>\n      )}\n    </div>\n  );\n}\n\nfunction TagsTooltip({\n  additionalTags,\n  children,\n  onClose,\n}: PropsWithChildren<{ additionalTags: TagProps[]; onClose?: () => void }>) {\n  return !!additionalTags.length ? (\n    <BadgeTooltip\n      align=\"end\"\n      content={\n        <div className=\"flex flex-wrap gap-1.5 rounded-md p-1\">\n          {additionalTags.map((tag) => (\n            <TagButton key={tag.id} tag={tag} onClose={onClose} />\n          ))}\n        </div>\n      }\n    >\n      <div>{children}</div>\n    </BadgeTooltip>\n  ) : (\n    children\n  );\n}\n\nfunction TagButton({\n  tag,\n  plus,\n  onClose,\n}: {\n  tag: TagProps;\n  plus?: number;\n  onClose?: () => void;\n}) {\n  const [tags, setTags] = useQueryState<string[]>(\"tags\", {\n    parse: (value: string) => value.split(\",\").filter(Boolean),\n    serialize: (value: string[]) => value.join(\",\"),\n  });\n\n  const selectedTagNames = useMemo(() => tags ?? [], [tags]);\n\n  const handleClick = () => {\n    const newTagNames = selectedTagNames.includes(tag.name)\n      ? selectedTagNames.filter((name: string) => name !== tag.name)\n      : [...selectedTagNames, tag.name];\n\n    if (newTagNames.length === 0) {\n      setTags(null);\n    } else {\n      setTags(newTagNames);\n    }\n    onClose?.();\n  };\n\n  return (\n    <button onClick={handleClick}>\n      <TagBadge\n        {...tag}\n        color={tag.color as TagColorProps}\n        withIcon\n        plus={plus}\n        isSelected={selectedTagNames.includes(tag.name)}\n      />\n    </button>\n  );\n}"
  },
  {
    "path": "components/links/link-sheet/tags/tag-section.tsx",
    "content": "import Link from \"next/link\";\n\nimport { Dispatch, SetStateAction, useMemo, useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { CircleHelpIcon, Tag } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useTags } from \"@/lib/swr/use-tags\";\nimport { TagProps } from \"@/lib/types\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Label } from \"@/components/ui/label\";\nimport { MultiSelect } from \"@/components/ui/multi-select-v2\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport { DEFAULT_LINK_TYPE } from \"..\";\n\nfunction getTagOption(tag: TagProps) {\n  return {\n    value: tag.id,\n    label: tag.name,\n    icon: (\n      <Tag\n        size={20}\n        className={`rounded-sm border border-gray-200 bg-${tag.color}-100 p-1 dark:text-primary-foreground`}\n      />\n    ),\n    meta: { color: tag.color, description: tag.description },\n  };\n}\n\nexport default function TagSection({\n  data,\n  setData,\n  teamId,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: Dispatch<SetStateAction<DEFAULT_LINK_TYPE>>;\n  teamId: string;\n}) {\n  const [isPopoverOpen, setIsPopoverOpen] = useState(false);\n  const [selectedValues, setSelectedValues] = useState<string[]>(\n    data.tags || [],\n  );\n  const [showUpgradeModal, setShowUpgradeModal] = useState(false);\n  const { isFree } = usePlan();\n\n  const {\n    tagCount,\n    tags: availableTags,\n    loading: loadingTags,\n  } = useTags({\n    query: {\n      sortBy: \"createdAt\",\n      sortOrder: \"desc\",\n    },\n  });\n\n  const options = useMemo(\n    () => availableTags?.map((tag) => getTagOption(tag)),\n    [availableTags],\n  );\n\n  // Callback to handle value change\n  const handleValueChange = (value: string[]) => {\n    setSelectedValues(value);\n    setData((prevData) => ({\n      ...prevData,\n      tags: value,\n    }));\n  };\n\n  const createTag = async (tag: string) => {\n    if (isFree && tagCount && tagCount >= 5) {\n      setShowUpgradeModal(true);\n      toast.error(\"You have reached the maximum number of tags.\");\n      return false;\n    }\n\n    const res = await fetch(`/api/teams/${teamId}/tags`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ name: tag }),\n    });\n    if (!res.ok) {\n      const { error } = await res.json();\n      toast.error(error);\n      return false;\n    }\n\n    const newTag = await res.json();\n    await mutate(\n      `/api/teams/${teamId}/tags?${new URLSearchParams({\n        sortBy: \"createdAt\",\n        sortOrder: \"desc\",\n        includeLinksCount: false,\n      } as Record<string, any>).toString()}`,\n    );\n    setSelectedValues([...selectedValues, newTag.id]);\n    setData((prevData) => ({\n      ...prevData,\n      tags: [...prevData.tags, newTag.id],\n    }));\n    setIsPopoverOpen(false);\n    toast.success(`Successfully created tag!`);\n    return true;\n  };\n\n  return (\n    <>\n      <div className=\"flex justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <Label htmlFor=\"link-domain\">Tags</Label>\n          <BadgeTooltip\n            content=\"Group links by tags to organize and track performance\"\n            link=\"https://www.papermark.com/help/article/tag-links\"\n          >\n            <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n          </BadgeTooltip>\n        </div>\n        <Link\n          href={`/settings/tags`}\n          className=\"text-xs text-muted-foreground hover:text-foreground hover:underline\"\n        >\n          Manage\n        </Link>\n      </div>\n      <div className=\"flex\">\n        <MultiSelect\n          loading={loadingTags}\n          options={options ?? []}\n          value={selectedValues}\n          setIsPopoverOpen={setIsPopoverOpen}\n          isPopoverOpen={isPopoverOpen}\n          onValueChange={handleValueChange}\n          placeholder=\"Select tags...\"\n          maxCount={3}\n          searchPlaceholder=\"Search or add tags...\"\n          onCreate={(search) => createTag(search)}\n        />\n      </div>\n      {showUpgradeModal && (\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.Pro}\n          trigger=\"create_tag\"\n          open={showUpgradeModal}\n          setOpen={setShowUpgradeModal}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/upload-section/index.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\n\nimport { CircleHelpIcon, CircleXIcon, FolderIcon, XIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\n\nimport { SidebarFolderTreeSelection as DataroomFolderTree } from \"@/components/datarooms/folders\";\nimport { TSelectedFolder } from \"@/components/documents/move-folder-modal\";\nimport { SidebarFolderTreeSelection as AllDocFolderTree } from \"@/components/sidebar-folders\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport { DEFAULT_LINK_TYPE } from \"..\";\nimport LinkItem from \"../link-item\";\nimport { LinkUpgradeOptions } from \"../link-options\";\n\nfunction FolderSelectionModal({\n  open,\n  setOpen,\n  dataroomId,\n  currentFolder,\n  handleSelectFolder,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  dataroomId: string;\n  currentFolder: TSelectedFolder | null;\n  handleSelectFolder: (selectedFolder: TSelectedFolder | null) => void;\n}) {\n  const [selectedFolder, setSelectedFolder] = useState<TSelectedFolder | null>(\n    currentFolder,\n  );\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n    handleSelectFolder(selectedFolder);\n    setOpen(false);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>\n        <div className=\"flex\">\n          <div className=\"flex w-full cursor-pointer rounded-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\">\n            <div className=\"flex w-full items-center px-3 py-2\">\n              {selectedFolder ? (\n                <div className=\"relative w-full\">\n                  <span className=\"flex items-center gap-1\">\n                    <FolderIcon className=\"mr-1 h-4 w-4\" />{\" \"}\n                    {selectedFolder.name}\n                  </span>\n                  <button\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      setSelectedFolder(null);\n                      handleSelectFolder(null);\n                    }}\n                    className=\"pointer-events-auto absolute inset-y-0 right-0 z-10 -mr-1 flex items-center rounded-md p-1 hover:bg-muted\"\n                  >\n                    <XIcon className=\"h-4 w-4 text-muted-foreground\" />\n                  </button>\n                </div>\n              ) : (\n                <span className=\"text-muted-foreground\">\n                  Optionally, select folder\n                </span>\n              )}\n            </div>\n          </div>\n        </div>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Select Folder</DialogTitle>\n          <DialogDescription>\n            Select folder location to upload file.\n          </DialogDescription>\n        </DialogHeader>\n        <form>\n          <div className=\"mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll\">\n            {dataroomId && dataroomId !== \"all_documents\" ? (\n              <DataroomFolderTree\n                dataroomId={dataroomId}\n                selectedFolder={selectedFolder}\n                setSelectedFolder={setSelectedFolder}\n              />\n            ) : (\n              <AllDocFolderTree\n                selectedFolder={selectedFolder}\n                setSelectedFolder={setSelectedFolder}\n              />\n            )}\n          </div>\n\n          <DialogFooter>\n            <Button\n              onClick={handleSubmit}\n              className=\"flex h-9 w-full gap-1\"\n              disabled={!selectedFolder}\n            >\n              {!selectedFolder ? (\n                \"Select a folder\"\n              ) : (\n                <>\n                  Select{\" \"}\n                  <span className=\"max-w-[200px] truncate font-medium\">\n                    {selectedFolder.name}\n                  </span>\n                </>\n              )}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nexport default function UploadSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n  targetId,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n  }: LinkUpgradeOptions) => void;\n  targetId: string;\n}) {\n  const { enableUpload, isFileRequestOnly, uploadFolderId, uploadFolderName } =\n    data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [selectedFolder, setSelectedFolder] = useState<TSelectedFolder | null>(\n    null,\n  );\n  const [open, setOpen] = useState<boolean>(false);\n\n  useEffect(() => {\n    setEnabled(enableUpload!);\n  }, [enableUpload]);\n\n  useEffect(() => {\n    if (uploadFolderId) {\n      setSelectedFolder({ id: uploadFolderId, name: uploadFolderName });\n    }\n  }, [uploadFolderId, uploadFolderName]);\n\n  const handleUpload = async () => {\n    const updatedUpload = !enabled;\n\n    setData({\n      ...data,\n      enableUpload: updatedUpload,\n    });\n    setEnabled(updatedUpload);\n  };\n\n  const handleFileRequestToggle = (checked: boolean): void => {\n    setData({\n      ...data,\n      isFileRequestOnly: checked,\n    });\n  };\n\n  const handleSelectFolder = (selectedFolder: TSelectedFolder | null): void => {\n    setSelectedFolder(selectedFolder);\n    setData({\n      ...data,\n      uploadFolderId: selectedFolder?.id ?? null,\n      uploadFolderName: selectedFolder?.name || \"Home\",\n    });\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Enable file requests\"\n        tooltipContent=\"Visitors can upload files to the dataroom.\"\n        enabled={enabled}\n        action={handleUpload}\n        isAllowed={isAllowed}\n        requiredPlan=\"data rooms plus\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_upload_section\",\n            plan: \"Data Rooms Plus\",\n          })\n        }\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 space-y-3\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <div className=\"flex w-full flex-col items-start gap-6 overflow-x-visible pb-4 pt-0\">\n            <div className=\"w-full space-y-4\">\n              {/* <div className=\"flex items-center space-x-2\">\n                <Switch\n                  id=\"file-request-mode\"\n                  checked={isFileRequestOnly}\n                  onCheckedChange={handleFileRequestToggle}\n                />\n                <Label htmlFor=\"file-request-mode\">\n                  File request only mode\n                </Label>\n              </div> */}\n\n              <div className=\"space-y-4\">\n                <Label\n                  htmlFor=\"link-folder\"\n                  className=\"flex flex-col items-start gap-2\"\n                >\n                  <div className=\"flex items-center gap-2\">\n                    <span>Upload to specific folder</span>\n                    <BadgeTooltip content=\"This is the folder that will be used to store uploaded files. If you don't select a folder, the files will be uploaded to the folder the visitor chooses.\">\n                      <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                    </BadgeTooltip>\n                  </div>\n                  <span className=\"text-sm text-muted-foreground\">\n                    Leave blank for visitor to choose folder\n                  </span>\n                </Label>\n                <FolderSelectionModal\n                  open={open}\n                  setOpen={setOpen}\n                  dataroomId={targetId}\n                  currentFolder={selectedFolder}\n                  handleSelectFolder={handleSelectFolder}\n                />\n              </div>\n            </div>\n          </div>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/watermark-panel/index.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\n\nimport { motion } from \"motion/react\";\nimport { HexColorInput, HexColorPicker } from \"react-colorful\";\nimport { z } from \"zod\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { WatermarkConfig, WatermarkConfigSchema } from \"@/lib/types\";\n\ninterface WatermarkConfigSheetProps {\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n  initialConfig: Partial<WatermarkConfig>;\n  onSave: (config: WatermarkConfig) => void;\n}\n\nexport default function WatermarkConfigSheet({\n  isOpen,\n  onOpenChange,\n  initialConfig,\n  onSave,\n}: WatermarkConfigSheetProps) {\n  const [formValues, setFormValues] =\n    useState<Partial<WatermarkConfig>>(initialConfig);\n  const [errors, setErrors] = useState<Record<string, string>>({});\n\n  useEffect(() => {\n    setFormValues(initialConfig);\n  }, [initialConfig]);\n\n  const handleInputChange = (\n    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,\n  ) => {\n    const { name, value } = e.target;\n    setFormValues((prevValues) => ({\n      ...prevValues,\n      [name]: value,\n    }));\n  };\n\n  const validateAndSave = () => {\n    try {\n      const validatedData = WatermarkConfigSchema.parse(formValues);\n      onSave(validatedData);\n      setErrors({});\n      onOpenChange(false);\n    } catch (error) {\n      if (error instanceof z.ZodError) {\n        const fieldErrors: Record<string, string> = {};\n        error.errors.forEach((err) => {\n          if (err.path[0]) {\n            fieldErrors[err.path[0] as string] = err.message;\n          }\n        });\n        setErrors(fieldErrors);\n      }\n    }\n  };\n\n  return (\n    <Sheet open={isOpen} onOpenChange={onOpenChange}>\n      <SheetContent className=\"flex h-full flex-col\">\n        <SheetHeader>\n          <SheetTitle>Watermark Configuration</SheetTitle>\n          <SheetDescription>\n            Configure the watermark settings for your document.\n          </SheetDescription>\n        </SheetHeader>\n\n        <ScrollArea className=\"flex-1\">\n          <motion.div\n            className=\"relative mt-4 space-y-3\"\n            {...FADE_IN_ANIMATION_SETTINGS}\n          >\n            <div className=\"flex w-full flex-col items-start gap-6 overflow-x-visible pb-4 pt-0\">\n              <div className=\"w-full space-y-2\">\n                <Label htmlFor=\"watermark-text\">Watermark Text</Label>\n                <Input\n                  id=\"watermark-text\"\n                  type=\"text\"\n                  name=\"text\"\n                  placeholder=\"e.g. Confidential {{email}} {{date}}\"\n                  value={formValues.text || \"\"}\n                  onChange={handleInputChange}\n                  className=\"focus:ring-inset\"\n                />\n                <div className=\"space-x-1 space-y-1\">\n                  {[\"email\", \"date\", \"time\", \"link\", \"ipAddress\"].map(\n                    (item) => (\n                      <Button\n                        key={item}\n                        size=\"sm\"\n                        variant=\"outline\"\n                        className=\"h-7 rounded-3xl bg-muted text-sm font-normal text-foreground/80 hover:bg-muted/70\"\n                        onClick={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setFormValues((prevValues) => ({\n                            ...prevValues,\n                            text: `${prevValues.text || \"\"}{{${item}}}`,\n                          }));\n                        }}\n                      >{`{{${item}}}`}</Button>\n                    ),\n                  )}\n                </div>\n                {errors.text && <p className=\"text-red-500\">{errors.text}</p>}\n              </div>\n\n              <div className=\"w-full space-y-2\">\n                <div className=\"relative flex items-center space-x-2\">\n                  <Checkbox\n                    id=\"watermark-tiled\"\n                    checked={formValues.isTiled}\n                    onCheckedChange={(checked) => {\n                      setFormValues((prevValues) => ({\n                        ...prevValues,\n                        isTiled: checked === true,\n                      }));\n                    }}\n                    className=\"mt-0.5 border border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-white data-[state=checked]:bg-black data-[state=checked]:text-white\"\n                  />\n                  <Label htmlFor=\"watermark-tiled\">Tiled</Label>\n                </div>\n                {errors.isTiled && (\n                  <p className=\"text-red-500\">{errors.isTiled}</p>\n                )}\n              </div>\n\n              <div className=\"w-full space-y-2\">\n                <Label htmlFor=\"watermark-position\">Position</Label>\n                <Select\n                  name=\"position\"\n                  value={formValues.position}\n                  disabled={formValues.isTiled}\n                  onValueChange={(value) => {\n                    setFormValues({\n                      ...formValues,\n                      position: value as WatermarkConfig[\"position\"],\n                    });\n                  }}\n                >\n                  <SelectTrigger>\n                    <SelectValue placeholder=\"Select position\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"top-left\">Top Left</SelectItem>\n                    <SelectItem value=\"top-center\">Top Center</SelectItem>\n                    <SelectItem value=\"top-right\">Top Right</SelectItem>\n                    <SelectItem value=\"middle-left\">Middle Left</SelectItem>\n                    <SelectItem value=\"middle-center\">Middle Center</SelectItem>\n                    <SelectItem value=\"middle-right\">Middle Right</SelectItem>\n                    <SelectItem value=\"bottom-left\">Bottom Left</SelectItem>\n                    <SelectItem value=\"bottom-center\">Bottom Center</SelectItem>\n                    <SelectItem value=\"bottom-right\">Bottom Right</SelectItem>\n                  </SelectContent>\n                </Select>\n                {errors.position && (\n                  <p className=\"text-red-500\">{errors.position}</p>\n                )}\n              </div>\n\n              <div className=\"w-full space-y-2\">\n                <Label htmlFor=\"watermark-rotation\">Rotation</Label>\n                <Select\n                  name=\"rotation\"\n                  value={formValues.rotation?.toString()}\n                  onValueChange={(value) => {\n                    setFormValues({\n                      ...formValues,\n                      rotation: parseInt(value) as WatermarkConfig[\"rotation\"],\n                    });\n                  }}\n                >\n                  <SelectTrigger>\n                    <SelectValue placeholder=\"Select rotation\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"0\">0°</SelectItem>\n                    <SelectItem value=\"30\">30°</SelectItem>\n                    <SelectItem value=\"45\">45°</SelectItem>\n                    <SelectItem value=\"90\">90°</SelectItem>\n                    <SelectItem value=\"180\">180°</SelectItem>\n                  </SelectContent>\n                </Select>\n                {errors.rotation && (\n                  <p className=\"text-red-500\">{errors.rotation}</p>\n                )}\n              </div>\n\n              <div className=\"w-full space-y-2\">\n                <Label htmlFor=\"watermark-color\">Text Color</Label>\n                <div className=\"ml-0.5 mr-0.5 flex space-x-1\">\n                  <Popover>\n                    <PopoverTrigger>\n                      <div\n                        className=\"h-9 w-9 cursor-pointer rounded-md shadow-sm ring-1 ring-muted-foreground hover:ring-1 hover:ring-gray-300 focus:ring-inset\"\n                        style={{ backgroundColor: formValues.color }}\n                      />\n                    </PopoverTrigger>\n                    <PopoverContent>\n                      <HexColorPicker\n                        color={formValues.color || \"\"}\n                        onChange={(value) => {\n                          setFormValues({\n                            ...formValues,\n                            color: value as WatermarkConfig[\"color\"],\n                          });\n                        }}\n                      />\n                    </PopoverContent>\n                  </Popover>\n                  <HexColorInput\n                    className=\"flex w-full rounded-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\"\n                    color={formValues.color || \"\"}\n                    onChange={(value) => {\n                      setFormValues({\n                        ...formValues,\n                        color: value as WatermarkConfig[\"color\"],\n                      });\n                    }}\n                    prefixed\n                  />\n                </div>\n                {errors.color && <p className=\"text-red-500\">{errors.color}</p>}\n              </div>\n\n              <div className=\"flex w-full space-x-4\">\n                <div className=\"w-full space-y-2\">\n                  <Label htmlFor=\"watermark-fontSize\">Font Size</Label>\n                  <Input\n                    id=\"watermark-fontSize\"\n                    type=\"number\"\n                    name=\"fontSize\"\n                    step=\"4\"\n                    value={formValues.fontSize}\n                    onChange={(e) => {\n                      setFormValues({\n                        ...formValues,\n                        fontSize: parseInt(\n                          e.target.value,\n                        ) as WatermarkConfig[\"fontSize\"],\n                      });\n                    }}\n                    className=\"focus:ring-inset\"\n                  />\n                  {errors.fontSize && (\n                    <p className=\"text-red-500\">{errors.fontSize}</p>\n                  )}\n                </div>\n                <div className=\"w-full space-y-2\">\n                  <Label htmlFor=\"watermark-opacity\">Transparency</Label>\n                  <Select\n                    name=\"opacity\"\n                    value={formValues.opacity?.toString()}\n                    onValueChange={(value) => {\n                      setFormValues({\n                        ...formValues,\n                        opacity: parseFloat(\n                          value,\n                        ) as WatermarkConfig[\"opacity\"],\n                      });\n                    }}\n                  >\n                    <SelectTrigger>\n                      <SelectValue placeholder=\"Select transparency\" />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"1\">No transparency</SelectItem>\n                      <SelectItem value=\"0.25\">75%</SelectItem>\n                      <SelectItem value=\"0.5\">50%</SelectItem>\n                      <SelectItem value=\"0.75\">25%</SelectItem>\n                    </SelectContent>\n                  </Select>\n                  {errors.opacity && (\n                    <p className=\"text-red-500\">{errors.opacity}</p>\n                  )}\n                </div>\n              </div>\n            </div>\n          </motion.div>\n        </ScrollArea>\n\n        <SheetFooter className=\"flex-shrink-0\">\n          <Button onClick={validateAndSave}>Save Watermark</Button>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/watermark-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { LinkPreset } from \"@prisma/client\";\nimport { SettingsIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { WatermarkConfig } from \"@/lib/types\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\nimport { LinkUpgradeOptions } from \"./link-options\";\nimport WatermarkConfigSheet from \"./watermark-panel\";\n\nexport default function WatermarkSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n  presets,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => void;\n  presets: LinkPreset | null;\n}) {\n  const { enableWatermark, watermarkConfig } = data;\n  const [enabled, setEnabled] = useState<boolean>(false);\n  const [isConfigOpen, setIsConfigOpen] = useState<boolean>(false);\n\n  useEffect(() => {\n    setEnabled(enableWatermark);\n  }, [enableWatermark]);\n\n  useEffect(() => {\n    if (isAllowed && presets?.enableWatermark && presets?.watermarkConfig) {\n      setEnabled(true);\n      setData((prevData) => ({\n        ...prevData,\n        enableWatermark: true,\n        watermarkConfig: presets.watermarkConfig\n          ? (JSON.parse(presets.watermarkConfig as string) as WatermarkConfig)\n          : null,\n      }));\n    }\n  }, [presets, isAllowed]);\n\n  const initialconfig: WatermarkConfig = {\n    text: watermarkConfig?.text ?? \"\",\n    isTiled: watermarkConfig?.isTiled ?? false,\n    opacity: watermarkConfig?.opacity ?? 0.5,\n    color: watermarkConfig?.color ?? \"#000000\",\n    fontSize: watermarkConfig?.fontSize ?? 24,\n    rotation: watermarkConfig?.rotation ?? 45,\n    position: watermarkConfig?.position ?? \"middle-center\",\n  };\n\n  const handleWatermarkToggle = () => {\n    const updatedWatermark = !enabled;\n\n    setData({\n      ...data,\n      enableWatermark: updatedWatermark,\n      watermarkConfig: updatedWatermark\n        ? watermarkConfig || initialconfig\n        : null,\n    });\n    setEnabled(updatedWatermark);\n  };\n\n  const handleConfigSave = (config: WatermarkConfig) => {\n    setData({\n      ...data,\n      watermarkConfig: config,\n    });\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Apply Watermark\"\n        link=\"https://www.papermark.com/help/article/document-watermark\"\n        tooltipContent=\"Add a dynamic watermark to your content.\"\n        enabled={enabled}\n        action={handleWatermarkToggle}\n        isAllowed={isAllowed}\n        requiredPlan=\"datarooms\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_watermark_section\",\n            plan: \"Data Rooms\",\n            highlightItem: [\"watermark\"],\n          })\n        }\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 space-y-3\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <div className=\"flex w-full flex-col items-start gap-6 overflow-x-visible pt-0\">\n            <div className=\"w-full space-y-2\">\n              <Label htmlFor=\"watermark-text\">Watermark Text</Label>\n              <Input\n                id=\"watermark-text\"\n                type=\"text\"\n                name=\"text\"\n                placeholder=\"e.g. Confidential {{email}} {{date}}\"\n                value={watermarkConfig?.text ?? \"\"}\n                required={enabled}\n                onChange={(e) => {\n                  setData((prevData) => ({\n                    ...prevData,\n                    watermarkConfig: {\n                      ...(prevData.watermarkConfig || initialconfig),\n                      text: e.target.value,\n                    },\n                  }));\n                }}\n                className=\"focus:ring-inset\"\n              />\n              <div className=\"space-x-1 space-y-1\">\n                {[\"email\", \"date\", \"time\", \"link\", \"ipAddress\"].map((item) => (\n                  <Button\n                    key={item}\n                    size=\"sm\"\n                    variant=\"outline\"\n                    className=\"h-7 rounded-3xl bg-muted text-sm font-normal text-foreground/80 hover:bg-muted/70\"\n                    onClick={(e) => {\n                      e.preventDefault();\n                      e.stopPropagation();\n                      setData((prevData) => ({\n                        ...prevData,\n                        watermarkConfig: {\n                          ...(prevData.watermarkConfig || initialconfig),\n                          text: `${prevData.watermarkConfig?.text || \"\"} {{${item}}}`,\n                        },\n                      }));\n                    }}\n                  >{`{{${item}}}`}</Button>\n                ))}\n              </div>\n            </div>\n          </div>\n\n          <div className=\"mt-2 flex w-full items-center justify-between\">\n            <p className=\"text-sm text-muted-foreground\">\n              {initialconfig.isTiled ? `tiled` : initialconfig.position},{\" \"}\n              {initialconfig.rotation}º, {initialconfig.fontSize}px,{\" \"}\n              {initialconfig.color.toUpperCase()},{\" \"}\n              {(1 - initialconfig.opacity) * 100}% transparent\n            </p>\n            <Button\n              onClick={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                setIsConfigOpen(true);\n              }}\n              variant=\"outline\"\n              className=\"h-8\"\n              size=\"sm\"\n            >\n              <SettingsIcon className=\"mr-2 h-4 w-4\" />\n              Configure\n            </Button>\n          </div>\n        </motion.div>\n      )}\n\n      <WatermarkConfigSheet\n        isOpen={isConfigOpen}\n        onOpenChange={setIsConfigOpen}\n        initialConfig={initialconfig}\n        onSave={handleConfigSave}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/links/link-sheet/welcome-message-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { Label } from \"@radix-ui/react-label\";\nimport { motion } from \"motion/react\";\n\nimport { FADE_IN_ANIMATION_SETTINGS } from \"@/lib/constants\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { DEFAULT_LINK_TYPE } from \".\";\nimport LinkItem from \"./link-item\";\n\nconst MAX_WELCOME_MESSAGE_LENGTH = 80;\n\nexport function WelcomeMessageSection({\n  data,\n  setData,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n}) {\n  const { welcomeMessage } = data;\n  const [enabled, setEnabled] = useState<boolean>(!!welcomeMessage);\n  const [welcomeMessageError, setWelcomeMessageError] = useState<\n    string | null\n  >(null);\n\n  useEffect(() => {\n    setEnabled(!!welcomeMessage);\n  }, [welcomeMessage]);\n\n  const handleWelcomeMessageToggle = () => {\n    const updatedEnabled = !enabled;\n    setEnabled(updatedEnabled);\n    \n    if (!updatedEnabled) {\n      // Clear the welcome message when disabled\n      setData({ ...data, welcomeMessage: null });\n      setWelcomeMessageError(null);\n    }\n  };\n\n  const validateWelcomeMessage = (message: string): boolean => {\n    if (message.length > MAX_WELCOME_MESSAGE_LENGTH) {\n      setWelcomeMessageError(\n        `Message is too long. Maximum ${MAX_WELCOME_MESSAGE_LENGTH} characters allowed.`,\n      );\n      return false;\n    }\n    setWelcomeMessageError(null);\n    return true;\n  };\n\n  const handleWelcomeMessageChange = (message: string) => {\n    validateWelcomeMessage(message);\n    setData({ ...data, welcomeMessage: message || null });\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Custom Welcome Message\"\n        tooltipContent=\"Override the default welcome message for this link\"\n        enabled={enabled}\n        action={handleWelcomeMessageToggle}\n      />\n\n      {enabled && (\n        <motion.div\n          className=\"relative mt-4 space-y-3\"\n          {...FADE_IN_ANIMATION_SETTINGS}\n        >\n          <div className=\"space-y-3\">\n            <div className=\"flex items-center justify-between\">\n              <Label htmlFor=\"welcome-message\">\n                <p className=\"block text-sm font-medium text-foreground\">\n                  Welcome Message\n                </p>\n              </Label>\n              <span className=\"text-sm text-muted-foreground\">\n                <span className={cn(welcomeMessageError && \"text-red-500\")}>\n                  {welcomeMessage?.length || 0}\n                </span>\n                /{MAX_WELCOME_MESSAGE_LENGTH}\n              </span>\n            </div>\n            <Textarea\n              id=\"welcome-message\"\n              value={welcomeMessage || \"\"}\n              onChange={(e) => handleWelcomeMessageChange(e.target.value)}\n              placeholder=\"Your action is requested to continue\"\n              className={cn(\n                \"min-h-24 resize-none\",\n                welcomeMessageError &&\n                  \"border-red-500 focus:border-red-500 focus:ring-red-500\",\n              )}\n              maxLength={MAX_WELCOME_MESSAGE_LENGTH}\n            />\n            {welcomeMessageError && (\n              <p className=\"text-xs text-red-500\">{welcomeMessageError}</p>\n            )}\n            <p className=\"text-xs text-muted-foreground\">\n              This message will override the default welcome message from your\n              branding settings for this specific link.\n            </p>\n          </div>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "components/links/links-table.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { InviteViewersModal } from \"@/ee/features/dataroom-invitations/components/invite-viewers-modal\";\nimport { invitationEmailSchema } from \"@/ee/features/dataroom-invitations/lib/schema/dataroom-invitations\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { DocumentVersion, LinkAudienceType } from \"@prisma/client\";\nimport { isWithinInterval, subMinutes } from \"date-fns\";\nimport {\n  BoxesIcon,\n  ChevronRightIcon,\n  ClockFadingIcon,\n  Code2Icon,\n  CopyCheckIcon,\n  CopyIcon,\n  CopyPlusIcon,\n  EyeIcon,\n  EyeOffIcon,\n  FileSlidersIcon,\n  LinkIcon,\n  SendIcon,\n  Settings2Icon,\n  Square,\n  SquareArrowOutUpRightIcon,\n  SquareDashedIcon,\n  TimerOffIcon,\n  Trash2Icon,\n} from \"lucide-react\";\nimport { useQueryState } from \"nuqs\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport z from \"zod\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { LinkWithViews, WatermarkConfig } from \"@/lib/types\";\nimport { cn, copyToClipboard, nFormatter, timeAgo } from \"@/lib/utils\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\n\nimport FileProcessStatusBar from \"../documents/file-process-status-bar\";\nimport BarChart from \"../shared/icons/bar-chart\";\nimport ChevronDown from \"../shared/icons/chevron-down\";\nimport MoreHorizontal from \"../shared/icons/more-horizontal\";\nimport { Badge } from \"../ui/badge\";\nimport { Label } from \"../ui/label\";\nimport { ButtonTooltip } from \"../ui/tooltip\";\nimport { useDeleteLinkModal } from \"./delete-link-modal\";\nimport EmbedCodeModal from \"./embed-code-modal\";\nimport LinkActiveControls, {\n  countActiveSettings,\n} from \"./link-active-controls\";\nimport LinkSheet, {\n  DEFAULT_LINK_PROPS,\n  type DEFAULT_LINK_TYPE,\n} from \"./link-sheet\";\nimport { DataroomLinkSheet } from \"./link-sheet/dataroom-link-sheet\";\nimport { PermissionsSheet } from \"./link-sheet/permissions-sheet\";\nimport { TagColumn } from \"./link-sheet/tags/tag-details\";\nimport LinksVisitors from \"./links-visitors\";\n\nconst isDocumentProcessing = (version?: DocumentVersion) => {\n  if (!version) return false;\n  return (\n    !version.hasPages &&\n    [\"pdf\", \"slides\", \"docs\", \"cad\"].includes(version.type!)\n  );\n};\n\n// Full URL helper\nconst getFullUrl = (link: LinkWithViews) => {\n  if (link.domainId) {\n    return `https://${link.domainSlug}/${link.slug}`;\n  }\n  return `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;\n};\n\n// Display URL helper - shows the path portion that fits the cell\nconst getDisplayUrl = (link: LinkWithViews) => {\n  if (link.domainId) {\n    return `${link.domainSlug}/${link.slug}`;\n  }\n  return `papermark.com/view/${link.id}`;\n};\n\n// Link URL cell component - displays URL with click-to-copy hover overlay\nconst LinkUrlCell = ({\n  link,\n  isFree,\n  onCopy,\n  isProcessing,\n  primaryVersion,\n  mutateDocument,\n  isPopoverOpen,\n}: {\n  link: LinkWithViews;\n  isFree: boolean;\n  onCopy: (url: string) => void;\n  isProcessing: boolean;\n  primaryVersion?: DocumentVersion;\n  mutateDocument?: () => void;\n  isPopoverOpen?: boolean;\n}) => {\n  const fullUrl = getFullUrl(link);\n  const displayUrl = getDisplayUrl(link);\n\n  return (\n    <div\n      className={cn(\n        \"group/url relative min-w-0 flex-1 cursor-pointer overflow-hidden rounded-md px-3 py-1.5 text-sm transition-all group-hover/row:ring-1 group-hover/row:ring-gray-400 group-hover/row:dark:ring-gray-100\",\n        link.domainId && isFree\n          ? \"bg-destructive/10 text-destructive hover:bg-red-700 hover:dark:bg-red-200\"\n          : \"bg-secondary text-secondary-foreground hover:bg-emerald-700 hover:dark:bg-emerald-200\",\n        isPopoverOpen && \"ring-1 ring-gray-400 dark:ring-gray-100\",\n      )}\n      onClick={() => onCopy(fullUrl)}\n    >\n      {/* Progress bar for document processing */}\n      {isProcessing && primaryVersion && (\n        <FileProcessStatusBar\n          documentVersionId={primaryVersion.id}\n          className=\"absolute bottom-0 left-0 right-0 top-0 z-20 flex h-full items-center gap-x-8\"\n          // @ts-ignore: mutateDocument is not present on datarooms but on document pages\n          mutateDocument={mutateDocument}\n        />\n      )}\n\n      {/* URL text - hidden on hover */}\n      <span\n        className=\"block overflow-hidden text-ellipsis whitespace-nowrap group-hover/url:opacity-0\"\n        style={{ direction: \"rtl\", textAlign: \"left\" }}\n      >\n        {displayUrl}\n      </span>\n      {/* Copy & Share overlay */}\n      <span className=\"absolute inset-0 z-10 hidden items-center justify-center whitespace-nowrap text-center text-sm text-primary-foreground group-hover/url:flex\">\n        Copy & Share\n      </span>\n    </div>\n  );\n};\n\n// Link actions cell component - copy, preview, edit buttons\nconst LinkActionsCell = ({\n  link,\n  onCopy,\n  onPreview,\n  isProcessing,\n}: {\n  link: LinkWithViews;\n  onCopy: (url: string) => void;\n  onPreview: (link: LinkWithViews) => void;\n  isProcessing: boolean;\n}) => {\n  const [copied, setCopied] = useState(false);\n  const fullUrl = getFullUrl(link);\n\n  useEffect(() => {\n    if (copied) {\n      const timeout = setTimeout(() => setCopied(false), 2000);\n      return () => clearTimeout(timeout);\n    }\n  }, [copied]);\n\n  const handleCopy = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    e.preventDefault();\n    onCopy(fullUrl);\n    setCopied(true);\n  };\n\n  const handlePreview = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    e.preventDefault();\n    onPreview(link);\n  };\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      <ButtonTooltip content={copied ? \"Copied!\" : \"Copy link\"}>\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-8 w-8 transition-colors hover:text-foreground group-hover/link:bg-emerald-500/10 group-hover/link:hover:bg-emerald-500/20\"\n          onClick={handleCopy}\n        >\n          {copied ? (\n            <CopyCheckIcon className=\"h-4 w-4 text-emerald-500\" />\n          ) : (\n            <CopyIcon className=\"h-4 w-4 text-muted-foreground transition-colors hover:text-foreground group-hover/link:text-emerald-500\" />\n          )}\n        </Button>\n      </ButtonTooltip>\n      <ButtonTooltip\n        content={isProcessing ? \"Preparing preview\" : \"Preview link\"}\n      >\n        <Button\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-8 w-8 hover:text-foreground\"\n          onClick={handlePreview}\n          disabled={isProcessing}\n        >\n          {isProcessing ? (\n            <SquareDashedIcon className=\"h-4 w-4 text-muted-foreground\" />\n          ) : (\n            <SquareArrowOutUpRightIcon className=\"h-4 w-4 text-muted-foreground hover:text-foreground\" />\n          )}\n        </Button>\n      </ButtonTooltip>\n    </div>\n  );\n};\n\nexport default function LinksTable({\n  targetType,\n  links,\n  primaryVersion,\n  mutateDocument,\n  dataroomName,\n}: {\n  targetType: \"DOCUMENT\" | \"DATAROOM\";\n  links?: LinkWithViews[];\n  primaryVersion?: DocumentVersion;\n  mutateDocument?: () => void;\n  dataroomName?: string;\n}) {\n  const [tags, _] = useQueryState<string[]>(\"tags\", {\n    parse: (value: string) => value.split(\",\").filter(Boolean),\n    serialize: (value: string[]) => value.join(\",\"),\n  });\n\n  const selectedTagNames = useMemo(() => tags ?? [], [tags]);\n\n  const now = Date.now();\n  const router = useRouter();\n  const { isFree, isTrial } = usePlan();\n  const { currentTeamId } = useTeam();\n  const { id: targetId, groupId } = router.query as {\n    id: string;\n    groupId?: string;\n  };\n\n  const { isMobile } = useMediaQuery();\n  const { isFeatureEnabled } = useFeatureFlags();\n\n  let processedLinks = useMemo(() => {\n    if (!links?.length) return [];\n\n    const oneMinuteAgo = subMinutes(now, 1);\n    const sortedLinks = links.sort(\n      (a, b) =>\n        new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n    );\n\n    return sortedLinks.map((link) => {\n      const createdDate = new Date(link.createdAt);\n      const updatedDate = new Date(link.updatedAt);\n\n      return {\n        ...link,\n        isNew: isWithinInterval(createdDate, {\n          start: oneMinuteAgo,\n          end: now,\n        }),\n        isUpdated:\n          isWithinInterval(updatedDate, {\n            start: oneMinuteAgo,\n            end: now,\n          }) && updatedDate.getTime() !== createdDate.getTime(),\n      };\n    });\n  }, [links, now]);\n\n  processedLinks = useMemo(() => {\n    if (!links?.length) return [];\n    return processedLinks.filter((link) => {\n      if (selectedTagNames.length === 0) return true;\n      return link.tags.some((tag) => selectedTagNames.includes(tag.name));\n    });\n  }, [links, processedLinks, selectedTagNames]);\n\n  const { canAddLinks } = useLimits();\n\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [loadingLinks, setLoadingLinks] = useState<Set<string>>(new Set());\n  const [isLinkSheetVisible, setIsLinkSheetVisible] = useState<boolean>(false);\n  const [selectedLink, setSelectedLink] = useState<DEFAULT_LINK_TYPE>(\n    DEFAULT_LINK_PROPS(`${targetType}_LINK`, groupId),\n  );\n  const [embedModalOpen, setEmbedModalOpen] = useState(false);\n  const [selectedEmbedLink, setSelectedEmbedLink] = useState<{\n    id: string;\n    name: string;\n  } | null>(null);\n  const [popoverOpen, setPopoverOpen] = useState<string | null>(null);\n  const hoverTimeout = useRef<NodeJS.Timeout | null>(null);\n\n  const [showPermissionsSheet, setShowPermissionsSheet] =\n    useState<boolean>(false);\n  const [editPermissionLink, setEditPermissionLink] =\n    useState<LinkWithViews | null>(null);\n\n  const [linkToDelete, setLinkToDelete] = useState<LinkWithViews | null>(null);\n  const { setShowDeleteLinkModal, DeleteLinkModal } = useDeleteLinkModal({\n    link: linkToDelete,\n    targetType,\n  });\n  const [isInviteModalOpen, setIsInviteModalOpen] = useState<boolean>(false);\n  const [inviteLink, setInviteLink] = useState<LinkWithViews | null>(null);\n  const [inviteDefaultEmails, setInviteDefaultEmails] = useState<string[]>([]);\n\n  const linksApiRoute =\n    currentTeamId && targetId\n      ? groupId\n        ? `/api/teams/${currentTeamId}/datarooms/${targetId}/groups/${groupId}/links`\n        : `/api/teams/${currentTeamId}/datarooms/${targetId}/links`\n      : null;\n\n  const dataroomDisplayName = dataroomName ?? \"this dataroom\";\n\n  const handleCopyToClipboard = (linkString: string) => {\n    copyToClipboard(`${linkString}`, \"Link copied to clipboard.\");\n  };\n\n  const handleEditLink = (link: LinkWithViews) => {\n    setSelectedLink({\n      id: link.id,\n      name: link.name || `Link #${link.id.slice(-5)}`,\n      domain: link.domainSlug,\n      slug: link.slug,\n      expiresAt: link.expiresAt,\n      password: link.password,\n      emailProtected: link.emailProtected,\n      emailAuthenticated: link.emailAuthenticated,\n      allowDownload: link.allowDownload ? link.allowDownload : false,\n      allowList: link.allowList,\n      denyList: link.denyList,\n      visitorGroupIds:\n        link.visitorGroups?.map(\n          (vg: { visitorGroupId: string }) => vg.visitorGroupId,\n        ) || [],\n      enableNotification: link.enableNotification\n        ? link.enableNotification\n        : false,\n      enableFeedback: link.enableFeedback ? link.enableFeedback : false,\n      enableScreenshotProtection: link.enableScreenshotProtection\n        ? link.enableScreenshotProtection\n        : false,\n      enableCustomMetatag: link.enableCustomMetatag\n        ? link.enableCustomMetatag\n        : false,\n      enableQuestion: link.enableQuestion ? link.enableQuestion : false,\n      questionText: link.feedback ? link.feedback.data?.question : \"\",\n      questionType: link.feedback ? link.feedback.data?.type : \"\",\n      metaTitle: link.metaTitle,\n      metaDescription: link.metaDescription,\n      metaImage: link.metaImage,\n      metaFavicon: link.metaFavicon,\n      enableAgreement: link.enableAgreement ? link.enableAgreement : false,\n      agreementId: link.agreementId,\n      showBanner: link.showBanner ?? false,\n      enableWatermark: link.enableWatermark ?? false,\n      watermarkConfig: link.watermarkConfig as WatermarkConfig | null,\n      audienceType: link.audienceType,\n      groupId: link.groupId,\n      customFields: link.customFields || [],\n      tags: link.tags.map((tag) => tag.id) || [],\n      enableConversation: link.enableConversation ?? false,\n      enableUpload: link.enableUpload ?? false,\n      isFileRequestOnly: link.isFileRequestOnly ?? false,\n      uploadFolderId: link.uploadFolderId ?? null,\n      uploadFolderName: link.uploadFolderName ?? \"Home\",\n      enableIndexFile: link.enableIndexFile ?? false,\n      permissionGroupId: link.permissionGroupId ?? null,\n      welcomeMessage: link.welcomeMessage ?? null,\n      enableAIAgents: link.enableAIAgents ?? false,\n    });\n    //wait for dropdown to close before opening the link sheet\n    setTimeout(() => {\n      setIsLinkSheetVisible(true);\n    }, 0);\n  };\n\n  const handlePreviewLink = async (link: LinkWithViews) => {\n    if (link.domainId && isFree) {\n      toast.error(\"You need to upgrade to preview this link\");\n      return;\n    }\n\n    if (isDocumentProcessing(primaryVersion)) {\n      toast.error(\n        \"Document is still processing. Please wait a moment and try again.\",\n      );\n      return;\n    }\n\n    const response = await fetch(`/api/links/${link.id}/preview`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    });\n\n    if (!response.ok) {\n      toast.error(\"Failed to generate preview link\");\n      return;\n    }\n\n    const { previewToken } = await response.json();\n    const previewLink = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}?previewToken=${previewToken}`;\n\n    window.open(previewLink, \"_blank\");\n  };\n\n  const handleDuplicateLink = async (link: LinkWithViews) => {\n    setIsLoading(true);\n\n    const response = await fetch(`/api/links/${link.id}/duplicate`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        teamId: currentTeamId,\n      }),\n    });\n\n    if (!response.ok) {\n      throw new Error(`HTTP error! status: ${response.status}`);\n    }\n\n    const duplicatedLink = await response.json();\n    const endpointTargetType = `${targetType.toLowerCase()}s`; // \"documents\" or \"datarooms\"\n\n    // Update the duplicated link in the list of links\n    mutate(\n      `/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(\n        link.documentId ?? link.dataroomId ?? \"\",\n      )}/links`,\n      (links || []).concat(duplicatedLink),\n      false,\n    );\n\n    // Update the group-specific links cache if this is a group link\n    if (!!groupId) {\n      const groupLinks =\n        links?.filter((link) => link.groupId === groupId) || [];\n      mutate(\n        `/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(\n          duplicatedLink.documentId ?? duplicatedLink.dataroomId ?? \"\",\n        )}/groups/${duplicatedLink.groupId}/links`,\n        groupLinks.concat(duplicatedLink),\n        false,\n      );\n    }\n\n    toast.success(\"Link duplicated successfully\");\n    setIsLoading(false);\n  };\n\n  const handleSendInvitations = (link: LinkWithViews) => {\n    if (targetType !== \"DATAROOM\") {\n      return;\n    }\n\n    const sanitizedEmails = Array.from(\n      new Set(\n        (link.allowList ?? []).filter(\n          (value) => invitationEmailSchema.safeParse(value).success,\n        ),\n      ),\n    );\n\n    setInviteDefaultEmails(sanitizedEmails);\n    setInviteLink(link);\n    setIsInviteModalOpen(true);\n  };\n\n  const handleEditPermissions = (link: LinkWithViews) => {\n    setEditPermissionLink(link);\n    setShowPermissionsSheet(true);\n  };\n\n  const handlePermissionsSave = async (permissions: any) => {\n    if (!editPermissionLink) return;\n\n    // Handle the case where user wants to share entire dataroom (permissions === null)\n    if (permissions === null && editPermissionLink.permissionGroupId) {\n      // Delete the permission group - database will set permissionGroupId to null automatically\n      try {\n        const teamIdParsed = z.string().cuid().parse(currentTeamId);\n        const targetIdParsed = z.string().cuid().parse(targetId);\n        const permissionGroupIdParsed = z\n          .string()\n          .cuid()\n          .parse(editPermissionLink.permissionGroupId);\n\n        const deleteResponse = await fetch(\n          `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,\n          {\n            method: \"DELETE\",\n          },\n        );\n\n        if (!deleteResponse.ok) {\n          const { error } = await deleteResponse.json();\n          throw new Error(error ?? \"Failed to delete permission group\");\n        }\n\n        // Refresh the links cache\n        const endpointTargetType = `${targetType.toLowerCase()}s`;\n        mutate(\n          `/api/teams/${teamIdParsed}/${endpointTargetType}/${encodeURIComponent(\n            targetIdParsed,\n          )}/links`,\n          (currentLinks: LinkWithViews[] | undefined) =>\n            (currentLinks || []).map((link: LinkWithViews) =>\n              link.id === editPermissionLink.id\n                ? { ...link, permissionGroupId: null }\n                : link,\n            ),\n          false,\n        );\n\n        // Invalidate the permission group cache\n        mutate(\n          `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,\n        );\n\n        setShowPermissionsSheet(false);\n        setEditPermissionLink(null);\n        toast.success(\"File permissions updated successfully\");\n      } catch (error) {\n        console.error(\"Error updating file permissions:\", error);\n        toast.error(\"Failed to update file permissions\");\n      }\n      return;\n    }\n\n    if (!editPermissionLink.permissionGroupId) {\n      setIsLoading(true);\n      try {\n        const teamIdParsed = z.string().cuid().parse(currentTeamId);\n        const targetIdParsed = z.string().cuid().parse(targetId);\n        const response = await fetch(\n          `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              permissions: permissions,\n              linkId: editPermissionLink.id,\n            }),\n          },\n        );\n\n        if (!response.ok) {\n          const error = await response.json();\n          throw new Error(error.error || \"Failed to create permission group\");\n        }\n\n        const { permissionGroup: newPermissionGroup, _ } =\n          await response.json();\n\n        // Refresh the links cache\n        const endpointTargetType = `${targetType.toLowerCase()}s`;\n        mutate(\n          `/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(\n            targetId,\n          )}/links`,\n        );\n\n        // Cache the new permission group data\n        if (newPermissionGroup?.id) {\n          mutate(\n            `/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,\n            newPermissionGroup,\n            false,\n          );\n        }\n\n        setShowPermissionsSheet(false);\n        setEditPermissionLink(null);\n        toast.success(\"File permissions updated successfully\");\n      } catch (error) {\n        console.error(\"Error creating permission group:\", error);\n        toast.error(\"Failed to create permission group\");\n      } finally {\n        setIsLoading(false);\n      }\n    } else {\n      try {\n        // Update the permissions for the existing link\n        const teamIdParsed = z.string().cuid().parse(currentTeamId);\n        const targetIdParsed = z.string().cuid().parse(targetId);\n        const permissionGroupIdParsed = z\n          .string()\n          .cuid()\n          .parse(editPermissionLink.permissionGroupId);\n\n        const res = await fetch(\n          `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,\n          {\n            method: \"PUT\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              permissions: permissions,\n              linkId: editPermissionLink.id,\n            }),\n          },\n        );\n\n        if (!res.ok) {\n          const { error } = await res.json();\n          throw new Error(error ?? \"Failed to update permissions\");\n        }\n\n        // Refresh the links cache\n        const endpointTargetType = `${targetType.toLowerCase()}s`;\n        mutate(\n          `/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(\n            targetId,\n          )}/links`,\n        );\n\n        // Invalidate the permission group cache\n        if (editPermissionLink.permissionGroupId) {\n          mutate(\n            `/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,\n          );\n        }\n\n        setShowPermissionsSheet(false);\n        setEditPermissionLink(null);\n        toast.success(\"File permissions updated successfully\");\n      } catch (error) {\n        console.error(\"Error updating file permissions:\", error);\n        toast.error(\"Failed to update file permissions\");\n      }\n    }\n  };\n\n  const AddLinkButton = () => {\n    if (!canAddLinks) {\n      return (\n        <UpgradePlanModal\n          clickedPlan={isTrial ? PlanEnum.Business : PlanEnum.Pro}\n          trigger={\"limit_add_link\"}\n        >\n          <Button>Upgrade to Create Link</Button>\n        </UpgradePlanModal>\n      );\n    } else {\n      return (\n        <Button onClick={() => setIsLinkSheetVisible(true)}>\n          Create link to share\n        </Button>\n      );\n    }\n  };\n\n  const handleArchiveLink = async (\n    linkId: string,\n    targetId: string,\n    isArchived: boolean,\n  ) => {\n    setLoadingLinks((prev) => new Set(prev).add(linkId));\n\n    try {\n      const response = await fetch(`/api/links/${linkId}/archive`, {\n        method: \"PUT\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          isArchived: !isArchived,\n        }),\n      });\n\n      if (!response.ok) {\n        throw new Error(`HTTP error! status: ${response.status}`);\n      }\n\n      const archivedLink = await response.json();\n      const endpointTargetType = `${targetType.toLowerCase()}s`; // \"documents\" or \"datarooms\"\n\n      // Update the archived link in the list of links\n      mutate(\n        `/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(\n          targetId,\n        )}/links`,\n        (links || []).map((link) => (link.id === linkId ? archivedLink : link)),\n        false,\n      );\n\n      // Update the group-specific links cache if this is a group link\n      if (!!groupId) {\n        const groupLinks =\n          links?.filter((link) => link.groupId === groupId) || [];\n        mutate(\n          `/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(\n            archivedLink.documentId ?? archivedLink.dataroomId ?? \"\",\n          )}/groups/${groupId}/links`,\n          groupLinks.map((link) => (link.id === linkId ? archivedLink : link)),\n          false,\n        );\n      }\n\n      toast.success(\n        !isArchived\n          ? \"Link successfully archived\"\n          : \"Link successfully reactivated\",\n      );\n    } catch (error) {\n      console.error(\"Error archiving link:\", error);\n      toast.error(\"Failed to update link status\");\n    } finally {\n      setLoadingLinks((prev) => {\n        const newSet = new Set(prev);\n        newSet.delete(linkId);\n        return newSet;\n      });\n    }\n  };\n\n  const hasAnyTags = useMemo(\n    () =>\n      processedLinks.reduce(\n        (acc, link) => acc || (link?.tags && link.tags.length > 0),\n        false,\n      ),\n    [processedLinks],\n  );\n\n  // Collapsible state for \"All Links\" section (document pages only)\n  const ALL_LINKS_COLLAPSED_KEY = \"papermark-all-links-collapsed\";\n  const [isAllLinksOpen, setIsAllLinksOpen] = useState<boolean>(true);\n\n  // Load collapse state from localStorage on mount\n  useEffect(() => {\n    if (targetType !== \"DOCUMENT\") return;\n    try {\n      const stored = localStorage.getItem(ALL_LINKS_COLLAPSED_KEY);\n      if (stored !== null) {\n        // stored value is \"true\" if collapsed, so we invert for isOpen\n        setIsAllLinksOpen(stored !== \"true\");\n      }\n    } catch (e) {\n      // localStorage might be unavailable\n      console.warn(\"Could not read from localStorage:\", e);\n    }\n  }, [targetType]);\n\n  // Handle toggle and persist to localStorage\n  const handleAllLinksToggle = useCallback((open: boolean) => {\n    setIsAllLinksOpen(open);\n    try {\n      // Store \"true\" when collapsed, \"false\" when expanded\n      localStorage.setItem(ALL_LINKS_COLLAPSED_KEY, String(!open));\n    } catch (e) {\n      console.warn(\"Could not write to localStorage:\", e);\n    }\n  }, []);\n\n  const isDataroom = targetType === \"DATAROOM\";\n\n  const linksTableContent = (\n    <div className=\"rounded-md border\">\n      <Table>\n        <TableHeader>\n          <TableRow className=\"*:whitespace-nowrap *:font-medium hover:bg-transparent\">\n            <TableHead>Name</TableHead>\n            <TableHead>Link</TableHead>\n            {hasAnyTags ? (\n              <TableHead className=\"w-[250px] 2xl:w-auto\">Tags</TableHead>\n            ) : null}\n            <TableHead className=\"w-[250px] sm:w-auto\">Views</TableHead>\n            <TableHead>Last Viewed</TableHead>\n            <TableHead className=\"w-[80px]\">Active</TableHead>\n            <TableHead className=\"text-center sm:text-right\"></TableHead>\n          </TableRow>\n        </TableHeader>\n        <TableBody>\n          {processedLinks && processedLinks.length > 0 ? (\n            processedLinks.map((link) => (\n              <Collapsible key={link.id} asChild>\n                <>\n                  <TableRow\n                    key={link.id}\n                    className={cn(\n                      \"group/row\",\n                      popoverOpen === link.id && \"bg-gray-100\",\n                      link.isArchived &&\n                        \"bg-gray-50 opacity-50 dark:bg-gray-700\",\n                    )}\n                  >\n                    <TableCell className=\"w-[200px] truncate font-medium md:w-[220px] lg:w-[250px] xl:w-[280px] 2xl:w-[350px]\">\n                      <div className=\"flex items-center gap-x-2\">\n                        {link.groupId ? (\n                          <ButtonTooltip content=\"Group Link\">\n                            <BoxesIcon className=\"size-4 shrink-0\" />\n                          </ButtonTooltip>\n                        ) : null}\n                        <ButtonTooltip\n                          content={link.name || `Link #${link.id.slice(-5)}`}\n                        >\n                          <span className=\"max-w-[150px] truncate md:max-w-[170px] lg:max-w-[200px] xl:max-w-[230px] 2xl:max-w-[300px]\">\n                            {link.name || `Link #${link.id.slice(-5)}`}\n                          </span>\n                        </ButtonTooltip>\n                        {link.isNew && !link.isUpdated && (\n                          <Badge\n                            variant=\"outline\"\n                            className=\"border-emerald-600/80 text-emerald-600/80\"\n                          >\n                            New\n                          </Badge>\n                        )}\n                        {link.isUpdated && (\n                          <Badge\n                            variant=\"outline\"\n                            className=\"border-blue-500/80 text-blue-500/80\"\n                          >\n                            Updated\n                          </Badge>\n                        )}\n                        {link.expiresAt &&\n                          (new Date(link.expiresAt) < new Date() ? (\n                            <TimestampTooltip\n                              timestamp={link.expiresAt}\n                              side=\"right\"\n                              rows={[\"local\", \"utc\"]}\n                              title=\"Expired at\"\n                              fullLabels\n                            >\n                              <span className=\"flex cursor-default items-center rounded-full bg-destructive/10 p-1 text-destructive ring-1 ring-destructive/20\">\n                                <TimerOffIcon className=\"h-3 w-3\" />\n                              </span>\n                            </TimestampTooltip>\n                          ) : (\n                            <TimestampTooltip\n                              timestamp={link.expiresAt}\n                              side=\"right\"\n                              rows={[\"local\", \"utc\"]}\n                              title=\"Expires at\"\n                              fullLabels\n                            >\n                              <span className=\"flex cursor-default items-center rounded-full bg-orange-100 p-1 text-orange-700 ring-1 ring-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:ring-orange-800\">\n                                <ClockFadingIcon className=\"h-3 w-3\" />\n                              </span>\n                            </TimestampTooltip>\n                          ))}\n                        {link.domainId && isFree ? (\n                          <span className=\"ml-2 rounded-full bg-destructive px-2.5 py-0.5 text-xs text-foreground ring-1 ring-destructive\">\n                            Inactive\n                          </span>\n                        ) : null}\n                      </div>\n                    </TableCell>\n                    {/* Link URL Cell */}\n                    <TableCell className=\"group/link max-w-[250px] pr-1 md:max-w-[300px] lg:max-w-[350px] xl:max-w-[400px] 2xl:max-w-[450px]\">\n                      <div className=\"flex flex-row gap-x-1\">\n                        <LinkUrlCell\n                          link={link}\n                          isFree={isFree}\n                          onCopy={handleCopyToClipboard}\n                          isProcessing={isDocumentProcessing(primaryVersion)}\n                          primaryVersion={primaryVersion}\n                          mutateDocument={mutateDocument}\n                          isPopoverOpen={popoverOpen === link.id}\n                        />\n                        <div className=\"flex shrink-0 items-center\">\n                          <LinkActionsCell\n                            link={link}\n                            onCopy={handleCopyToClipboard}\n                            onPreview={handlePreviewLink}\n                            isProcessing={isDocumentProcessing(primaryVersion)}\n                          />\n                          {isMobile ? (\n                            <ButtonTooltip content=\"Edit link\">\n                              <Button\n                                variant=\"link\"\n                                size=\"icon\"\n                                className=\"group h-8 w-8\"\n                                onClick={() => handleEditLink(link)}\n                              >\n                                <span className=\"sr-only\">Edit link</span>\n                                <Settings2Icon className=\"text-gray-400 group-hover:text-gray-500\" />\n                              </Button>\n                            </ButtonTooltip>\n                          ) : (\n                            <Popover\n                              open={popoverOpen === link.id}\n                              onOpenChange={() => {}}\n                            >\n                              <PopoverTrigger asChild>\n                                <Button\n                                  variant=\"link\"\n                                  className={cn(\n                                    \"h-8 w-8 font-normal hover:no-underline focus-visible:ring-0 focus-visible:ring-offset-0\",\n                                    popoverOpen === link.id\n                                      ? \"text-foreground\"\n                                      : \"text-muted-foreground hover:text-foreground\",\n                                  )}\n                                  size=\"sm\"\n                                  onClick={(e) => {\n                                    e.preventDefault();\n                                    handleEditLink(link);\n                                  }}\n                                  onMouseDown={(e) => e.preventDefault()}\n                                  onMouseEnter={() => {\n                                    hoverTimeout.current = setTimeout(\n                                      () => setPopoverOpen(link.id),\n                                      250,\n                                    );\n                                  }}\n                                  onMouseLeave={() => {\n                                    if (hoverTimeout.current)\n                                      clearTimeout(hoverTimeout.current);\n                                    hoverTimeout.current = setTimeout(\n                                      () => setPopoverOpen(null),\n                                      100,\n                                    );\n                                  }}\n                                >\n                                  <Settings2Icon />\n                                </Button>\n                              </PopoverTrigger>\n                              <PopoverContent\n                                side=\"top\"\n                                align=\"start\"\n                                className=\"w-56 p-0\"\n                                onMouseEnter={() => {\n                                  if (hoverTimeout.current)\n                                    clearTimeout(hoverTimeout.current);\n                                  setPopoverOpen(link.id);\n                                }}\n                                onMouseLeave={() => {\n                                  if (hoverTimeout.current)\n                                    clearTimeout(hoverTimeout.current);\n                                  hoverTimeout.current = setTimeout(\n                                    () => setPopoverOpen(null),\n                                    100,\n                                  );\n                                }}\n                              >\n                                <LinkActiveControls\n                                  link={link}\n                                  onEditClick={(e) => {\n                                    e.preventDefault();\n                                    handleEditLink(link);\n                                  }}\n                                />\n                              </PopoverContent>\n                            </Popover>\n                          )}\n                          {/* File permissions icon (dataroom only) */}\n                          {isDataroom && (\n                            <div className=\"flex w-8 items-center justify-center\">\n                              {link.permissionGroupId && (\n                                <ButtonTooltip content=\"Limited File Access\">\n                                  <Button\n                                    variant=\"ghost\"\n                                    size=\"icon\"\n                                    className=\"h-8 w-8 cursor-default\"\n                                  >\n                                    <FileSlidersIcon className=\"h-4 w-4 text-muted-foreground hover:text-foreground\" />\n                                  </Button>\n                                </ButtonTooltip>\n                              )}\n                            </div>\n                          )}\n                        </div>\n                      </div>\n                    </TableCell>\n                    {hasAnyTags ? (\n                      <TableCell className=\"w-[250px] 2xl:w-auto\">\n                        <TagColumn link={link} />\n                      </TableCell>\n                    ) : null}\n                    <TableCell>\n                      <CollapsibleTrigger\n                        disabled={isDataroom || link._count.views === 0}\n                      >\n                        <div className=\"flex items-center space-x-1 [&[data-state=open]>svg.chevron]:rotate-180\">\n                          <BarChart className=\"h-4 w-4 text-muted-foreground\" />\n                          <p className=\"whitespace-nowrap text-sm text-muted-foreground\">\n                            {nFormatter(link._count.views)}\n                            <span className=\"ml-1 hidden sm:inline-block\">\n                              views\n                            </span>\n                          </p>\n                          {!isDataroom && link._count.views > 0 ? (\n                            <ChevronDown className=\"chevron h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200\" />\n                          ) : null}\n                        </div>\n                      </CollapsibleTrigger>\n                    </TableCell>\n                    <TableCell className=\"text-sm text-muted-foreground\">\n                      {link.views[0] ? (\n                        <TimestampTooltip\n                          timestamp={link.views[0].viewedAt}\n                          side=\"right\"\n                          rows={[\"local\", \"utc\", \"unix\"]}\n                        >\n                          <time\n                            className=\"select-none\"\n                            dateTime={new Date(\n                              link.views[0].viewedAt,\n                            ).toISOString()}\n                          >\n                            {timeAgo(link.views[0].viewedAt)}\n                          </time>\n                        </TimestampTooltip>\n                      ) : (\n                        \"-\"\n                      )}\n                    </TableCell>\n                    <TableCell className=\"text-center\">\n                      <div className=\"flex items-center justify-center gap-x-1\">\n                        <Switch\n                          className=\"data-[state=checked]:bg-primary/80 data-[state=checked]:hover:bg-primary data-[state=unchecked]:hover:bg-muted-foreground/80\"\n                          id={`${link.id}-active-switch`}\n                          checked={!link.isArchived}\n                          onCheckedChange={(checked) =>\n                            handleArchiveLink(\n                              link.id,\n                              link.documentId ?? link.dataroomId ?? \"\",\n                              checked,\n                            )\n                          }\n                          disabled={loadingLinks.has(link.id)}\n                        />\n                        <Label\n                          className=\"font-normal\"\n                          htmlFor={`${link.id}-active-switch`}\n                        >\n                          {link.isArchived ? \"No\" : \"Yes\"}\n                        </Label>\n                      </div>\n                    </TableCell>\n                    <TableCell className=\"text-center sm:text-right\">\n                      <DropdownMenu>\n                        <DropdownMenuTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            className=\"h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700\"\n                          >\n                            <span className=\"sr-only\">Open menu</span>\n                            <MoreHorizontal className=\"h-4 w-4\" />\n                          </Button>\n                        </DropdownMenuTrigger>\n                        <DropdownMenuContent align=\"end\">\n                          <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                          <DropdownMenuSeparator />\n                          <DropdownMenuItem\n                            onClick={() => handleEditLink(link)}\n                          >\n                            <Settings2Icon className=\"mr-2 h-4 w-4\" />\n                            Edit Link\n                          </DropdownMenuItem>\n                          {/* Dataroom-only: Edit File Permissions */}\n                          {isDataroom &&\n                            link.audienceType !== LinkAudienceType.GROUP && (\n                              <DropdownMenuItem\n                                onClick={() => handleEditPermissions(link)}\n                                disabled={isLoading}\n                              >\n                                <FileSlidersIcon className=\"mr-2 h-4 w-4\" />\n                                Edit File Permissions\n                              </DropdownMenuItem>\n                            )}\n                          <DropdownMenuItem\n                            onClick={() => handlePreviewLink(link)}\n                          >\n                            <EyeIcon className=\"mr-2 h-4 w-4\" />\n                            Preview Link\n                          </DropdownMenuItem>\n                          {/* Dataroom-only: Send Invitations */}\n                          {isDataroom &&\n                            isFeatureEnabled(\"dataroomInvitations\") && (\n                              <DropdownMenuItem\n                                onClick={() => handleSendInvitations(link)}\n                              >\n                                <SendIcon className=\"mr-2 h-4 w-4\" />\n                                Send Invitations\n                              </DropdownMenuItem>\n                            )}\n                          <DropdownMenuItem\n                            disabled={!canAddLinks}\n                            onClick={() => handleDuplicateLink(link)}\n                          >\n                            <CopyPlusIcon className=\"mr-2 h-4 w-4\" />\n                            Duplicate Link\n                          </DropdownMenuItem>\n                          <DropdownMenuItem\n                            onClick={() => {\n                              setSelectedEmbedLink({\n                                id: link.id,\n                                name: link.name || `Link #${link.id.slice(-5)}`,\n                              });\n                              setEmbedModalOpen(true);\n                            }}\n                          >\n                            <Code2Icon className=\"mr-2 h-4 w-4\" />\n                            Get Embed Code\n                          </DropdownMenuItem>\n                          {!isFree && (\n                            <>\n                              <DropdownMenuSeparator />\n                              <DropdownMenuItem\n                                onClick={() => {\n                                  setLinkToDelete(link);\n                                  setShowDeleteLinkModal(true);\n                                }}\n                                className=\"text-destructive focus:bg-destructive focus:text-destructive-foreground\"\n                              >\n                                <Trash2Icon className=\"mr-2 h-4 w-4\" />\n                                Delete Link\n                              </DropdownMenuItem>\n                            </>\n                          )}\n                        </DropdownMenuContent>\n                      </DropdownMenu>\n                    </TableCell>\n                  </TableRow>\n                  <CollapsibleContent asChild>\n                    <LinksVisitors\n                      linkName={link.name || \"No link name\"}\n                      linkId={link.id}\n                    />\n                  </CollapsibleContent>\n                </>\n              </Collapsible>\n            ))\n          ) : (\n            <TableRow>\n              <TableCell colSpan={hasAnyTags ? 7 : 6}>\n                <div className=\"flex w-full flex-col items-center justify-center gap-4 rounded-xl py-4\">\n                  <div className=\"hidden rounded-full sm:block\">\n                    <div\n                      className={cn(\n                        \"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3\",\n                      )}\n                    >\n                      <LinkIcon className=\"size-6\" />\n                    </div>\n                  </div>\n                  <p>No links found for this {targetType.toLowerCase()}</p>\n                  <AddLinkButton />\n                </div>\n              </TableCell>\n            </TableRow>\n          )}\n        </TableBody>\n      </Table>\n    </div>\n  );\n\n  return (\n    <>\n      <div className=\"w-full\">\n        {/* Collapsible wrapper for DOCUMENT type, plain div for DATAROOM */}\n        {targetType === \"DOCUMENT\" ? (\n          <Collapsible\n            open={isAllLinksOpen}\n            onOpenChange={handleAllLinksToggle}\n            className=\"w-full\"\n          >\n            <CollapsibleTrigger asChild>\n              <button\n                type=\"button\"\n                className=\"mb-2 flex w-full cursor-pointer items-center gap-2 text-left md:mb-4\"\n              >\n                <ChevronRightIcon\n                  className={cn(\n                    \"h-5 w-5 text-muted-foreground transition-transform duration-200\",\n                    isAllLinksOpen && \"rotate-90\",\n                  )}\n                />\n                <h2 className=\"m-0\">All links</h2>\n                {processedLinks && processedLinks.length > 0 && (\n                  <Badge variant=\"outline\" className=\"text-muted-foreground\">\n                    {processedLinks.length}\n                  </Badge>\n                )}\n              </button>\n            </CollapsibleTrigger>\n            <CollapsibleContent className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\">\n              {linksTableContent}\n            </CollapsibleContent>\n          </Collapsible>\n        ) : (\n          linksTableContent\n        )}\n\n        {targetType === \"DATAROOM\" ? (\n          <>\n            <DataroomLinkSheet\n              isOpen={isLinkSheetVisible}\n              setIsOpen={setIsLinkSheetVisible}\n              linkType={`${targetType}_LINK`}\n              currentLink={selectedLink.id ? selectedLink : undefined}\n              existingLinks={links}\n            />\n\n            <PermissionsSheet\n              isOpen={showPermissionsSheet}\n              setIsOpen={(open: boolean) => {\n                setShowPermissionsSheet(open);\n                if (!open) {\n                  setEditPermissionLink(null);\n                }\n              }}\n              dataroomId={targetId}\n              linkId={editPermissionLink?.id}\n              permissionGroupId={editPermissionLink?.permissionGroupId}\n              onSave={handlePermissionsSave}\n            />\n            {inviteLink && isFeatureEnabled(\"dataroomInvitations\") ? (\n              <InviteViewersModal\n                open={isInviteModalOpen}\n                setOpen={(open) => {\n                  setIsInviteModalOpen(open);\n                  if (!open) {\n                    setInviteLink(null);\n                  }\n                }}\n                dataroomId={targetId}\n                dataroomName={dataroomDisplayName}\n                groupId={inviteLink.groupId ?? undefined}\n                linkId={inviteLink.id}\n                defaultEmails={inviteDefaultEmails}\n                onSuccess={() => {\n                  if (linksApiRoute) {\n                    mutate(linksApiRoute);\n                  }\n                  setInviteLink(null);\n                }}\n              />\n            ) : null}\n          </>\n        ) : (\n          <LinkSheet\n            isOpen={isLinkSheetVisible}\n            setIsOpen={setIsLinkSheetVisible}\n            linkType={`${targetType}_LINK`}\n            currentLink={selectedLink.id ? selectedLink : undefined}\n            existingLinks={links}\n          />\n        )}\n\n        {selectedEmbedLink && (\n          <EmbedCodeModal\n            isOpen={embedModalOpen}\n            setIsOpen={setEmbedModalOpen}\n            linkId={selectedEmbedLink.id}\n            linkName={selectedEmbedLink.name}\n          />\n        )}\n\n        <DeleteLinkModal />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/links/links-visitors.tsx",
    "content": "import Link from \"next/link\";\n\nimport { AlertTriangleIcon } from \"lucide-react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useLinkVisits } from \"@/lib/swr/use-link\";\nimport { durationFormat, timeAgo } from \"@/lib/utils\";\n\nimport { Gauge } from \"@/components/ui/gauge\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { TableCell, TableRow } from \"@/components/ui/table\";\nimport { VisitorAvatar } from \"@/components/visitors/visitor-avatar\";\n\nexport default function LinksVisitors({\n  linkId,\n  linkName,\n}: {\n  linkId: string;\n  linkName: string;\n}) {\n  const { views, hiddenFromPause } = useLinkVisits(linkId);\n  const { isPaused } = usePlan();\n\n  return (\n    <>\n      {views && isPaused && hiddenFromPause > 0 && (\n        <TableRow>\n          <TableCell colSpan={5} className=\"text-left sm:text-center\">\n            <div className=\"flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center\">\n              <span className=\"flex items-center gap-x-1\">\n                <AlertTriangleIcon className=\"inline-block h-4 w-4 text-orange-500\" />\n                {hiddenFromPause} visit\n                {hiddenFromPause !== 1 ? \"s\" : \"\"} occurred while your\n                subscription was paused and{\" \"}\n                {hiddenFromPause !== 1 ? \"are\" : \"is\"} hidden.\n              </span>\n              <Link\n                href=\"/settings/billing\"\n                className=\"font-medium text-orange-600 underline hover:text-orange-700\"\n              >\n                Unpause subscription to see all visits\n              </Link>\n            </div>\n          </TableCell>\n        </TableRow>\n      )}\n      {views ? (\n        views.map((view) => (\n          <TableRow key={view.id}>\n            <TableCell colSpan={2}>\n              <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                <VisitorAvatar\n                  viewerEmail={view.viewerEmail}\n                  className=\"h-7 w-7 text-xs md:h-8 md:w-8 md:text-sm\"\n                />\n\n                <p className=\"overflow-visible text-sm text-gray-800 dark:text-gray-200\">\n                  {view.viewerEmail ? view.viewerEmail : \"Anonymous\"}\n                </p>\n              </div>\n            </TableCell>\n\n            <TableCell>\n              <div className=\"flex items-center space-x-2 md:space-x-4\">\n                <div className=\"whitespace-nowrap text-sm text-muted-foreground\">\n                  {durationFormat(view.totalDuration)}\n                </div>\n\n                <div className=\"text-xs md:text-sm\">\n                  <Gauge\n                    value={view.completionRate}\n                    size={\"small\"}\n                    showValue={true}\n                  />\n                </div>\n              </div>\n            </TableCell>\n\n            <TableCell>\n              <div>\n                <time\n                  className=\"truncate text-sm text-muted-foreground\"\n                  dateTime={new Date(view.viewedAt).toISOString()}\n                >\n                  {timeAgo(view.viewedAt)}\n                </time>\n              </div>\n            </TableCell>\n            <TableCell className=\"hidden sm:table-cell\"></TableCell>\n          </TableRow>\n        ))\n      ) : (\n        <TableRow>\n          <TableCell colSpan={2}>\n            <div className=\"flex items-center space-x-2\">\n              <Skeleton className=\"h-10 w-10 rounded-full\" />\n              <Skeleton className=\"h-6 w-[220px]\" />\n            </div>\n          </TableCell>\n          <TableCell>\n            <Skeleton className=\"h-6 w-24\" />\n          </TableCell>\n          <TableCell>\n            <Skeleton className=\"h-6 w-16\" />\n          </TableCell>\n        </TableRow>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/links/preview-button.tsx",
    "content": "import { EyeIcon } from \"lucide-react\";\nimport { EyeOffIcon } from \"lucide-react\";\n\nimport { LinkWithViews } from \"@/lib/types\";\n\nimport { Button } from \"../ui/button\";\nimport { ButtonTooltip } from \"../ui/tooltip\";\n\nconst PreviewButton = ({\n  link,\n  isProcessing,\n  onPreview,\n}: {\n  link: LinkWithViews;\n  isProcessing: boolean;\n  onPreview: (link: LinkWithViews) => void;\n}) => {\n  const handleClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    e.preventDefault();\n    onPreview(link);\n  };\n\n  const handleContainerClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n  };\n\n  return (\n    <div className=\"relative\" onClick={handleContainerClick} onMouseDown={handleContainerClick}>\n      <ButtonTooltip\n        content={isProcessing ? \"Preparing preview\" : \"Preview link\"}\n      >\n        <div>\n          <Button\n            variant={\"link\"}\n            size={\"icon\"}\n            className=\"group h-7 w-8\"\n            onClick={handleClick}\n            disabled={isProcessing}\n          >\n            <span className=\"sr-only\">Preview link</span>\n            {isProcessing ? (\n              <EyeOffIcon className=\"text-gray-400 group-hover:text-gray-500\" />\n            ) : (\n              <EyeIcon className=\"text-gray-400 group-hover:text-gray-500\" />\n            )}\n          </Button>\n        </div>\n      </ButtonTooltip>\n    </div>\n  );\n};\n\nexport { PreviewButton };\n"
  },
  {
    "path": "components/navigation-menu.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport * as React from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { CrownIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Separator } from \"@/components/ui/separator\";\n\nimport { UpgradePlanModal } from \"./billing/upgrade-plan-modal\";\n\ntype Props = {\n  navigation: {\n    label: string;\n    href: string;\n    segment: string | null;\n    tag?: string;\n    disabled?: boolean;\n    limited?: boolean;\n  }[];\n  className?: string;\n};\n\nexport const NavMenu: React.FC<React.PropsWithChildren<Props>> = ({\n  navigation,\n  className,\n}) => {\n  return (\n    <nav\n      className={cn(\"sticky top-0 bg-background dark:bg-gray-900\", className)}\n    >\n      <div className=\"flex w-full items-center overflow-x-auto px-4 pl-1\">\n        <ul className=\"flex flex-row gap-4\">\n          {navigation.map(\n            ({ label, href, segment, tag, disabled, limited }) => (\n              <NavItem\n                key={label}\n                label={label}\n                href={href}\n                segment={segment}\n                tag={tag}\n                disabled={disabled}\n                limited={limited}\n              />\n            ),\n          )}\n        </ul>\n      </div>\n      <Separator />\n    </nav>\n  );\n};\n\nconst NavItem: React.FC<Props[\"navigation\"][0]> = ({\n  label,\n  href,\n  segment,\n  tag,\n  disabled,\n  limited,\n}) => {\n  const router = useRouter();\n  // active is true if the segment included in the pathname, but not if it's the root pathname. unless the segment is the root pathname.\n  let active =\n    router.pathname.includes(segment as string) &&\n    segment !== \"/datarooms/[id]\";\n\n  if (segment === \"/datarooms/[id]\") {\n    active = router.pathname === \"/datarooms/[id]\";\n  }\n\n  // Special case for permissions - also active when pathname includes \"groups\"\n  // but NOT when it's within settings (like settings/file-permissions)\n  if (segment === \"permissions\") {\n    active =\n      (router.pathname.includes(\"permissions\") &&\n        !router.pathname.includes(\"settings\")) ||\n      router.pathname.includes(\"groups\");\n  }\n\n  if (segment === \"analytics\" && router.pathname.includes(\"groups\")) {\n    active = false;\n  }\n\n  return (\n    <li\n      key={label}\n      className={cn(\n        \"flex shrink-0 list-none border-b-2 border-transparent p-2\",\n        {\n          \"border-primary\": active,\n          hidden: disabled,\n        },\n      )}\n    >\n      {limited ? (\n        <UpgradePlanModal\n          key={label}\n          clickedPlan={PlanEnum.DataRoomsPlus}\n          trigger={label}\n          highlightItem={[\"qa\"]}\n        >\n          <div className=\"text-content-subtle hover:bg-background-subtle -mx-3 flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium hover:bg-muted hover:text-primary\">\n            {label}\n            <CrownIcon className=\"h-4 w-4 text-muted-foreground\" />\n          </div>\n        </UpgradePlanModal>\n      ) : (\n        <Link\n          href={href}\n          className={cn(\n            \"text-content-subtle hover:bg-background-subtle -mx-3 flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium hover:bg-muted hover:text-primary\",\n            {\n              \"text-primary\": active,\n            },\n          )}\n        >\n          {label}\n          {tag ? (\n            <div className=\"text-content-subtle rounded border bg-background px-1 py-0.5 font-mono text-xs\">\n              {tag}\n            </div>\n          ) : null}\n        </Link>\n      )}\n    </li>\n  );\n};\n"
  },
  {
    "path": "components/profile-menu.tsx",
    "content": "import Image from \"next/image\";\nimport Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { HelpCircle, LogOut, Search } from \"lucide-react\";\nimport { FileText } from \"lucide-react\";\nimport { signOut, useSession } from \"next-auth/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport ChevronUp from \"@/components/shared/icons/chevron-up\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nimport { SearchCommand } from \"./search-command\";\nimport UserRound from \"./shared/icons/user-round\";\nimport { ModeToggle } from \"./theme-toggle\";\n\ntype ProfileMenuProps = {\n  className?: string;\n  size: \"large\" | \"small\";\n};\n\n// Define the Article interface\ninterface Article {\n  data: {\n    slug: string;\n    title: string;\n    description?: string;\n  };\n}\n\nconst ProfileMenu = ({ className, size }: ProfileMenuProps) => {\n  const { data: session, status } = useSession();\n  const [searchOpen, setSearchOpen] = useState(false);\n  const [articles, setArticles] = useState<Article[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  const fetchArticles = async (query?: string) => {\n    setLoading(true);\n    try {\n      const params = new URLSearchParams({\n        locale: \"en\", // or get this from your app's locale\n        ...(query && { q: query }),\n      });\n\n      console.log(\"Fetching articles...\"); // Debug log\n      const res = await fetch(`/api/help?${params}`);\n      const data = await res.json();\n\n      console.log(\"Received data:\", data); // Debug log\n\n      if (data.error) {\n        throw new Error(data.error);\n      }\n\n      setArticles(data.articles || []);\n    } catch (error) {\n      console.error(\"Error fetching articles:\", error);\n      setArticles([]); // Set empty array on error\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const isSize = size === \"large\";\n  return (\n    <>\n      <div className=\"flex items-center justify-between space-x-2\">\n        {status === \"loading\" ? (\n          <div className=\"flex w-full items-center gap-x-3 rounded-md p-2\">\n            <Skeleton className=\"h-8 w-8 rounded-full\" />\n            {isSize && <Skeleton className=\"h-7 w-[90%]\" />}\n          </div>\n        ) : (\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild className=\"w-full\">\n              <div className=\"group flex w-full items-center rounded-full text-sm font-semibold leading-6 text-foreground hover:bg-gray-200 hover:dark:bg-secondary lg:gap-x-3 lg:rounded-md lg:p-2\">\n                {session?.user?.image ? (\n                  <Image\n                    className=\"h-7 w-7 rounded-full bg-secondary\"\n                    src={session?.user?.image}\n                    width={30}\n                    height={30}\n                    alt={`Profile picture of ${session?.user?.name}`}\n                    loading=\"lazy\"\n                  />\n                ) : (\n                  <UserRound className=\"h-7 w-7 rounded-full bg-secondary p-1 ring-1 ring-muted-foreground/50\" />\n                )}\n                {isSize && (\n                  <span className=\"flex w-full items-center justify-between\">\n                    <span className=\"sr-only\">Your profile</span>\n                    <span aria-hidden=\"true\" className=\"line-clamp-2\">\n                      {session?.user?.name\n                        ? session?.user?.name\n                        : session?.user?.email?.split(\"@\")[0]}\n                    </span>\n                    <ChevronUp\n                      className=\"ml-2 h-5 w-5 shrink-0 text-muted-foreground\"\n                      aria-hidden=\"true\"\n                    />\n                  </span>\n                )}\n              </div>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent className=\"mr-2 px-0 pb-2 lg:mr-0 lg:w-[240px] xl:w-[270px]\">\n              {session ? (\n                <>\n                  <DropdownMenuLabel className=\"mt-2 truncate !py-[3px] px-3 text-sm text-muted-foreground\">\n                    {session?.user?.email}\n                  </DropdownMenuLabel>\n                  <DropdownMenuSeparator className=\"!my-2\" />\n                  <ModeToggle />\n\n                  <button\n                    onClick={() => {\n                      setSearchOpen(true);\n                      fetchArticles();\n                    }}\n                    className=\"flex w-full items-center px-3 py-2 text-sm duration-200 hover:bg-gray-200 dark:hover:bg-muted\"\n                  >\n                    <Search className=\"mr-2 h-4 w-4\" />\n                    Need help?\n                  </button>\n\n                  <a\n                    href=\"mailto:support@papermark.com\"\n                    className=\"my-1 flex items-center px-3 py-2 text-sm duration-200 hover:bg-gray-200 dark:hover:bg-muted\"\n                  >\n                    <HelpCircle className=\"mr-2 h-4 w-4\" />\n                    Contact us\n                  </a>\n\n                  <Link\n                    onClick={() =>\n                      signOut({\n                        callbackUrl: `${window.location.origin}`,\n                      })\n                    }\n                    className=\"flex items-center px-3 py-2 text-sm duration-200 hover:bg-gray-200 dark:hover:bg-muted\"\n                    href={\"\"}\n                  >\n                    <LogOut className=\"mr-2 h-4 w-4\" />\n                    Sign Out\n                  </Link>\n                </>\n              ) : null}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        )}\n      </div>\n\n      <Dialog open={searchOpen} onOpenChange={setSearchOpen}>\n        <DialogContent className=\"max-w-[550px] gap-0 overflow-hidden border-none p-0 shadow-lg\">\n          <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n            <CommandInput\n              placeholder=\"Search help articles...\"\n              className=\"h-14 border-none px-4 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0\"\n              onValueChange={(search) => fetchArticles(search)}\n            />\n            <CommandList className=\"max-h-[400px] overflow-y-auto\">\n              <CommandEmpty>No articles found</CommandEmpty>\n              <CommandGroup heading=\"All Articles\">\n                {articles.map((article) => (\n                  <CommandItem\n                    key={article.data.slug}\n                    value={article.data.title}\n                    onSelect={() => {\n                      window.open(\n                        `${process.env.NEXT_PUBLIC_MARKETING_URL}/help/article/${article.data.slug}`,\n                        \"_blank\",\n                      );\n                      setSearchOpen(false);\n                    }}\n                  >\n                    <FileText className=\"mr-2 h-4 w-4 text-[#fb7a00]\" />\n                    <div className=\"flex flex-col\">\n                      <span className=\"text-sm font-medium\">\n                        {article.data.title}\n                      </span>\n                      {article.data.description && (\n                        <span className=\"text-xs text-muted-foreground\">\n                          {article.data.description}\n                        </span>\n                      )}\n                    </div>\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            </CommandList>\n          </Command>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n};\n\nexport default ProfileMenu;\n"
  },
  {
    "path": "components/profile-search-trigger.tsx",
    "content": "\"use client\";\n\nimport { Search } from \"lucide-react\";\n\ninterface ProfileSearchTriggerProps {\n  onClick: () => void;\n}\n\nexport function ProfileSearchTrigger({ onClick }: ProfileSearchTriggerProps) {\n  return (\n    <button\n      onClick={onClick}\n      className=\"flex w-full items-center px-3 py-2 text-sm duration-200 hover:bg-gray-200 dark:hover:bg-muted\"\n    >\n      <Search className=\"mr-2 h-4 w-4\" />\n      Search Help Articles\n    </button>\n  );\n}\n"
  },
  {
    "path": "components/providers/posthog-provider.tsx",
    "content": "import { getSession } from \"next-auth/react\";\nimport posthog from \"posthog-js\";\n// import { useEffect } from \"react\";\n// import { useRouter } from \"next/router\";\nimport { PostHogProvider } from \"posthog-js/react\";\n\nimport { getPostHogConfig } from \"@/lib/posthog\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport const PostHogCustomProvider = ({\n  children,\n}: {\n  children: React.ReactNode;\n}) => {\n  const posthogConfig = getPostHogConfig();\n  // const router = useRouter();\n\n  // Check that PostHog is client-side\n  if (typeof window !== \"undefined\" && posthogConfig) {\n    posthog.init(posthogConfig.key, {\n      api_host: posthogConfig.host,\n      ui_host: \"https://eu.posthog.com\",\n      disable_session_recording: true,\n      autocapture: false,\n      // Enable debug mode in development\n      loaded: (posthog) => {\n        if (process.env.NODE_ENV === \"development\") posthog.debug();\n        getSession()\n          .then((session) => {\n            if (session) {\n              posthog.identify(\n                (session.user as CustomUser).email ??\n                  (session.user as CustomUser).id,\n                {\n                  email: (session.user as CustomUser).email,\n                  userId: (session.user as CustomUser).id,\n                },\n              );\n            } else {\n              posthog.reset();\n            }\n          })\n          .catch(() => {\n            // Do nothing.\n          });\n      },\n    });\n  }\n\n  // useEffect(() => {\n  //   // Track page views\n  //   const handleRouteChange = () => posthog?.capture(\"$pageview\");\n  //   router.events.on(\"routeChangeComplete\", handleRouteChange);\n\n  //   return () => {\n  //     router.events.off(\"routeChangeComplete\", handleRouteChange);\n  //   };\n  // }, []);\n\n  return <PostHogProvider client={posthog}>{children}</PostHogProvider>;\n};\n"
  },
  {
    "path": "components/search-box.tsx",
    "content": "// Inspired by Steven Tey's flawless search implementation in dub.co\n// https://github.com/dubinc/dub/blob/450749a29ca2ec2486fb2272a73cfbd8a5d80b3f/apps/web/ui/shared/search-box.tsx\nimport { useRouter } from \"next/router\";\n\nimport {\n  memo,\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { CircleXIcon, SearchIcon } from \"lucide-react\";\nimport { useDebouncedCallback } from \"use-debounce\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport LoadingSpinner from \"./ui/loading-spinner\";\n\ntype SearchBoxProps = {\n  value: string;\n  loading?: boolean;\n  showClearButton?: boolean;\n  onChange: (value: string) => void;\n  onChangeDebounced?: (value: string) => void;\n  debounceTimeoutMs?: number;\n  inputClassName?: string;\n  leftIconClassName?: string;\n  clearIconClassName?: string;\n  placeholder?: string;\n};\n\nconst SearchBox = forwardRef(\n  (\n    {\n      value,\n      loading,\n      showClearButton = true,\n      onChange,\n      onChangeDebounced,\n      debounceTimeoutMs = 500,\n      inputClassName,\n      leftIconClassName,\n      clearIconClassName,\n      placeholder = \"Search...\",\n    }: SearchBoxProps,\n    forwardedRef,\n  ) => {\n    const inputRef = useRef<HTMLInputElement>(null);\n    useImperativeHandle(forwardedRef, () => inputRef.current);\n\n    const debounced = useDebouncedCallback(\n      (value) => onChangeDebounced?.(value),\n      debounceTimeoutMs,\n    );\n\n    const onKeyDown = useCallback((e: KeyboardEvent) => {\n      const target = e.target as HTMLElement;\n      // only focus on filter input when:\n      // - user is not typing in an input or textarea\n      // - there is no existing modal backdrop (i.e. no other modal is open)\n      if (\n        e.key === \"/\" &&\n        target.tagName !== \"INPUT\" &&\n        target.tagName !== \"TEXTAREA\"\n      ) {\n        e.preventDefault();\n        inputRef.current?.focus();\n      }\n    }, []);\n\n    useEffect(() => {\n      document.addEventListener(\"keydown\", onKeyDown);\n      return () => document.removeEventListener(\"keydown\", onKeyDown);\n    }, [onKeyDown]);\n\n    return (\n      <div className=\"relative\">\n        <div className=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4\">\n          {loading && value.length > 0 ? (\n            <LoadingSpinner className=\"h-4 w-4\" />\n          ) : (\n            <SearchIcon\n              className={cn(\"h-4 w-4 text-muted-foreground\", leftIconClassName)}\n            />\n          )}\n        </div>\n        <input\n          ref={inputRef}\n          type=\"text\"\n          className={cn(\n            \"peer w-full cursor-text rounded-md border border-border bg-white px-10 text-foreground outline-none placeholder:text-muted-foreground dark:bg-gray-800 sm:text-sm\",\n            \"transition-all focus:border-gray-500 focus:ring-0\",\n            inputClassName,\n          )}\n          placeholder={placeholder}\n          value={value}\n          onChange={(e) => {\n            onChange(e.target.value);\n            debounced(e.target.value);\n          }}\n          autoCapitalize=\"none\"\n        />\n        {showClearButton && value.length > 0 && (\n          <button\n            onClick={() => {\n              onChange(\"\");\n              onChangeDebounced?.(\"\");\n            }}\n            className=\"pointer-events-auto absolute inset-y-0 right-0 flex items-center pr-4\"\n          >\n            <CircleXIcon\n              className={cn(\"h-4 w-4 text-muted-foreground\", clearIconClassName)}\n            />\n          </button>\n        )}\n      </div>\n    );\n  },\n);\nSearchBox.displayName = \"SearchBox\";\nconst MemoizedSearchBox = memo(SearchBox);\n\nexport function SearchBoxPersisted({\n  urlParam = \"search\",\n  ...props\n}: { urlParam?: string } & Partial<SearchBoxProps>) {\n  const router = useRouter();\n  const queryParams = router.query;\n\n  const initial =\n    typeof queryParams[urlParam] === \"string\" ? queryParams[urlParam] : \"\";\n\n  const [value, setValue] = useState(initial);\n  const [debouncedValue, setDebouncedValue] = useState(initial);\n\n  useEffect(() => {\n    const currentQuery = { ...router.query };\n\n    if (debouncedValue === \"\") {\n      delete currentQuery[urlParam];\n      delete currentQuery.page;\n      delete currentQuery.limit;\n    } else {\n      currentQuery[urlParam] = debouncedValue;\n    }\n\n    // For custom domains, preserve the clean URL structure\n    const isCustomDomain = !!(router.query.domain && router.query.slug);\n\n    router.push(\n      {\n        pathname: router.pathname,\n        query: currentQuery,\n      },\n      isCustomDomain ? `/${router.query.slug}` : undefined, // Preserve custom domain URL\n      { shallow: true },\n    );\n    // This is intentionally keyed only by debounced input value.\n    // Adding router/query deps can cause feedback loops with shallow routing.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [debouncedValue]);\n\n  useEffect(() => {\n    const queryValue =\n      typeof queryParams[urlParam] === \"string\" ? queryParams[urlParam] : \"\";\n    if (queryValue !== value && value === debouncedValue) {\n      setValue(queryValue);\n      setDebouncedValue(queryValue);\n    }\n    // Keep this tied to the specific URL param only.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [queryParams[urlParam]]);\n\n  return (\n    <MemoizedSearchBox\n      value={value}\n      onChange={setValue}\n      onChangeDebounced={setDebouncedValue}\n      {...props}\n    />\n  );\n}\n"
  },
  {
    "path": "components/search-command.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { DialogProps } from \"@radix-ui/react-dialog\";\nimport { FileText } from \"lucide-react\";\n\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\n\ninterface Article {\n  data: {\n    title: string;\n    slug: string;\n    description?: string;\n  };\n}\n\ninterface SearchCommandProps extends DialogProps {\n  articles: Article[];\n  locale: string;\n  placeholder: string;\n  noResultsText: string;\n  articlesHeading: string;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nexport function SearchCommand({\n  articles,\n  locale,\n  placeholder,\n  noResultsText,\n  articlesHeading,\n  open,\n  onOpenChange,\n  ...props\n}: SearchCommandProps) {\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange} {...props}>\n      <DialogContent className=\"max-w-[550px] gap-0 overflow-hidden border-none p-0 shadow-lg\">\n        <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          <CommandInput\n            placeholder={placeholder}\n            className=\"h-14 border-none px-4 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0\"\n          />\n          <CommandList className=\"max-h-[400px] overflow-y-auto\">\n            <CommandEmpty className=\"py-6 text-center text-sm\">\n              {noResultsText}\n            </CommandEmpty>\n            <CommandGroup heading={articlesHeading} className=\"px-4\">\n              {articles.map((article) => (\n                <CommandItem\n                  key={article.data.slug}\n                  value={article.data.title}\n                  onSelect={() => {\n                    const path = `/help/article/${article.data.slug}`;\n                    window.open(\n                      `${process.env.NEXT_PUBLIC_MARKETING_URL}${path}`,\n                      \"_blank\",\n                    );\n                    onOpenChange?.(false);\n                  }}\n                  className=\"cursor-pointer rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800\"\n                >\n                  <FileText className=\"mr-2 h-4 w-4 text-[#fb7a00]\" />\n                  <div className=\"flex flex-col\">\n                    <span className=\"text-sm font-medium\">\n                      {article.data.title}\n                    </span>\n                    {article.data.description && (\n                      <span className=\"text-xs text-muted-foreground\">\n                        {article.data.description}\n                      </span>\n                    )}\n                  </div>\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/settings/delete-team-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport {\n  Dispatch,\n  SetStateAction,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { TeamContextType, useTeam } from \"@/context/team-context\";\nimport { signOut } from \"next-auth/react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { CardDescription, CardTitle } from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Modal } from \"@/components/ui/modal\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nfunction DeleteTeamModal({\n  showDeleteTeamModal,\n  setShowDeleteTeamModal,\n}: {\n  showDeleteTeamModal: boolean;\n  setShowDeleteTeamModal: Dispatch<SetStateAction<boolean>>;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n\n  const [deleting, setDeleting] = useState(false);\n  const [isValid, setIsValid] = useState(false);\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const { value } = e.target;\n    // Check if the input matches the pattern\n    if (value === \"confirm delete team\") {\n      setIsValid(true);\n    } else {\n      setIsValid(false);\n    }\n  };\n\n  async function deleteTeam() {\n    const teamsCount = teamInfo?.teams.length ?? 1;\n\n    return new Promise((resolve, reject) => {\n      setDeleting(true);\n\n      fetch(`/api/teams/${teamInfo?.currentTeam?.id}`, {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      }).then(async (res) => {\n        if (res.ok) {\n          analytics.capture(\"Account Deleted\", {\n            teamName: teamInfo?.currentTeam?.name,\n            teamId: teamInfo?.currentTeam?.id,\n          });\n          await mutate(\"/api/teams\");\n          console.log(\"teamsCount\", teamsCount);\n          teamsCount > 1 ? router.push(\"/documents\") : signOut();\n          resolve(null);\n        } else {\n          setDeleting(false);\n          const error = await res.json();\n          reject(error.message);\n        }\n      });\n    });\n  }\n\n  const { isMobile } = useMediaQuery();\n\n  return (\n    <Modal\n      showModal={showDeleteTeamModal}\n      setShowModal={setShowDeleteTeamModal}\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <CardTitle>Delete Team</CardTitle>\n        <CardDescription>\n          Warning: This will permanently delete your team, custom domains,\n          documents and all associated links and their respective views.\n        </CardDescription>\n      </div>\n\n      <form\n        onSubmit={async (e) => {\n          e.preventDefault();\n          toast.promise(deleteTeam(), {\n            loading: \"Deleting team...\",\n            success: \"Team deleted successfully!\",\n            error: (err) => err,\n          });\n        }}\n        className=\"flex flex-col space-y-6 bg-muted px-4 py-8 text-left dark:bg-gray-900 sm:px-8\"\n      >\n        <div>\n          <label\n            htmlFor=\"team-name\"\n            className=\"block text-sm font-medium text-muted-foreground\"\n          >\n            Enter the team name{\" \"}\n            <span className=\"font-semibold text-foreground\">\n              {teamInfo?.currentTeam?.name}\n            </span>{\" \"}\n            to continue:\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              type=\"text\"\n              name=\"team-name\"\n              id=\"team-name\"\n              autoFocus={!isMobile}\n              autoComplete=\"off\"\n              required\n              pattern={teamInfo?.currentTeam?.name}\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n            />\n          </div>\n        </div>\n\n        <div>\n          <label\n            htmlFor=\"verification\"\n            className=\"block text-sm text-muted-foreground\"\n          >\n            To verify, type{\" \"}\n            <span className=\"font-semibold text-foreground\">\n              confirm delete team\n            </span>{\" \"}\n            below\n          </label>\n          <div className=\"relative mt-1 rounded-md shadow-sm\">\n            <Input\n              type=\"text\"\n              name=\"verification\"\n              id=\"verification\"\n              pattern=\"confirm delete team\"\n              required\n              autoComplete=\"off\"\n              className=\"bg-white dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent\"\n              onInput={handleInputChange}\n            />\n          </div>\n        </div>\n\n        <Button variant=\"destructive\" loading={deleting} disabled={!isValid}>\n          Confirm delete team\n        </Button>\n      </form>\n    </Modal>\n  );\n}\n\nexport function useDeleteTeamModal() {\n  const [showDeleteTeamModal, setShowDeleteTeamModal] = useState(false);\n\n  const DeleteTeamModalCallback = useCallback(() => {\n    return (\n      <DeleteTeamModal\n        showDeleteTeamModal={showDeleteTeamModal}\n        setShowDeleteTeamModal={setShowDeleteTeamModal}\n      />\n    );\n  }, [showDeleteTeamModal, setShowDeleteTeamModal]);\n\n  return useMemo(\n    () => ({\n      setShowDeleteTeamModal,\n      DeleteTeamModal: DeleteTeamModalCallback,\n    }),\n    [setShowDeleteTeamModal, DeleteTeamModalCallback],\n  );\n}\n"
  },
  {
    "path": "components/settings/delete-team.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\n\nimport { useDeleteTeamModal } from \"./delete-team-modal\";\n\nexport default function DeleteTeam() {\n  const { setShowDeleteTeamModal, DeleteTeamModal } = useDeleteTeamModal();\n\n  return (\n    <div className=\"rounded-lg\">\n      <DeleteTeamModal />\n      <Card className=\"border-destructive bg-transparent\">\n        <CardHeader>\n          <CardTitle>Delete Team</CardTitle>\n          <CardDescription>\n            Permanently delete your team, custom domains, and all associated\n            documents, links + their views. <br />\n            <span className=\"font-medium\">This action cannot be undone</span> -\n            please proceed with caution.\n          </CardDescription>\n        </CardHeader>\n        <CardContent></CardContent>\n        <CardFooter className=\"flex items-center justify-end rounded-b-lg border-t px-6 py-3\">\n          <div className=\"shrink-0\">\n            <Button\n              onClick={() => setShowDeleteTeamModal(true)}\n              variant=\"destructive\"\n            >\n              Delete Team\n            </Button>\n          </div>\n        </CardFooter>\n      </Card>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/settings/global-block-list-form.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { fetcher, sanitizeList } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nexport default function GlobalBlockListForm() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: blockList, mutate } = useSWR<string[]>(\n    teamId ? `/api/teams/${teamId}/global-block-list` : null,\n    fetcher,\n  );\n\n  const [blockListInput, setBlockListInput] = useState(\"\");\n  const [initialBlockListInput, setInitialBlockListInput] = useState(\"\");\n  const [isSaving, setIsSaving] = useState(false);\n\n  useEffect(() => {\n    if (blockList) {\n      const val = blockList.join(\"\\n\");\n      setBlockListInput(val);\n      setInitialBlockListInput(val);\n    }\n  }, [blockList]);\n\n  const allEntered = blockListInput\n    .split(\"\\n\")\n    .map((d) => d.trim())\n    .filter(Boolean);\n  const validEntries = sanitizeList(blockListInput, \"both\");\n  const invalidEntries = allEntered.filter(\n    (d) => !validEntries.includes(d.toLowerCase()),\n  );\n\n  const saveDisabled = useMemo(() => {\n    return (\n      isSaving ||\n      blockListInput === initialBlockListInput ||\n      invalidEntries.length > 0\n    );\n  }, [isSaving, blockListInput, initialBlockListInput, invalidEntries]);\n\n  const handleSave = async () => {\n    setIsSaving(true);\n    const entries = sanitizeList(blockListInput, \"both\");\n\n    const promise = fetch(`/api/teams/${teamId}/global-block-list`, {\n      method: \"PUT\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ blockList: entries }),\n    }).then(async (res) => {\n      if (!res.ok) {\n        const { error } = await res.json();\n        throw new Error(error?.message || \"Failed to update block list.\");\n      }\n      await mutate();\n      return res.json();\n    });\n\n    toast.promise(promise, {\n      loading: \"Saving block list...\",\n      success: \"Block list saved!\",\n      error: (err) => err.message,\n    });\n\n    try {\n      await promise;\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle>Global Block List</CardTitle>\n        <CardDescription>\n          Visitors with these emails or domains will be{\" \"}\n          <b>blocked from all links</b> in your team. Use{\" \"}\n          <code>@domain.com</code> for domains or full email addresses.\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <Textarea\n          className=\"focus:ring-inset\"\n          rows={5}\n          placeholder={`Enter one email or domain per line, e.g.\\n@company.io\\nuser@example.com`}\n          value={blockListInput}\n          onChange={(e) => setBlockListInput(e.target.value)}\n        />\n        {invalidEntries.length > 0 && (\n          <p className=\"mt-2 text-sm text-destructive\">\n            The following entries are not valid and will be ignored:{\" \"}\n            {invalidEntries.join(\", \")}\n          </p>\n        )}\n      </CardContent>\n      <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n        <p className=\"text-sm text-muted-foreground transition-colors\">\n          Add emails or domains to block access to all links.\n        </p>\n        <Button onClick={handleSave} loading={isSaving} disabled={saveDisabled}>\n          Save Changes\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/settings/ignored-domains-form.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { fetcher, sanitizeList } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nexport default function IgnoredDomainsForm() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: ignoredDomains, mutate } = useSWR<string[]>(\n    teamId ? `/api/teams/${teamId}/ignored-domains` : null,\n    fetcher,\n  );\n\n  const [domainsInput, setDomainsInput] = useState(\"\");\n  const [initialDomainsInput, setInitialDomainsInput] = useState(\"\");\n  const [isSaving, setIsSaving] = useState(false);\n\n  useEffect(() => {\n    if (ignoredDomains) {\n      const val = ignoredDomains.join(\"\\n\");\n      setDomainsInput(val);\n      setInitialDomainsInput(val);\n    }\n  }, [ignoredDomains]);\n\n  const allEnteredDomains = domainsInput\n    .split(\"\\n\")\n    .map((d) => d.trim())\n    .filter(Boolean);\n  const validDomains = sanitizeList(domainsInput, \"domain\");\n  const invalidDomains = allEnteredDomains.filter(\n    (d) => !validDomains.includes(d.toLowerCase()),\n  );\n\n  const saveDisabled = useMemo(() => {\n    return (\n      isSaving ||\n      domainsInput === initialDomainsInput ||\n      invalidDomains.length > 0\n    );\n  }, [isSaving, domainsInput, initialDomainsInput, invalidDomains]);\n\n  const handleSave = async () => {\n    setIsSaving(true);\n    const domains = sanitizeList(domainsInput, \"domain\");\n\n    const promise = fetch(`/api/teams/${teamId}/ignored-domains`, {\n      method: \"PUT\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ domains }),\n    }).then(async (res) => {\n      if (!res.ok) {\n        const { error } = await res.json();\n        throw new Error(error?.message || \"Failed to update ignored domains.\");\n      }\n      await mutate();\n      return res.json();\n    });\n\n    toast.promise(promise, {\n      loading: \"Saving ignored domains...\",\n      success: \"Ignored domains saved!\",\n      error: (err) => err.message,\n    });\n\n    try {\n      await promise;\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle>Ignored Domains for Notifications</CardTitle>\n        <CardDescription>\n          No one on the team will be notified when a visitor from these domains\n          views a link.\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <Textarea\n          className=\"focus:ring-inset\"\n          rows={5}\n          placeholder={`Enter one domain per line, e.g.\n@company.io\n@example.com`}\n          value={domainsInput}\n          onChange={(e) => setDomainsInput(e.target.value)}\n        />\n        {invalidDomains.length > 0 && (\n          <p className=\"mt-2 text-sm text-destructive\">\n            The following entries are not valid domains and will be ignored:{\" \"}\n            {invalidDomains.join(\", \")}\n          </p>\n        )}\n      </CardContent>\n      <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n        <p className=\"text-sm text-muted-foreground transition-colors\">\n          Add domains to prevent notifications from internal views.\n        </p>\n        <Button onClick={handleSave} loading={isSaving} disabled={saveDisabled}>\n          Save Changes\n        </Button>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/settings/og-preview.tsx",
    "content": "import { Dispatch, SetStateAction, useMemo } from \"react\";\n\nimport { ImageIcon } from \"lucide-react\";\nimport ReactTextareaAutosize from \"react-textarea-autosize\";\n\nimport { PresetDataSchema } from \"@/lib/zod/schemas/presets\";\n\nimport { Facebook } from \"../shared/icons/facebook\";\nimport LinkedIn from \"../shared/icons/linkedin\";\nimport Twitter from \"../shared/icons/twitter\";\n\nexport default function Preview({\n  data,\n  setData,\n}: {\n  data: Partial<PresetDataSchema>;\n  setData: Dispatch<SetStateAction<Partial<PresetDataSchema>>>;\n}) {\n  const {\n    metaTitle: title,\n    metaDescription: description,\n    metaImage: image,\n    metaFavicon: favicon,\n  } = data;\n\n  const hostname = \"papermark.com\";\n\n  return (\n    <div>\n      <div className=\"sticky top-0 z-10 flex h-10 items-center justify-center border-b border-border bg-white px-5 dark:bg-gray-900 sm:h-14\">\n        <h2 className=\"text-lg font-medium\">Previews</h2>\n      </div>\n      <div className=\"grid gap-5 p-5\">\n        {/* Twitter */}\n        <div>\n          <div className=\"relative mb-2\">\n            <div\n              className=\"absolute inset-0 flex items-center\"\n              aria-hidden=\"true\"\n            >\n              <div className=\"w-full border-t border-border\" />\n            </div>\n            <div className=\"relative flex justify-center\">\n              <div className=\"flex items-center space-x-2 bg-white px-3 dark:bg-gray-900\">\n                <Twitter className=\"h-4 w-4\" />\n                <p className=\"text-sm text-muted-foreground\">Twitter</p>\n              </div>\n            </div>\n          </div>\n          <div className=\"group relative overflow-hidden rounded-xl border border-border md:rounded-2xl\">\n            <ImagePreview image={image} />\n            <div className=\"absolute bottom-2 left-2 flex min-h-6 flex-row items-center gap-2 rounded-md bg-[#414142] px-2 py-1\">\n              <img\n                src={(favicon || \"/favicon.ico\") as string}\n                alt=\"Preview\"\n                className=\"h-4 w-4 rounded-md object-contain\"\n              />\n              {(title || title === \"\") && (\n                <h3 className=\"max-w-64 truncate text-sm text-white\">\n                  {title}\n                </h3>\n              )}\n            </div>\n          </div>\n          {hostname && (\n            <div className=\"flex flex-row\">\n              <p className=\"mt-2 text-[0.8rem] text-[#606770]\">{hostname}</p>\n            </div>\n          )}\n        </div>\n\n        {/* LinkedIn */}\n        <div>\n          <div className=\"relative mb-2\">\n            <div\n              className=\"absolute inset-0 flex items-center\"\n              aria-hidden=\"true\"\n            >\n              <div className=\"w-full border-t border-border\" />\n            </div>\n            <div className=\"relative flex justify-center\">\n              <div className=\"flex items-center space-x-2 bg-white px-3 dark:bg-gray-900\">\n                <LinkedIn className=\"h-4 w-4\" />\n                <p className=\"text-sm text-muted-foreground\">LinkedIn</p>\n              </div>\n            </div>\n          </div>\n          <div className=\"relative overflow-hidden rounded-[2px] shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_2px_3px_rgba(0,0,0,0.2)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.15),0_2px_3px_rgba(255,255,255,0.2)]\">\n            <ImagePreview image={image} />\n            <div className=\"flex flex-col gap-1 border-t border-border bg-white p-3\">\n              <div className=\"flex flex-row items-center gap-2\">\n                <img\n                  src={(favicon || \"/favicon.ico\") as string}\n                  alt=\"Preview\"\n                  className=\"h-4 w-4 rounded-md object-contain\"\n                />\n                {title || title === \"\" ? (\n                  <input\n                    maxLength={120}\n                    className=\"w-full truncate border-none bg-transparent p-0 font-semibold text-[#000000E6] outline-none focus:rounded-md focus:px-1 focus:ring-1 focus:ring-inset focus:ring-gray-500\"\n                    value={title}\n                    onChange={(e) => {\n                      setData((prev) => ({\n                        ...prev,\n                        metaTitle: e.currentTarget.value,\n                      }));\n                    }}\n                  />\n                ) : (\n                  <div className=\"h-5 w-full rounded-md bg-gray-200\" />\n                )}\n              </div>\n              {hostname ? (\n                <p className=\"text-xs text-[#00000099]\">{hostname}</p>\n              ) : (\n                <div className=\"mb-1 h-4 w-24 rounded-md bg-gray-200\" />\n              )}\n            </div>\n          </div>\n        </div>\n\n        {/* Facebook */}\n        <div>\n          <div className=\"relative mb-2\">\n            <div\n              className=\"absolute inset-0 flex items-center\"\n              aria-hidden=\"true\"\n            >\n              <div className=\"w-full border-t border-border\" />\n            </div>\n            <div className=\"relative flex justify-center\">\n              <div className=\"flex items-center space-x-2 bg-white px-3 dark:bg-gray-900\">\n                <Facebook className=\"h-4 w-4\" />\n                <p className=\"text-sm text-muted-foreground\">Facebook</p>\n              </div>\n            </div>\n          </div>\n          <div className=\"relative border border-border\">\n            <ImagePreview image={image} />\n            <div className=\"grid gap-1 border-t border-border bg-[#f2f3f5] p-3\">\n              {hostname ? (\n                <p className=\"text-[0.8rem] uppercase text-[#606770]\">\n                  {hostname}\n                </p>\n              ) : (\n                <div className=\"mb-1 h-4 w-24 rounded-md bg-gray-200\" />\n              )}\n              <div className=\"flex flex-row items-center gap-2\">\n                <img\n                  src={(favicon || \"/favicon.ico\") as string}\n                  alt=\"Preview\"\n                  className=\"h-4 w-4 rounded-md object-contain\"\n                />\n                {title || title === \"\" ? (\n                  <input\n                    maxLength={120}\n                    name={window.crypto.randomUUID()}\n                    className=\"w-full truncate border-none bg-transparent p-0 font-semibold text-[#000000E6] outline-none focus:rounded-md focus:px-1 focus:ring-1 focus:ring-inset focus:ring-gray-500\"\n                    value={title}\n                    onChange={(e) => {\n                      setData((prev) => ({\n                        ...prev,\n                        metaTitle: e.currentTarget.value,\n                      }));\n                    }}\n                  />\n                ) : (\n                  <div className=\"mb-1 h-5 w-full rounded-md bg-gray-200\" />\n                )}\n              </div>\n              {description || description === \"\" ? (\n                <ReactTextareaAutosize\n                  maxLength={240}\n                  className=\"line-clamp-2 w-full resize-none rounded-md border-none bg-gray-200 bg-transparent p-0 text-sm text-[#606770] outline-none focus:rounded-md focus:px-1 focus:ring-1 focus:ring-inset focus:ring-gray-500\"\n                  value={description}\n                  maxRows={2}\n                  onChange={(e) => {\n                    setData((prev) => ({\n                      ...prev,\n                      metaDescription: e.currentTarget.value,\n                    }));\n                  }}\n                />\n              ) : (\n                <div className=\"grid gap-2\">\n                  <div className=\"h-4 w-full rounded-md bg-gray-200\" />\n                  <div className=\"h-4 w-48 rounded-md bg-gray-200\" />\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst ImagePreview = ({ image }: { image: string | null | undefined }) => {\n  const previewImage = useMemo(() => {\n    if (image) {\n      return (\n        <img\n          src={image}\n          alt=\"Preview\"\n          className=\"aspect-[1200/630] h-full w-full object-cover\"\n        />\n      );\n    } else {\n      return (\n        <div className=\"flex aspect-[1200/630] h-full w-full flex-col items-center justify-center space-y-4 bg-gray-100\">\n          <ImageIcon className=\"h-8 w-8 text-gray-400\" />\n          <p className=\"text-sm text-gray-400\">\n            Add an image to generate a preview.\n          </p>\n        </div>\n      );\n    }\n  }, [image]);\n\n  return <>{previewImage}</>;\n};\n"
  },
  {
    "path": "components/settings/settings-header.tsx",
    "content": "import { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { useIsAdmin } from \"@/lib/hooks/use-is-admin\";\n\nimport { NavMenu } from \"../navigation-menu\";\n\nexport function SettingsHeader() {\n  const { features } = useFeatureFlags();\n  const { isAdmin } = useIsAdmin();\n\n  return (\n    <header>\n      <section className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n        <div className=\"space-y-1\">\n          <h1 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n            Settings\n          </h1>\n          <p className=\"text-xs text-muted-foreground sm:text-sm\">\n            Manage your account settings\n          </p>\n        </div>\n      </section>\n\n      <NavMenu\n        navigation={[\n          {\n            label: \"Overview\",\n            href: `/settings/general`,\n            segment: `general`,\n          },\n          {\n            label: \"Team\",\n            href: `/settings/people`,\n            segment: \"people\",\n          },\n          {\n            label: \"Domains\",\n            href: `/settings/domains`,\n            segment: \"domains\",\n          },\n          {\n            label: \"Presets\",\n            href: `/settings/presets`,\n            segment: \"presets\",\n          },\n          {\n            label: \"Tags\",\n            href: `/settings/tags`,\n            segment: \"tags\",\n          },\n          {\n            label: \"Agreements\",\n            href: `/settings/agreements`,\n            segment: \"agreements\",\n          },\n          {\n            label: \"Webhooks\",\n            href: `/settings/webhooks`,\n            segment: \"webhooks\",\n          },\n          {\n            label: \"Slack\",\n            href: `/settings/slack`,\n            segment: \"slack\",\n          },\n          {\n            label: \"AI\",\n            href: `/settings/ai`,\n            segment: \"ai\",\n            disabled: !features?.ai,\n          },\n          {\n            label: \"Tokens\",\n            href: `/settings/tokens`,\n            segment: \"tokens\",\n            disabled: !features?.tokens,\n          },\n          {\n            label: \"API\",\n            href: `/settings/incoming-webhooks`,\n            segment: \"incoming-webhooks\",\n            disabled: !features?.incomingWebhooks,\n          },\n          {\n            label: \"Security\",\n            href: `/settings/security`,\n            segment: \"security\",\n            disabled: !isAdmin,\n          },\n          {\n            label: \"Billing\",\n            href: `/settings/billing`,\n            segment: \"billing\",\n            disabled: !isAdmin,\n          },\n        ]}\n      />\n    </header>\n  );\n}\n"
  },
  {
    "path": "components/settings/slack-settings-skeleton.tsx",
    "content": "import { Skeleton } from \"@/components/ui/skeleton\";\n\nexport default function SlackSettingsSkeleton() {\n  return (\n    <div className=\"space-y-6\">\n      {/* Header Skeleton */}\n      <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n        <div className=\"space-y-2\">\n          <Skeleton className=\"h-8 w-48\" />\n          <Skeleton className=\"h-4 w-80\" />\n        </div>\n        <Skeleton className=\"h-10 w-32\" />\n      </div>\n\n      {/* Main Content Skeleton */}\n      <div className=\"space-y-6\">\n        {/* Card 1 */}\n        <div className=\"rounded-lg border bg-card p-6\">\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Skeleton className=\"h-6 w-40\" />\n              <Skeleton className=\"h-4 w-60\" />\n            </div>\n            <div className=\"space-y-4\">\n              <div className=\"flex items-center justify-between\">\n                <div className=\"space-y-1\">\n                  <Skeleton className=\"h-5 w-32\" />\n                  <Skeleton className=\"h-4 w-48\" />\n                </div>\n                <Skeleton className=\"h-6 w-11\" />\n              </div>\n              <div className=\"space-y-3\">\n                <div className=\"space-y-2\">\n                  <Skeleton className=\"h-4 w-32\" />\n                  <Skeleton className=\"h-4 w-64\" />\n                </div>\n                <div className=\"flex items-center rounded-lg border border-border bg-background\">\n                  <div className=\"flex flex-1 items-center gap-2 px-2\">\n                    <Skeleton className=\"h-6 w-20\" />\n                    <Skeleton className=\"h-6 w-24\" />\n                  </div>\n                  <Skeleton className=\"h-6 w-px\" />\n                  <Skeleton className=\"h-8 w-32 shrink-0\" />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {/* Card 2 */}\n        <div className=\"rounded-lg border bg-card p-6\">\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Skeleton className=\"h-6 w-44\" />\n              <Skeleton className=\"h-4 w-72\" />\n            </div>\n            <div className=\"space-y-4\">\n              <div className=\"flex space-x-6\">\n                {[1, 2, 3].map((i) => (\n                  <div key={i} className=\"flex items-center space-x-2\">\n                    <Skeleton className=\"h-4 w-4\" />\n                    <Skeleton className=\"h-4 w-16\" />\n                  </div>\n                ))}\n              </div>\n              <div className=\"grid gap-4 md:grid-cols-2\">\n                <div className=\"space-y-2\">\n                  <Skeleton className=\"h-4 w-12\" />\n                  <Skeleton className=\"h-10 w-full\" />\n                </div>\n                <div className=\"space-y-2\">\n                  <Skeleton className=\"h-4 w-20\" />\n                  <Skeleton className=\"h-10 w-full\" />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/settings/survey-settings.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { CheckCircleIcon, PencilIcon, XIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\n\nconst DEAL_TYPE_OPTIONS = [\n  { value: \"startup-fundraising\", label: \"Startup Fundraising\" },\n  { value: \"fund-management\", label: \"Fundraising & Reporting\" },\n  { value: \"mergers-acquisitions\", label: \"Mergers & Acquisitions\" },\n  { value: \"financial-operations\", label: \"Financial Operations\" },\n  { value: \"real-estate\", label: \"Real Estate\" },\n  { value: \"project-management\", label: \"Project Management\" },\n];\n\nconst DEAL_SIZE_OPTIONS = [\n  { value: \"0-500k\", label: \"$0 - $500K\" },\n  { value: \"500k-5m\", label: \"$500K - $5M\" },\n  { value: \"5m-10m\", label: \"$5M - $10M\" },\n  { value: \"10m-100m\", label: \"$10M - $100M\" },\n  { value: \"100m+\", label: \"$100M+\" },\n];\n\nexport function SurveySettings() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const [dealType, setDealType] = useState<string | null>(null);\n  const [dealTypeOther, setDealTypeOther] = useState<string | null>(null);\n  const [dealSize, setDealSize] = useState<string | null>(null);\n  const [isLoading, setIsLoading] = useState(true);\n  const [step, setStep] = useState<1 | 2 | 3>(1);\n  const [editDealType, setEditDealType] = useState<string | null>(null);\n  const [editDealTypeOther, setEditDealTypeOther] = useState<string>(\"\");\n  const [showOtherInput, setShowOtherInput] = useState(false);\n  const [isSaving, setIsSaving] = useState(false);\n\n  useEffect(() => {\n    const fetchSurvey = async () => {\n      if (!teamId) return;\n\n      try {\n        const response = await fetch(`/api/teams/${teamId}/survey`);\n        if (response.ok) {\n          const data = await response.json();\n          setDealType(data.dealType);\n          setDealTypeOther(data.dealTypeOther);\n          setDealSize(data.dealSize);\n          \n          // Set initial step based on existing data\n          if (data.dealType && (data.dealSize || data.dealType === \"project-management\")) {\n            setStep(3); // Show completed state\n          } else if (data.dealType) {\n            setEditDealType(data.dealType);\n            setEditDealTypeOther(data.dealTypeOther || \"\");\n            setStep(2); // Show deal size question\n          } else {\n            setStep(1); // Show deal type question\n          }\n        }\n      } catch (error) {\n        console.error(\"Failed to fetch survey data:\", error);\n      } finally {\n        setIsLoading(false);\n      }\n    };\n\n    fetchSurvey();\n  }, [teamId]);\n\n  const getDealTypeLabel = (value: string | null, otherText?: string | null) => {\n    if (!value) return null;\n    if (value === \"other\" && otherText) {\n      return `Other: ${otherText}`;\n    }\n    return DEAL_TYPE_OPTIONS.find((opt) => opt.value === value)?.label || value;\n  };\n\n  const getDealSizeLabel = (value: string | null) => {\n    if (!value) return null;\n    return DEAL_SIZE_OPTIONS.find((opt) => opt.value === value)?.label || value;\n  };\n\n  const handleEdit = () => {\n    setEditDealType(dealType);\n    setEditDealTypeOther(dealTypeOther || \"\");\n    setShowOtherInput(false);\n    setStep(1);\n  };\n\n  const handleDealTypeSelect = async (value: string) => {\n    setEditDealType(value);\n    setShowOtherInput(false);\n    \n    if (value === \"project-management\") {\n      // Project management doesn't need deal size - save directly\n      await handleSave(value, null, null);\n    } else {\n      setStep(2);\n    }\n  };\n\n  const handleOtherSubmit = () => {\n    if (!editDealTypeOther.trim()) return;\n    setEditDealType(\"other\");\n    setShowOtherInput(false);\n    setStep(2);\n  };\n\n  const handleDealSizeSelect = async (value: string) => {\n    await handleSave(editDealType, value, editDealTypeOther || null);\n  };\n\n  const handleSave = async (\n    type?: string | null,\n    size?: string | null,\n    otherText?: string | null,\n  ) => {\n    const finalDealType = type || editDealType;\n    if (!finalDealType || !teamId) return;\n\n    setIsSaving(true);\n    try {\n      const response = await fetch(`/api/teams/${teamId}/survey`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          dealType: finalDealType,\n          dealTypeOther: otherText,\n          dealSize: size,\n        }),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to save\");\n      }\n\n      setDealType(finalDealType);\n      setDealTypeOther(otherText || null);\n      setDealSize(size || null);\n      setStep(3);\n      toast.success(\"Survey answers saved!\");\n    } catch (error) {\n      console.error(\"Failed to save survey:\", error);\n      toast.error(\"Failed to save changes\");\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const getDealSizeQuestion = () => {\n    switch (editDealType) {\n      case \"startup-fundraising\":\n      case \"fund-management\":\n        return \"How much are you raising?\";\n      case \"mergers-acquisitions\":\n      case \"real-estate\":\n      case \"financial-operations\":\n        return \"What's the deal size?\";\n      default:\n        return \"What's the typical deal size?\";\n    }\n  };\n\n  return (\n    <Card id=\"team-survey\">\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div>\n            <CardTitle>Team Survey</CardTitle>\n            <CardDescription>\n              This will help us tailor your Papermark experience\n            </CardDescription>\n          </div>\n          {step === 3 && (\n            <Button variant=\"outline\" size=\"sm\" onClick={handleEdit}>\n              <PencilIcon className=\"mr-2 h-4 w-4\" />\n              Edit\n            </Button>\n          )}\n        </div>\n      </CardHeader>\n      <CardContent>\n        {isLoading ? (\n          <p className=\"text-sm text-muted-foreground\">Loading...</p>\n        ) : step === 1 ? (\n          <>\n            <div className=\"mb-4\">\n              <h3 className=\"text-lg font-semibold\">What do you use Papermark for?</h3>\n            </div>\n\n            <div className=\"grid gap-2\">\n              {DEAL_TYPE_OPTIONS.map((option) => (\n                <button\n                  key={option.value}\n                  onClick={() => handleDealTypeSelect(option.value)}\n                  disabled={isSaving}\n                  className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50\"\n                >\n                  <span className=\"font-medium\">{option.label}</span>\n                </button>\n              ))}\n              {/* Other - inline input */}\n              {showOtherInput ? (\n                <div className=\"flex gap-2\">\n                  <input\n                    type=\"text\"\n                    value={editDealTypeOther}\n                    onChange={(e) => setEditDealTypeOther(e.target.value)}\n                    placeholder=\"Please specify...\"\n                    className=\"flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary\"\n                    autoFocus\n                    onKeyDown={(e) => {\n                      if (e.key === \"Enter\" && editDealTypeOther.trim()) {\n                        handleOtherSubmit();\n                      }\n                    }}\n                  />\n                  <button\n                    onClick={handleOtherSubmit}\n                    disabled={!editDealTypeOther.trim() || isSaving}\n                    className=\"rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50\"\n                  >\n                    →\n                  </button>\n                </div>\n              ) : (\n                <button\n                  onClick={() => setShowOtherInput(true)}\n                  disabled={isSaving}\n                  className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50\"\n                >\n                  <span className=\"font-medium\">Other</span>\n                </button>\n              )}\n            </div>\n          </>\n        ) : step === 2 ? (\n          <>\n            <div className=\"mb-4\">\n              <h3 className=\"text-lg font-semibold\">\n                {getDealSizeQuestion()}\n              </h3>\n            </div>\n\n            <div className=\"grid gap-2\">\n              {DEAL_SIZE_OPTIONS.map((option) => (\n                <button\n                  key={option.value}\n                  onClick={() => handleDealSizeSelect(option.value)}\n                  disabled={isSaving}\n                  className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50\"\n                >\n                  <span className=\"font-medium\">{option.label}</span>\n                </button>\n              ))}\n            </div>\n          </>\n        ) : (\n          <>\n            <div className=\"mb-4 flex items-center gap-3\">\n              <CheckCircleIcon className=\"h-6 w-6 text-green-500\" />\n              <h3 className=\"text-lg font-semibold\">Thanks for sharing!</h3>\n            </div>\n\n            <div className=\"space-y-3\">\n              <div className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2\">\n                <span className=\"text-sm text-muted-foreground\">Use case</span>\n                <span className=\"text-sm font-medium\">\n                  {getDealTypeLabel(dealType, dealTypeOther)}\n                </span>\n              </div>\n              {dealSize && (\n                <div className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2\">\n                  <span className=\"text-sm text-muted-foreground\">Deal size</span>\n                  <span className=\"text-sm font-medium\">\n                    {getDealSizeLabel(dealSize)}\n                  </span>\n                </div>\n              )}\n            </div>\n          </>\n        )}\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/settings/timezone-selector.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useTeamSettings } from \"@/lib/swr/use-team-settings\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\n// Common timezone options with IANA identifiers\nconst TIMEZONE_OPTIONS = [\n  { value: \"Etc/UTC\", label: \"UTC (Coordinated Universal Time)\" },\n  { value: \"America/New_York\", label: \"America/New York (EST/EDT)\" },\n  { value: \"America/Chicago\", label: \"America/Chicago (CST/CDT)\" },\n  { value: \"America/Denver\", label: \"America/Denver (MST/MDT)\" },\n  { value: \"America/Los_Angeles\", label: \"America/Los Angeles (PST/PDT)\" },\n  { value: \"America/Toronto\", label: \"America/Toronto (EST/EDT)\" },\n  { value: \"America/Vancouver\", label: \"America/Vancouver (PST/PDT)\" },\n  { value: \"America/Mexico_City\", label: \"America/Mexico City (CST/CDT)\" },\n  { value: \"America/Sao_Paulo\", label: \"America/São Paulo (BRT)\" },\n  {\n    value: \"America/Argentina/Buenos_Aires\",\n    label: \"America/Buenos Aires (ART)\",\n  },\n  { value: \"America/Santiago\", label: \"America/Santiago (CLT/CLST)\" },\n  { value: \"America/Bogota\", label: \"America/Bogotá (COT)\" },\n  { value: \"America/Lima\", label: \"America/Lima (PET)\" },\n  { value: \"Europe/London\", label: \"Europe/London (GMT/BST)\" },\n  { value: \"Europe/Paris\", label: \"Europe/Paris (CET/CEST)\" },\n  { value: \"Europe/Berlin\", label: \"Europe/Berlin (CET/CEST)\" },\n  { value: \"Europe/Madrid\", label: \"Europe/Madrid (CET/CEST)\" },\n  { value: \"Europe/Rome\", label: \"Europe/Rome (CET/CEST)\" },\n  { value: \"Europe/Amsterdam\", label: \"Europe/Amsterdam (CET/CEST)\" },\n  { value: \"Europe/Zurich\", label: \"Europe/Zurich (CET/CEST)\" },\n  { value: \"Europe/Stockholm\", label: \"Europe/Stockholm (CET/CEST)\" },\n  { value: \"Europe/Warsaw\", label: \"Europe/Warsaw (CET/CEST)\" },\n  { value: \"Europe/Moscow\", label: \"Europe/Moscow (MSK)\" },\n  { value: \"Europe/Istanbul\", label: \"Europe/Istanbul (TRT)\" },\n  { value: \"Asia/Dubai\", label: \"Asia/Dubai (GST)\" },\n  { value: \"Asia/Kolkata\", label: \"Asia/Kolkata (IST)\" },\n  { value: \"Asia/Bangkok\", label: \"Asia/Bangkok (ICT)\" },\n  { value: \"Asia/Singapore\", label: \"Asia/Singapore (SGT)\" },\n  { value: \"Asia/Hong_Kong\", label: \"Asia/Hong Kong (HKT)\" },\n  { value: \"Asia/Shanghai\", label: \"Asia/Shanghai (CST)\" },\n  { value: \"Asia/Tokyo\", label: \"Asia/Tokyo (JST)\" },\n  { value: \"Asia/Seoul\", label: \"Asia/Seoul (KST)\" },\n  { value: \"Australia/Sydney\", label: \"Australia/Sydney (AEST/AEDT)\" },\n  { value: \"Australia/Melbourne\", label: \"Australia/Melbourne (AEST/AEDT)\" },\n  { value: \"Australia/Perth\", label: \"Australia/Perth (AWST)\" },\n  { value: \"Pacific/Auckland\", label: \"Pacific/Auckland (NZST/NZDT)\" },\n  { value: \"Pacific/Honolulu\", label: \"Pacific/Honolulu (HST)\" },\n  { value: \"Africa/Johannesburg\", label: \"Africa/Johannesburg (SAST)\" },\n  { value: \"Africa/Cairo\", label: \"Africa/Cairo (EET)\" },\n  { value: \"Africa/Lagos\", label: \"Africa/Lagos (WAT)\" },\n];\n\nexport function TimezoneSelector() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { settings: teamSettings } = useTeamSettings(teamId);\n\n  const [selectedTimezone, setSelectedTimezone] = useState<string>(\"Etc/UTC\");\n  const [saving, setSaving] = useState(false);\n\n  useEffect(() => {\n    if (teamSettings?.timezone) {\n      setSelectedTimezone(teamSettings.timezone);\n    }\n  }, [teamSettings?.timezone]);\n\n  const handleSave = async () => {\n    if (!teamId) return;\n\n    setSaving(true);\n    try {\n      const response = await fetch(`/api/teams/${teamId}/settings`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ timezone: selectedTimezone }),\n      });\n\n      if (!response.ok) {\n        const { error } = await response.json();\n        throw new Error(error || \"Failed to update timezone\");\n      }\n\n      await Promise.all([\n        mutate(`/api/teams/${teamId}/settings`),\n        mutate(`/api/teams/${teamId}`),\n      ]);\n\n      toast.success(\"Timezone updated successfully\");\n    } catch (error) {\n      toast.error((error as Error).message || \"Failed to update timezone\");\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  const hasChanged = teamSettings?.timezone !== selectedTimezone;\n\n  return (\n    <Card className=\"bg-transparent\">\n      <CardHeader>\n        <CardTitle>Analytics Timezone</CardTitle>\n        <CardDescription>\n          Set the timezone for your team&apos;s analytics. This affects how\n          visit data is grouped by day in charts and reports.\n        </CardDescription>\n      </CardHeader>\n      <CardContent>\n        <Select value={selectedTimezone} onValueChange={setSelectedTimezone}>\n          <SelectTrigger className=\"w-full max-w-md\">\n            <SelectValue placeholder=\"Select a timezone\" />\n          </SelectTrigger>\n          <SelectContent className=\"max-h-[300px]\">\n            {TIMEZONE_OPTIONS.map((tz) => (\n              <SelectItem key={tz.value} value={tz.value}>\n                {tz.label}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      </CardContent>\n      <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n        <p className=\"text-sm text-muted-foreground\">\n          Uses IANA timezone identifiers. Analytics charts will display data\n          based on this timezone.\n        </p>\n        <div className=\"shrink-0\">\n          <Button loading={saving} disabled={!hasChanged || saving} onClick={handleSave}>\n            Save Changes\n          </Button>\n        </div>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/shared/dealflow-popup.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { CheckCircleIcon, XIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nconst DEAL_TYPE_OPTIONS = [\n  { value: \"startup-fundraising\", label: \"Startup Fundraising\" },\n  { value: \"fund-management\", label: \"Fundraising & Reporting\" },\n  { value: \"mergers-acquisitions\", label: \"Mergers & Acquisitions\" },\n  { value: \"financial-operations\", label: \"Financial Operations\" },\n  { value: \"real-estate\", label: \"Real Estate\" },\n  { value: \"project-management\", label: \"Project Management\" },\n];\n\nconst DEAL_SIZE_OPTIONS = [\n  { value: \"0-500k\", label: \"$0 - $500K\" },\n  { value: \"500k-5m\", label: \"$500K - $5M\" },\n  { value: \"5m-10m\", label: \"$5M - $10M\" },\n  { value: \"10m-100m\", label: \"$10M - $100M\" },\n  { value: \"100m+\", label: \"$100M+\" },\n];\n\nexport function DealflowPopup() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const [isOpen, setIsOpen] = useState(false);\n  const [step, setStep] = useState(1);\n  const [dealType, setDealType] = useState<string | null>(null);\n  const [dealTypeOther, setDealTypeOther] = useState<string>(\"\");\n  const [showOtherInput, setShowOtherInput] = useState(false);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const teamId = teamInfo?.currentTeam?.id;\n  const storageKey = `dealflow-survey-dismissed-${teamId}`;\n\n  // Check if we're on onboarding/welcome pages\n  const isOnboarding = router.pathname.startsWith(\"/welcome\");\n\n  useEffect(() => {\n    if (isOnboarding) return;\n    if (!teamId) return;\n\n    const dismissed = localStorage.getItem(storageKey);\n    if (dismissed) return;\n\n    let timeoutId: NodeJS.Timeout;\n\n    const checkSurvey = async () => {\n      try {\n        const response = await fetch(`/api/teams/${teamId}/survey`);\n        if (response.ok) {\n          const data = await response.json();\n\n          if (data.dismissed) {\n            localStorage.setItem(storageKey, \"true\");\n            return;\n          }\n\n          if (!data.dealType) {\n            timeoutId = setTimeout(() => {\n              setStep(1);\n              setIsOpen(true);\n            }, 2000);\n          } else if (!data.dealSize && data.dealType !== \"project-management\") {\n            timeoutId = setTimeout(() => {\n              setDealType(data.dealType);\n              setDealTypeOther(data.dealTypeOther || \"\");\n              setStep(2);\n              setIsOpen(true);\n            }, 2000);\n          }\n        }\n      } catch (error) {\n        console.error(\"Failed to check survey status:\", error);\n      }\n    };\n\n    checkSurvey();\n\n    return () => clearTimeout(timeoutId);\n  }, [teamId, storageKey, isOnboarding]);\n\n  const handleDealTypeSelect = (value: string) => {\n    setDealType(value);\n    setShowOtherInput(false);\n    if (value === \"project-management\") {\n      // Project management doesn't need a second step - go straight to thank you\n      handleSubmit(value, null, null);\n    } else {\n      setStep(2);\n    }\n  };\n\n  const handleOtherSubmit = () => {\n    if (!dealTypeOther.trim()) return;\n    setDealType(\"other\");\n    setShowOtherInput(false);\n    // After entering \"Other\" text, go to deal size question\n    setStep(2);\n  };\n\n  const handleDealSizeSelect = async (value: string) => {\n    await handleSubmit(dealType, value, dealTypeOther || null);\n  };\n\n  const handleSubmit = async (\n    type?: string | null,\n    size?: string | null,\n    otherText?: string | null,\n  ) => {\n    const finalDealType = type || dealType;\n    if (!finalDealType) return;\n\n    setIsSubmitting(true);\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/survey`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            dealType: finalDealType,\n            dealTypeOther:\n              otherText !== undefined\n                ? otherText\n                : dealTypeOther || null,\n            dealSize: size ?? null,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to save response\");\n      }\n\n      localStorage.setItem(storageKey, \"true\");\n      setStep(3); // Go to thank you screen\n    } catch (error) {\n      console.error(\"Failed to save survey response:\", error);\n      toast.error(\"Failed to save your response\");\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const handleClose = () => {\n    setIsOpen(false);\n  };\n\n  const handleDismiss = async () => {\n    localStorage.setItem(storageKey, \"true\");\n    setIsOpen(false);\n\n    toast(\"Survey dismissed\", {\n      description: \"You can complete it anytime from Settings → General.\",\n      action: {\n        label: \"Go to Settings\",\n        onClick: () => router.push(\"/settings/general#team-survey\"),\n      },\n    });\n\n    if (teamId) {\n      try {\n        await fetch(`/api/teams/${teamId}/survey`, {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify({\n            dismissed: true,\n            dismissedAt: new Date().toISOString(),\n          }),\n        });\n      } catch (error) {\n        console.error(\"Failed to save survey dismissal:\", error);\n      }\n    }\n  };\n\n  const getDealSizeQuestion = () => {\n    switch (dealType) {\n      case \"startup-fundraising\":\n      case \"fund-management\":\n        return \"How much are you raising?\";\n      case \"mergers-acquisitions\":\n      case \"real-estate\":\n      case \"financial-operations\":\n        return \"What's the deal size?\";\n      default:\n        return \"What's the typical deal size?\";\n    }\n  };\n\n  if (!isOpen) return null;\n\n  return (\n    <div className=\"fixed bottom-4 right-4 z-50 w-full max-w-sm animate-in fade-in slide-in-from-bottom-4 duration-300\">\n      <div className=\"rounded-lg border-2 border-black bg-background p-4 shadow-lg\">\n        {step === 1 ? (\n          <>\n            <div className=\"mb-4 flex items-start justify-between\">\n              <div>\n                <h3 className=\"text-lg font-semibold\">What do you use Papermark for?</h3>\n               \n              </div>\n              <button\n                onClick={handleDismiss}\n                className=\"rounded-full p-1 hover:bg-muted\"\n              >\n                <XIcon className=\"h-4 w-4 text-muted-foreground\" />\n              </button>\n            </div>\n\n            <div className=\"grid gap-2\">\n              {DEAL_TYPE_OPTIONS.map((option) => (\n                <button\n                  key={option.value}\n                  onClick={() => handleDealTypeSelect(option.value)}\n                  disabled={isSubmitting}\n                  className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50\"\n                >\n                  <span className=\"font-medium\">{option.label}</span>\n                </button>\n              ))}\n              {/* Other - inline input */}\n              {showOtherInput ? (\n                <div className=\"flex gap-2\">\n                  <input\n                    type=\"text\"\n                    value={dealTypeOther}\n                    onChange={(e) => setDealTypeOther(e.target.value)}\n                    placeholder=\"Please specify...\"\n                    className=\"flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary\"\n                    autoFocus\n                    onKeyDown={(e) => {\n                      if (e.key === \"Enter\" && dealTypeOther.trim()) {\n                        handleOtherSubmit();\n                      }\n                    }}\n                  />\n                  <button\n                    onClick={handleOtherSubmit}\n                    disabled={!dealTypeOther.trim() || isSubmitting}\n                    className=\"rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50\"\n                  >\n                    →\n                  </button>\n                </div>\n              ) : (\n                <button\n                  onClick={() => setShowOtherInput(true)}\n                  disabled={isSubmitting}\n                  className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50\"\n                >\n                  <span className=\"font-medium\">Other</span>\n                </button>\n              )}\n            </div>\n\n            <p className=\"mt-3 text-xs text-muted-foreground\">\n              This will help us tailor your Papermark experience\n            </p>\n          </>\n        ) : step === 2 ? (\n          <>\n            <div className=\"mb-4 flex items-start justify-between\">\n              <div>\n                <h3 className=\"text-lg font-semibold\">\n                  {getDealSizeQuestion()}\n                </h3>\n              </div>\n              <button\n                onClick={handleDismiss}\n                className=\"rounded-full p-1 hover:bg-muted\"\n              >\n                <XIcon className=\"h-4 w-4 text-muted-foreground\" />\n              </button>\n            </div>\n\n            <div className=\"grid gap-2\">\n              {DEAL_SIZE_OPTIONS.map((option) => (\n                <button\n                  key={option.value}\n                  onClick={() => handleDealSizeSelect(option.value)}\n                  disabled={isSubmitting}\n                  className=\"flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50\"\n                >\n                  <span className=\"font-medium\">{option.label}</span>\n                </button>\n              ))}\n            </div>\n\n            <p className=\"mt-3 text-xs text-muted-foreground\">\n              This will help us tailor your Papermark experience\n            </p>\n          </>\n        ) : (\n          <>\n            <div className=\"mb-4 flex items-start justify-between\">\n              <div className=\"flex items-center gap-3\">\n                <CheckCircleIcon className=\"h-6 w-6 text-green-500\" />\n                <h3 className=\"text-lg font-semibold\">Thanks for sharing!</h3>\n              </div>\n              <button\n                onClick={handleClose}\n                className=\"rounded-full p-1 hover:bg-muted\"\n              >\n                <XIcon className=\"h-4 w-4 text-muted-foreground\" />\n              </button>\n            </div>\n\n            <p className=\"mb-4 text-sm text-muted-foreground\">\n              You can find and update your responses in settings.\n            </p>\n\n            <button\n              onClick={() => {\n                router.push(\"/settings/general#team-survey\");\n                handleClose();\n              }}\n              className=\"w-full rounded-lg border border-black bg-white px-3 py-2 text-sm font-medium text-black transition-colors hover:bg-gray-50\"\n            >\n              Go to Team Survey\n            </button>\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/advanced-sheet.tsx",
    "content": "export default function AdvancedSheet({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"52\"\n      height=\"52\"\n      viewBox=\"0 0 52 52\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"3\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path d=\"M45.5 30V10.8333C45.5 8.4401 43.5599 6.5 41.1667 6.5H10.8333C8.4401 6.5 6.5 8.4401 6.5 10.8333V41.1667C6.5 43.5599 8.4401 45.5 10.8333 45.5H32.3187\" />\n      <path d=\"M6.5 19.5H45.5\" />\n      <path d=\"M6.5 32.5H32.5\" />\n      <path d=\"M19.5 19.5V45.5\" />\n      <path d=\"M32.5 19.5V32.5\" />\n      <path d=\"M31.0034 40.0026C30.8142 40.0032 30.6286 39.9502 30.4684 39.8496C30.3081 39.749 30.1796 39.605 30.098 39.4343C30.0163 39.2636 29.9847 39.0732 30.0069 38.8853C30.0291 38.6973 30.1042 38.5196 30.2234 38.3726L40.1234 28.1726C40.1977 28.0869 40.2989 28.029 40.4104 28.0083C40.5219 27.9877 40.6371 28.0056 40.7371 28.0591C40.8371 28.1126 40.916 28.1985 40.9607 28.3027C41.0055 28.4069 41.0135 28.5233 40.9834 28.6326L39.0634 34.6526C39.0068 34.8041 38.9878 34.9671 39.008 35.1276C39.0282 35.2881 39.0871 35.4413 39.1795 35.574C39.2719 35.7068 39.3952 35.8151 39.5387 35.8898C39.6822 35.9644 39.8417 36.0031 40.0034 36.0026H47.0034C47.1926 36.002 47.3782 36.055 47.5385 36.1556C47.6987 36.2562 47.8272 36.4002 47.9089 36.5709C47.9905 36.7416 48.0221 36.932 47.9999 37.1199C47.9777 37.3079 47.9026 37.4856 47.7834 37.6326L37.8834 47.8326C37.8091 47.9183 37.708 47.9762 37.5964 47.9969C37.4849 48.0175 37.3697 47.9996 37.2697 47.9461C37.1697 47.8926 37.0908 47.8067 37.0461 47.7025C37.0013 47.5983 36.9933 47.482 37.0234 47.3726L38.9434 41.3526C39 41.2011 39.019 41.0381 38.9988 40.8776C38.9786 40.7171 38.9198 40.5639 38.8273 40.4312C38.7349 40.2984 38.6116 40.1901 38.4681 40.1154C38.3246 40.0408 38.1652 40.0021 38.0034 40.0026H31.0034Z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/alert-circle.tsx",
    "content": "export default function AlertCircle({\n  className,\n  fill,\n}: {\n  className?: string;\n  fill?: string;\n}) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill={fill || \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" />\n      <line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/arrow-up.tsx",
    "content": "export default function ArrowUp({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"m5 12 7-7 7 7\" />\n      <path d=\"M12 19V5\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/badge-check.tsx",
    "content": "export default function BadgeCheck({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z\" />\n      <path d=\"m9 12 2 2 4-4\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/bar-chart.tsx",
    "content": "export default function BarChart({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <line x1=\"12\" x2=\"12\" y1=\"20\" y2=\"10\" />\n      <line x1=\"18\" x2=\"18\" y1=\"20\" y2=\"4\" />\n      <line x1=\"6\" x2=\"6\" y1=\"20\" y2=\"16\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/check-cirlce-2.tsx",
    "content": "export default function CheckCircle2({\n  className,\n  fill,\n}: {\n  className?: string;\n  fill?: string;\n}) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill={fill || \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z\" />\n      <path d=\"m9 12 2 2 4-4\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/check.tsx",
    "content": "export default function Check({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <polyline points=\"20 6 9 17 4 12\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/chevron-down.tsx",
    "content": "export default function ChevronDown({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"m6 9 6 6 6-6\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/chevron-right.tsx",
    "content": "export default function ChevronRight({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"m9 18 6-6-6-6\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/chevron-up.tsx",
    "content": "export default function ChevronUp({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"m18 15-6-6-6 6\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/circle.tsx",
    "content": "export default function Circle({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/cloud-download-off.tsx",
    "content": "export default function CloudDownloadOff({\n  className,\n}: {\n  className?: string;\n}) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <path\n        d=\"M12 17V12\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M12 7V3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M6 11L12 17L14.5 14.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M17 12L18 11\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M19 21H5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M2 2L22 22\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/copy-right.tsx",
    "content": "export default function CopyRight({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"M14.83 14.83a4 4 0 1 1 0-5.66\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/copy.tsx",
    "content": "export default function Copy({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\" />\n      <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/external-link.tsx",
    "content": "export default function CheckCircle2({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\" />\n      <polyline points=\"15 3 21 3 21 9\" />\n      <line x1=\"10\" x2=\"21\" y1=\"14\" y2=\"3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/eye-off.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport default function EyeOff({\n  className,\n  ...props\n}: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n      {...props}\n    >\n      <path d=\"M9.88 9.88a3 3 0 1 0 4.24 4.24\" />\n      <path d=\"M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68\" />\n      <path d=\"M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61\" />\n      <line x1=\"2\" x2=\"22\" y1=\"2\" y2=\"22\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/eye.tsx",
    "content": "import type { SVGProps } from \"react\";\n\nexport default function Eye({ className, ...props }: SVGProps<SVGSVGElement>) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n      {...props}\n    >\n      <path d=\"M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z\" />\n      <circle cx=\"12\" cy=\"12\" r=\"3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/facebook.tsx",
    "content": "export function Facebook({\n  className,\n  fill = \"#1977f3\",\n}: {\n  className?: string;\n  fill?: string;\n}) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"1365.12\"\n      height=\"1365.12\"\n      viewBox=\"0 0 14222 14222\"\n      className={className}\n    >\n      <circle cx=\"7111\" cy=\"7112\" r=\"7111\" fill={fill} />\n      <path\n        d=\"M9879 9168l315-2056H8222V5778c0-562 275-1111 1159-1111h897V2917s-814-139-1592-139c-1624 0-2686 984-2686 2767v1567H4194v2056h1806v4969c362 57 733 86 1111 86s749-30 1111-86V9168z\"\n        fill=\"#fff\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/file-up.tsx",
    "content": "export default function FileUp({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z\" />\n      <polyline points=\"14 2 14 8 20 8\" />\n      <path d=\"M12 12v6\" />\n      <path d=\"m15 15-3-3-3 3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/cad.tsx",
    "content": "export default function CadIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#a)\">\n        <rect\n          x=\"57\"\n          y=\"59\"\n          width=\"454\"\n          height=\"462\"\n          fill={isLight ? \"#000\" : \"#fff\"}\n        />\n        <path\n          opacity=\"0.999\"\n          fill-rule=\"evenodd\"\n          clip-rule=\"evenodd\"\n          d=\"M77.5 31.5H497.5C521.833 37.8333 537.167 53.1667 543.5 77.5V497.5C537.167 521.833 521.833 537.167 497.5 543.5H77.5C53.1667 537.167 37.8333 521.833 31.5 497.5V77.5C37.8333 53.1667 53.1667 37.8333 77.5 31.5ZM168.092 126.004C178.391 115.754 192.33 110 206.861 110C221.392 110 235.331 115.754 245.631 126.004L245.658 126.031L266.605 146.978L266.674 147.047C266.698 147.071 266.721 147.094 266.744 147.117L303.972 184.345L323.411 164.901C323.855 164.314 324.344 163.75 324.88 163.214C325.414 162.68 325.977 162.191 326.564 161.748L360.251 128.053L360.254 128.051C371.802 116.505 387.464 110.02 403.794 110.022C420.123 110.024 435.784 116.513 447.329 128.061C458.874 139.61 465.359 155.272 465.357 171.601C465.355 187.931 458.866 203.591 447.318 215.137L413.652 248.808C413.206 249.4 412.713 249.969 412.173 250.508C411.635 251.047 411.067 251.539 410.476 251.985L391.045 271.418L428.233 308.607L428.289 308.662L428.34 308.712C428.375 308.748 428.41 308.783 428.445 308.819L449.356 329.73C470.801 351.175 470.801 385.878 449.356 407.323L407.323 449.356C385.878 470.801 351.175 470.801 329.73 449.356L271.427 391.054L231.546 430.942L231.527 430.961C225.924 436.545 219.037 440.669 211.468 442.971L211.456 442.975L141.083 464.315L141.046 464.326C136.882 465.578 132.457 465.679 128.241 464.619C124.025 463.559 120.174 461.377 117.097 458.305C114.02 455.234 111.833 451.386 110.766 447.172C109.699 442.957 109.793 438.532 111.038 434.367L111.058 434.301L132.414 363.944L132.427 363.901C134.748 356.331 138.893 349.448 144.497 343.856L144.51 343.843L184.359 303.985L126.031 245.658L126.004 245.631C115.754 235.331 110 221.392 110 206.861C110 192.33 115.754 178.391 126.004 168.092L126.031 168.064L168.064 126.031L168.092 126.004ZM400.748 216.455L424.691 192.509L424.694 192.507C430.24 186.962 433.356 179.44 433.357 171.597C433.358 163.755 430.244 156.233 424.699 150.686C419.154 145.14 411.632 142.023 403.79 142.022C395.947 142.021 388.425 145.136 382.878 150.681L358.929 174.636L400.748 216.455ZM336.304 197.266L378.123 239.084L208.935 408.297L208.924 408.308C207.033 410.19 204.709 411.58 202.157 412.356L145.991 429.388L163.021 373.281L163.026 373.264C163.812 370.711 165.213 368.389 167.104 366.503L167.122 366.485L336.304 197.266ZM255.361 180.989L281.347 206.975L206.983 281.355L148.686 223.058L148.674 223.046C144.399 218.744 142 212.926 142 206.861C142 200.796 144.4 194.977 148.675 190.675L148.686 190.664L190.664 148.686L190.675 148.675C194.977 144.4 200.796 142 206.861 142C212.926 142 218.744 144.399 223.046 148.674L223.058 148.686L232.733 158.361L211.714 179.38C205.466 185.629 205.466 195.759 211.714 202.008C217.962 208.256 228.093 208.256 234.341 202.008L255.361 180.989ZM294.053 368.425L368.419 294.047L394.398 320.027L373.379 341.045C367.131 347.294 367.131 357.424 373.379 363.673C379.628 369.921 389.758 369.921 396.007 363.673L417.026 342.654L426.729 352.357C435.677 361.305 435.677 375.748 426.729 384.696L384.696 426.729C375.748 435.677 361.305 435.677 352.357 426.729L294.053 368.425Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"a\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/docs.tsx",
    "content": "export default function DocsIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#a)\">\n        <path fill={isLight ? \"#000\" : \"#fff\"} d=\"M57 59h454v462H57z\" />\n        <path\n          opacity=\".999\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M497.5 31.5h-420c-24.333 6.333-39.667 21.667-46 46v420c6.333 24.333 21.667 39.667 46 46h420c24.333-6.333 39.667-21.667 46-46v-420c-6.333-24.333-21.667-39.667-46-46ZM129.465 268.876h-20v40h357.666v-40H129.465ZM109.87 163.648h357.665v40H109.87v-40ZM129.465 373h-20v40h253.542v-40H129.465Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"a\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/image.tsx",
    "content": "export default function ImageFileIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#a)\">\n        <rect\n          x=\"57\"\n          y=\"59\"\n          width=\"454\"\n          height=\"462\"\n          fill={isLight ? \"#000\" : \"#fff\"}\n        />\n        <path\n          opacity=\"0.999\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M497.5 31.5H77.5C53.1667 37.8333 37.8333 53.1667 31.5 77.5V497.5C37.8333 521.833 53.1667 537.167 77.5 543.5H497.5C521.833 537.167 537.167 521.833 543.5 497.5V77.5C537.167 53.1667 521.833 37.8333 497.5 31.5ZM147 165.889C147 155.457 155.457 147 165.889 147H410.111C420.543 147 429 155.457 429 165.889V301.706L402.48 275.186L402.478 275.185C392.935 265.644 379.994 260.285 366.5 260.285C353.006 260.285 340.065 265.644 330.522 275.185L330.52 275.186L176.706 429H165.889C155.457 429 147 420.543 147 410.111V165.889ZM183.412 461C183.359 461 183.307 461 183.255 461H165.889C137.784 461 115 438.216 115 410.111V165.889C115 137.784 137.784 115 165.889 115H410.111C438.216 115 461 137.784 461 165.889V340.318C461 340.328 461 340.339 461 340.349V410.111C461 438.216 438.216 461 410.111 461H183.412ZM429 346.961V410.111C429 420.543 420.543 429 410.111 429H221.961L353.146 297.815L353.147 297.814C356.689 294.274 361.492 292.285 366.5 292.285C371.508 292.285 376.311 294.274 379.853 297.814L379.854 297.815L429 346.961ZM235.667 216.778C225.235 216.778 216.778 225.235 216.778 235.667C216.778 246.099 225.235 254.556 235.667 254.556C246.099 254.556 254.555 246.099 254.555 235.667C254.555 225.235 246.099 216.778 235.667 216.778ZM184.778 235.667C184.778 207.562 207.561 184.778 235.667 184.778C263.772 184.778 286.555 207.562 286.555 235.667C286.555 263.772 263.772 286.556 235.667 286.556C207.561 286.556 184.778 263.772 184.778 235.667Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"a\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/map.tsx",
    "content": "export default function MapIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#clip0_map)\">\n        <rect\n          x=\"57\"\n          y=\"59\"\n          width=\"454\"\n          height=\"462\"\n          fill={isLight ? \"#000\" : \"#fff\"}\n        />\n        <path\n          opacity=\"0.999\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M497.5 31.5H77.5C53.1667 37.8333 37.8333 53.1667 31.5 77.5V497.5C37.8333 521.833 53.1667 537.167 77.5 543.5H497.5C521.833 537.167 537.167 521.833 543.5 497.5V77.5C537.167 53.1667 521.833 37.8333 497.5 31.5ZM287.5 117.5C229.5 117.5 182.5 164.5 182.5 222.5C182.5 299.5 287.5 417.5 287.5 417.5C287.5 417.5 392.5 299.5 392.5 222.5C392.5 164.5 345.5 117.5 287.5 117.5ZM287.5 262.5C265.5 262.5 247.5 244.5 247.5 222.5C247.5 200.5 265.5 182.5 287.5 182.5C309.5 182.5 327.5 200.5 327.5 222.5C327.5 244.5 309.5 262.5 287.5 262.5Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"clip0_map\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/notion.tsx",
    "content": "// Thanks to trigger.dev for the icon:\n// https://github.com/triggerdotdev/companyicons/blob/c6dcc5b389aa47fd6d8a0d346b48e7099f90703c/src/icons/companies/notion.tsx\nimport React from \"react\";\n\nexport default function NotionIcon(\n  props: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,\n) {\n  return (\n    <svg\n      viewBox=\"0 0 100 100\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      {...props}\n    >\n      <path\n        d=\"M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z\"\n        fill=\"#fff\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z\"\n        fill=\"#000\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/pdf.tsx",
    "content": "export default function PdfIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#a)\">\n        <path fill={isLight ? \"#000\" : \"#fff\"} d=\"M57 59h454v462H57z\" />\n        <path\n          opacity=\".999\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M497.5 31.5h-420c-24.333 6.333-39.667 21.667-46 46v420c6.333 24.333 21.667 39.667 46 46h420c24.333-6.333 39.667-21.667 46-46v-420c-6.333-24.333-21.667-39.667-46-46ZM103 360V215h57.184c10.994 0 20.359 2.1 28.097 6.301 7.738 4.154 13.636 9.936 17.693 17.346 4.105 7.364 6.158 15.86 6.158 25.489 0 9.629-2.076 18.125-6.228 25.488-4.152 7.363-10.168 13.098-18.047 17.205-7.833 4.106-17.316 6.159-28.451 6.159h-25.761V360H103Zm51.452-71.58h-20.807v-48.357h20.665c5.945 0 10.852 1.015 14.721 3.045 3.869 1.982 6.747 4.791 8.634 8.425 1.935 3.587 2.902 7.788 2.902 12.603 0 4.767-.967 8.991-2.902 12.673-1.887 3.635-4.765 6.49-8.634 8.567-3.822 2.03-8.681 3.044-14.579 3.044ZM283.417 360h-51.381V215h51.806c14.579 0 27.13 2.903 37.651 8.708 10.522 5.759 18.613 14.043 24.275 24.852 5.709 10.808 8.564 23.741 8.564 38.798 0 15.105-2.855 28.085-8.564 38.941-5.662 10.856-13.8 19.187-24.416 24.993-10.569 5.805-23.214 8.708-37.935 8.708Zm-20.736-26.267h19.463c9.058 0 16.678-1.605 22.859-4.815 6.228-3.256 10.899-8.283 14.013-15.08 3.161-6.844 4.742-15.671 4.742-26.48 0-10.714-1.581-19.47-4.742-26.267-3.114-6.797-7.761-11.8-13.942-15.009-6.181-3.21-13.801-4.815-22.86-4.815h-19.533v92.466ZM377.032 215v145h30.645v-59.897h58.953v-25.276h-58.953v-34.551H473V215h-95.968Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"a\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/sheet.tsx",
    "content": "export default function SheetIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#a)\">\n        <path fill={isLight ? \"#000\" : \"#fff\"} d=\"M57.5 59.5h454v462h-454z\" />\n        <path\n          opacity=\".999\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M498 32H78c-24.333 6.333-39.667 21.667-46 46v420c6.333 24.333 21.667 39.667 46 46h420c24.333-6.333 39.667-21.667 46-46V78c-6.333-24.333-21.667-39.667-46-46ZM145.135 197h127.95l-.214 76.36H145.135V197Zm-30 89.341a15.103 15.103 0 0 0 0 4.039v104.071c0 8.284 6.716 15 15 15h315.98c8.284 0 15-6.716 15-15V290.38a15.118 15.118 0 0 0 0-4.04V182c0-8.284-6.716-15-15-15h-315.98c-8.284 0-15 6.716-15 15v104.341Zm315.98-12.981V197H303.086l-.215 76.36h128.244Zm-158.328 30-.214 76.091H145.135V303.36h127.652Zm30 0h128.328v76.091H302.573l.214-76.091Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"a\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/slides.tsx",
    "content": "export default function SlidesIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#a)\">\n        <path fill={isLight ? \"#000\" : \"#fff\"} d=\"M57 59h454v462H57z\" />\n        <path\n          opacity=\".999\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M497.5 31.5h-420c-24.333 6.333-39.667 21.667-46 46v420c6.333 24.333 21.667 39.667 46 46h420c24.333-6.333 39.667-21.667 46-46v-420c-6.333-24.333-21.667-39.667-46-46ZM133 160c-19.33 0-35 15.67-35 35v186c0 19.33 15.67 35 35 35h309.347c19.329 0 35-15.67 35-35V195c0-19.33-15.671-35-35-35H133Zm-5 59.347h319.347v136.871H128V219.347Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"a\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/files/video.tsx",
    "content": "export default function VideoIcon({\n  className,\n  isLight = true,\n}: {\n  className?: string;\n  isLight?: boolean;\n}) {\n  return (\n    <svg\n      width=\"576\"\n      height=\"576\"\n      viewBox=\"0 0 576 576\"\n      fill=\"none\"\n      className={className}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <g clipPath=\"url(#clip0_159_5)\">\n        <rect\n          x=\"57\"\n          y=\"59\"\n          width=\"454\"\n          height=\"462\"\n          fill={isLight ? \"#000\" : \"#fff\"}\n        />\n        <path\n          opacity=\"0.999\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M497.5 31.5H77.5C53.1667 37.8333 37.8333 53.1667 31.5 77.5V497.5C37.8333 521.833 53.1667 537.167 77.5 543.5H497.5C521.833 537.167 537.167 521.833 543.5 497.5V77.5C537.167 53.1667 521.833 37.8333 497.5 31.5ZM203.514 117.453C198.584 114.354 192.359 114.179 187.261 116.996C182.164 119.813 179 125.176 179 131V444C179 449.824 182.164 455.187 187.261 458.004C192.359 460.821 198.584 460.646 203.514 457.547L452.514 301.047C457.173 298.119 460 293.002 460 287.5C460 281.998 457.173 276.881 452.514 273.953L203.514 117.453ZM413.933 287.5L211 415.046V159.954L413.933 287.5Z\"\n          fill={isLight ? \"#fff\" : \"#111827\"}\n        />\n      </g>\n      <rect\n        x=\"16\"\n        y=\"16\"\n        width=\"544\"\n        height=\"544\"\n        rx=\"48\"\n        stroke={isLight ? \"#000\" : \"#fff\"}\n        strokeWidth=\"32\"\n      />\n      <defs>\n        <clipPath id=\"clip0_159_5\">\n          <rect\n            x=\"32\"\n            y=\"32\"\n            width=\"512\"\n            height=\"512\"\n            rx=\"32\"\n            fill={isLight ? \"#fff\" : \"#111827\"}\n          />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/folder.tsx",
    "content": "export default function Folder({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <path d=\"M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z\" />\n      <path d=\"M2 10h20\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/github.tsx",
    "content": "import React from \"react\";\n\nfunction GitHubIcon(\n  props: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,\n) {\n  return (\n    <svg fill=\"currentColor\" viewBox=\"0 0 24 24\" {...props}>\n      <path\n        fillRule=\"evenodd\"\n        d=\"M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z\"\n        clipRule=\"evenodd\"\n      />\n    </svg>\n  );\n}\n\nexport default GitHubIcon;\n"
  },
  {
    "path": "components/shared/icons/globe.tsx",
    "content": "export default function Globe({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <line x1=\"2\" x2=\"22\" y1=\"12\" y2=\"12\" />\n      <path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/google.tsx",
    "content": "export default function Google({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      height=\"24\"\n      viewBox=\"0 0 24 24\"\n      width=\"24\"\n      className={className}\n    >\n      <path\n        d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z\"\n        fill=\"#4285F4\"\n      />\n      <path\n        d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"\n        fill=\"#34A853\"\n      />\n      <path\n        d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"\n        fill=\"#FBBC05\"\n      />\n      <path\n        d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"\n        fill=\"#EA4335\"\n      />\n      <path d=\"M1 1h22v22H1z\" fill=\"none\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/grip-vertical.tsx",
    "content": "export default function GripVertical({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"9\" cy=\"12\" r=\"1\" />\n      <circle cx=\"9\" cy=\"5\" r=\"1\" />\n      <circle cx=\"9\" cy=\"19\" r=\"1\" />\n      <circle cx=\"15\" cy=\"12\" r=\"1\" />\n      <circle cx=\"15\" cy=\"5\" r=\"1\" />\n      <circle cx=\"15\" cy=\"19\" r=\"1\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/home.tsx",
    "content": "export default function Home({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <path d=\"m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\" />\n      <polyline points=\"9 22 9 12 15 12 15 22\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/index.tsx",
    "content": "\"use client\";\n\nimport { ComponentType, SVGProps } from \"react\";\n\nimport { LucideIcon } from \"lucide-react\";\n\nexport type Icon = LucideIcon | ComponentType<SVGProps<SVGSVGElement>>;\n"
  },
  {
    "path": "components/shared/icons/linkedin.tsx",
    "content": "export default function LinkedIn({\n  className,\n  color = true,\n}: {\n  className?: string;\n  color?: boolean;\n}) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 256 256\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <g>\n        <path\n          d=\"M0 18.338C0 8.216 8.474 0 18.92 0h218.16C247.53 0 256 8.216 256 18.338v219.327C256 247.79 247.53 256 237.08 256H18.92C8.475 256 0 247.791 0 237.668V18.335z\"\n          fill={color ? \"#069\" : \"#000\"}\n        />\n        <path\n          d=\"M77.796 214.238V98.986H39.488v115.252H77.8zM58.65 83.253c13.356 0 21.671-8.85 21.671-19.91-.25-11.312-8.315-19.915-21.417-19.915-13.111 0-21.674 8.603-21.674 19.914 0 11.06 8.312 19.91 21.169 19.91h.248zM99 214.238h38.305v-64.355c0-3.44.25-6.889 1.262-9.346 2.768-6.885 9.071-14.012 19.656-14.012 13.858 0 19.405 10.568 19.405 26.063v61.65h38.304v-66.082c0-35.399-18.896-51.872-44.099-51.872-20.663 0-29.738 11.549-34.78 19.415h.255V98.99H99.002c.5 10.812-.003 115.252-.003 115.252z\"\n          fill=\"#fff\"\n        />\n      </g>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/menu.tsx",
    "content": "export default function Menu({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" />\n      <line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" />\n      <line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/moon.tsx",
    "content": "export default function Moon({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/more-horizontal.tsx",
    "content": "export default function MoreHorizontal({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"1\" />\n      <circle cx=\"19\" cy=\"12\" r=\"1\" />\n      <circle cx=\"5\" cy=\"12\" r=\"1\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/more-vertical.tsx",
    "content": "export default function MoreVertical({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"1\" />\n      <circle cx=\"12\" cy=\"5\" r=\"1\" />\n      <circle cx=\"12\" cy=\"19\" r=\"1\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/papermark-sparkle.tsx",
    "content": "export default function PapermarkSparkle({\n  className,\n}: {\n  className?: string;\n}) {\n  return (\n    <svg\n      width=\"24\"\n      height=\"24\"\n      viewBox=\"0 0 40 40\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <path\n        d=\"M31 12L29.7253 18.1359C29.6601 18.45 29.5492 18.7354 29.4016 18.9691C29.2539 19.2029 29.0737 19.3785 28.8753 19.4818L25 21.5L28.8753 23.5182C29.0737 23.6215 29.2539 23.7971 29.4016 24.0309C29.5492 24.2646 29.6601 24.55 29.7253 24.8641L31 31L32.2747 24.8641C32.3399 24.55 32.4508 24.2646 32.5984 24.0309C32.7461 23.7971 32.9263 23.6215 33.1247 23.5182L37 21.5L33.1247 19.4818C32.9263 19.3785 32.7461 19.2029 32.5984 18.9691C32.4508 18.7354 32.3399 18.45 32.2747 18.1359L31 12Z\"\n        fill=\"currentColor\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M7.89631 31V9.18182H16.5043C18.1591 9.18182 19.5689 9.49787 20.7337 10.13C21.8984 10.755 22.7862 11.625 23.397 12.7401C24.0149 13.848 24.3239 15.1264 24.3239 16.5753C24.3239 18.0241 24.0114 19.3026 23.3864 20.4105C22.7614 21.5185 21.8558 22.3814 20.6697 22.9993C19.4908 23.6172 18.0632 23.9261 16.3871 23.9261H10.9006V20.2294H15.6413C16.5291 20.2294 17.2607 20.0767 17.8359 19.7713C18.4183 19.4588 18.8516 19.0291 19.1357 18.4822C19.4268 17.9283 19.5724 17.2926 19.5724 16.5753C19.5724 15.8509 19.4268 15.2187 19.1357 14.679C18.8516 14.1321 18.4183 13.7095 17.8359 13.4112C17.2536 13.1058 16.5149 12.9531 15.62 12.9531H12.5092V31H7.89631Z\"\n        fill=\"currentColor\"\n      />\n      <path\n        d=\"M26.5 5L25.9689 6.93767C25.9417 7.03684 25.8955 7.12696 25.834 7.20078C25.7725 7.2746 25.6974 7.33005 25.6147 7.36267L24 8L25.6147 8.63733C25.6974 8.66995 25.7725 8.7254 25.834 8.79922C25.8955 8.87304 25.9417 8.96316 25.9689 9.06233L26.5 11L27.0311 9.06233C27.0583 8.96316 27.1045 8.87304 27.166 8.79922C27.2275 8.7254 27.3026 8.66995 27.3853 8.63733L29 8L27.3853 7.36267C27.3026 7.33005 27.2275 7.2746 27.166 7.20078C27.1045 7.12696 27.0583 7.03684 27.0311 6.93767L26.5 5Z\"\n        fill=\"currentColor\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        d=\"M22.5 27L21.9689 28.9377C21.9417 29.0368 21.8955 29.127 21.834 29.2008C21.7725 29.2746 21.6974 29.33 21.6147 29.3627L20 30L21.6147 30.6373C21.6974 30.67 21.7725 30.7254 21.834 30.7992C21.8955 30.873 21.9417 30.9632 21.9689 31.0623L22.5 33L23.0311 31.0623C23.0583 30.9632 23.1045 30.873 23.166 30.7992C23.2275 30.7254 23.3026 30.67 23.3853 30.6373L25 30L23.3853 29.3627C23.3026 29.33 23.2275 29.2746 23.166 29.2008C23.1045 29.127 23.0583 29.0368 23.0311 28.9377L22.5 27Z\"\n        fill=\"currentColor\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/passkey.tsx",
    "content": "export default function Passkey({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"currentColor\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M2.743 4.757a3.757 3.757 0 1 1 5.851 3.119 5.991 5.991 0 0 1 2.15 1.383c.17.17.257.405.258.646.003.598.001 1.197 0 1.795L11 12.778v.721a.5.5 0 0 1-.5.5H1.221a.749.749 0 0 1-.714-.784 6.004 6.004 0 0 1 3.899-5.339 3.754 3.754 0 0 1-1.663-3.119Z\"></path>\n      <path d=\"M15.75 6.875c0 .874-.448 1.643-1.127 2.09a.265.265 0 0 0-.123.22v.59c0 .067-.026.13-.073.177l-.356.356a.125.125 0 0 0 0 .177l.356.356c.047.047.073.11.073.176v.231c0 .067-.026.13-.073.177l-.356.356a.125.125 0 0 0 0 .177l.356.356c.047.047.073.11.073.177v.287a.247.247 0 0 1-.065.168l-.8.88a.52.52 0 0 1-.77 0l-.8-.88a.247.247 0 0 1-.065-.168V9.185a.264.264 0 0 0-.123-.22 2.5 2.5 0 1 1 3.873-2.09ZM14 6.5a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z\"></path>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/pie-chart.tsx",
    "content": "export default function PieChart({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <path d=\"M21.21 15.89A10 10 0 1 1 8 2.83\" />\n      <path d=\"M22 12A10 10 0 0 0 12 2v10z\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/portrait-landscape.tsx",
    "content": "export default function PortraitLandscape({\n  className,\n  fill,\n}: {\n  className?: string;\n  fill?: string;\n}) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill={fill || \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M19 11L19 4.25C19 3.65326 18.7893 3.08097 18.4142 2.65901C18.0391 2.23705 17.5304 2 17 2L14 2\" />\n      <path d=\"M16 8L19 11L22 8\" />\n      <path d=\"M10 2L6 2C5.46957 2 4.96086 2.26339 4.58579 2.73223C4.21072 3.20107 4 3.83696 4 4.5L4 19.5C4 20.163 4.21071 20.7989 4.58579 21.2678C4.96086 21.7366 5.46957 22 6 22L17 22C17.5304 22 18.0391 21.7366 18.4142 21.2678C18.7893 20.7989 19 20.163 19 19.5L19 15\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/producthunt.tsx",
    "content": "import React from \"react\";\n\nfunction ProductHuntIcon(\n  props: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>,\n) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" {...props}>\n      <g clipPath=\"url(#clip0_9_7)\">\n        <path\n          d=\"M15.2955 10.2563C15.2955 11.217 14.5125 12 13.5518 12H10.2563V8.5125H13.5518C14.5125 8.5125 15.2955 9.2955 15.2955 10.2563ZM23.625 12C23.625 18.4222 18.4215 23.625 12 23.625C5.5785 23.625 0.375 18.4215 0.375 12C0.375 5.57775 5.5785 0.375 12 0.375C18.4215 0.375 23.625 5.5785 23.625 12ZM17.6205 10.2563C17.6205 8.01075 15.7973 6.1875 13.5518 6.1875H7.93125V17.8125H10.2563V14.325H13.5518C15.7973 14.325 17.6205 12.5018 17.6205 10.2563Z\"\n          fill=\"currentColor\"\n        />\n      </g>\n      <defs>\n        <clipPath id=\"clip0_9_7\">\n          <rect width=\"24\" height=\"24\" fill=\"white\" />\n        </clipPath>\n      </defs>\n    </svg>\n  );\n}\n\nexport default ProductHuntIcon;\n"
  },
  {
    "path": "components/shared/icons/search.tsx",
    "content": "export default function Search({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"11\" cy=\"11\" r=\"8\" />\n      <path d=\"m21 21-4.3-4.3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/settings.tsx",
    "content": "export default function Settings({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      shapeRendering=\"geometricPrecision\"\n      className={className}\n    >\n      <path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\" />\n      <circle cx=\"12\" cy=\"12\" r=\"3\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/slack-icon.tsx",
    "content": "import React from \"react\";\n\ninterface SlackIconProps {\n  className?: string;\n  size?: number;\n}\n\nexport function SlackIcon({ className = \"\", size = 24 }: SlackIconProps) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      className={className}\n    >\n      {/* Slack logo with proper colors */}\n      <path\n        d=\"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z\"\n        fill=\"#E01E5A\"\n      />\n      <path\n        d=\"M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.527 2.527 0 0 1 8.834 0a2.527 2.527 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.52 2.527 2.527 0 0 1-2.521 2.521H2.522A2.527 2.527 0 0 1 0 8.833a2.528 2.528 0 0 1 2.522-2.52h6.312z\"\n        fill=\"#36C5F0\"\n      />\n      <path\n        d=\"M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.527 2.527 0 0 1 24 8.834a2.527 2.527 0 0 1-2.522 2.52h-2.522V8.834zM17.688 8.834a2.527 2.527 0 0 1-2.52 2.52 2.526 2.526 0 0 1-2.52-2.52V2.522A2.527 2.527 0 0 1 15.166 0a2.527 2.527 0 0 1 2.522 2.522v6.312z\"\n        fill=\"#2EB67D\"\n      />\n      <path\n        d=\"M15.166 18.956a2.528 2.528 0 0 1 2.52 2.522A2.527 2.527 0 0 1 15.166 24a2.527 2.527 0 0 1-2.52-2.522v-2.52h2.52zM15.166 17.688a2.527 2.527 0 0 1-2.52-2.52 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.168a2.528 2.528 0 0 1-2.522 2.52h-6.312z\"\n        fill=\"#ECB22E\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/sparkle.tsx",
    "content": "export default function Sparkle({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      // fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path\n        d=\"M12 3L10.088 8.813C9.99015 9.11051 9.82379 9.38088 9.60234 9.60234C9.38088 9.82379 9.11051 9.99015 8.813 10.088L3 12L8.813 13.912C9.11051 14.0099 9.38088 14.1762 9.60234 14.3977C9.82379 14.6191 9.99015 14.8895 10.088 15.187L12 21L13.912 15.187C14.0099 14.8895 14.1762 14.6191 14.3977 14.3977C14.6191 14.1762 14.8895 14.0099 15.187 13.912L21 12L15.187 10.088C14.8895 9.99015 14.6191 9.82379 14.3977 9.60234C14.1762 9.38088 14.0099 9.11051 13.912 8.813L12 3Z\"\n        fill=\"currentColor\"\n      />\n      <path d=\"M5 3V7\" />\n      <path d=\"M19 17V21\" />\n      <path d=\"M3 5H7\" />\n      <path d=\"M17 19H21\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/sun.tsx",
    "content": "export default function Sun({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"4\" />\n      <path d=\"M12 2v2\" />\n      <path d=\"M12 20v2\" />\n      <path d=\"m4.93 4.93 1.41 1.41\" />\n      <path d=\"m17.66 17.66 1.41 1.41\" />\n      <path d=\"M2 12h2\" />\n      <path d=\"M20 12h2\" />\n      <path d=\"m6.34 17.66-1.41 1.41\" />\n      <path d=\"m19.07 4.93-1.41 1.41\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/teams.tsx",
    "content": "import React from \"react\";\n\nconst TeamsIcon = () => {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      viewBox=\"0 0 24 24\"\n      strokeWidth=\"1.5\"\n      stroke=\"currentColor\"\n      className=\"h-6 w-6\"\n    >\n      <path\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        d=\"M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z\"\n      />\n    </svg>\n  );\n};\n\nexport default TeamsIcon;\n"
  },
  {
    "path": "components/shared/icons/twitter.tsx",
    "content": "import { cn } from \"@/lib/utils\";\n\n// Thanks to Steven Tey for this awesome component <3\n// originally spotted here github.com/steven-tey/dub/components/shared/icons/twitter.tsx\nexport default function Twitter({ className }: { className?: string }) {\n  return (\n    <div className=\"group\">\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 248 204\"\n        className={cn(\"group-hover:hidden\", className)}\n      >\n        <path\n          fill=\"currentColor\"\n          d=\"M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z\"\n        />\n      </svg>\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 1668.56 1221.19\"\n        className={cn(\"hidden scale-150 group-hover:block\", className)}\n      >\n        <g transform=\"translate(52.390088,-25.058597)\">\n          <path\n            fill=\"currentColor\"\n            d=\"M283.94,167.31l386.39,516.64L281.5,1104h87.51l340.42-367.76L984.48,1104h297.8L874.15,558.3l361.92-390.99\n\t\th-87.51l-313.51,338.7l-253.31-338.7H283.94z M412.63,231.77h136.81l604.13,807.76h-136.81L412.63,231.77z\"\n          />\n        </g>\n      </svg>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/user-round.tsx",
    "content": "export default function UserRound({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"8\" r=\"5\" />\n      <path d=\"M20 21a8 8 0 0 0-16 0\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/x-circle.tsx",
    "content": "export default function XCircle({\n  className,\n  fill,\n}: {\n  className?: string;\n  fill?: string;\n}) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill={fill || \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"10\" />\n      <path d=\"m15 9-6 6\" />\n      <path d=\"m9 9 6 6\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/icons/x.tsx",
    "content": "export default function X({ className }: { className?: string }) {\n  return (\n    <svg\n      width=\"14\"\n      height=\"14\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n      shapeRendering=\"geometricPrecision\"\n    >\n      <path d=\"M18 6 6 18\" />\n      <path d=\"m6 6 12 12\" />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/shared/logo-cloud.tsx",
    "content": "export const LogoCloud = () => {\n  return (\n    <div>\n      <div className=\"overflow-hidden\">\n        <div className=\"grid grid-cols-2 grid-rows-[100px] gap-px md:grid-cols-3\">\n          <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex items-center justify-center bg-white p-8 font-bold text-[#249edc] transition-colors\"\n            href=\"https://snowflake.com\"\n          >\n            <svg\n              width=\"250\"\n              height=\"50\"\n              viewBox=\"0 0 200 50\"\n              fill=\"none\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <path\n                fillRule=\"evenodd\"\n                clipRule=\"evenodd\"\n                d=\"M172.976 16.0403H172.581V16.5237H172.976C173.161 16.5237 173.279 16.4409 173.279 16.2868C173.279 16.1232 173.168 16.0403 172.976 16.0403ZM172.093 15.592H172.987C173.473 15.592 173.796 15.8565 173.796 16.2698C173.797 16.3848 173.768 16.4981 173.711 16.5978C173.654 16.6976 173.572 16.7802 173.473 16.8372L173.822 17.3408V17.4406H173.316L172.976 16.9562H172.581V17.4406H172.093V15.592ZM174.584 16.5503C174.584 15.5622 173.923 14.8132 172.91 14.8132C171.916 14.8132 171.255 15.5261 171.255 16.5503C171.255 17.5278 171.916 18.2885 172.91 18.2885C173.923 18.2885 174.584 17.5384 174.584 16.5503ZM175 16.5503C175 17.7116 174.218 18.6827 172.913 18.6827C171.617 18.6827 170.842 17.7052 170.842 16.5503C170.842 15.3901 171.614 14.419 172.913 14.419C174.218 14.4211 175 15.389 175 16.5503ZM57.9752 20.6844L53.9442 23.0016L57.9752 25.3125C58.2162 25.4489 58.4277 25.6322 58.5976 25.8519C58.7674 26.0716 58.8921 26.3231 58.9643 26.5918C59.0366 26.8606 59.055 27.1411 59.0184 27.4171C58.9819 27.693 58.8911 27.9589 58.7515 28.1992C58.4646 28.6841 58.0004 29.0365 57.4585 29.1805C56.9166 29.3245 56.3402 29.2487 55.8532 28.9695L48.6338 24.8259C48.1711 24.563 47.826 24.1314 47.6698 23.62C47.5991 23.3984 47.5667 23.1662 47.5738 22.9336C47.5791 22.7668 47.6034 22.601 47.6477 22.4342C47.7922 21.9189 48.1297 21.4568 48.6306 21.1657L55.8479 17.0273C56.3354 16.7471 56.9129 16.6713 57.4554 16.8165C57.9979 16.9616 58.462 17.3159 58.7473 17.8029C59.339 18.8123 58.993 20.1 57.9752 20.6844ZM54.1583 31.9136L46.9442 27.7774C46.5717 27.5613 46.1427 27.4649 45.7144 27.5011C45.1836 27.5366 44.686 27.7734 44.3216 28.1637C43.9572 28.554 43.7531 29.069 43.7506 29.6048V37.8878C43.7506 39.0565 44.6966 40 45.8726 40C47.0475 40 47.9968 39.0544 47.9968 37.8878V33.2555L52.0362 35.5727C52.5229 35.8545 53.1002 35.9317 53.643 35.7875C54.1858 35.6433 54.6502 35.2894 54.9356 34.8024C55.521 33.7931 55.1761 32.4979 54.1583 31.9136ZM45.8325 23.8123L42.833 26.7893C42.73 26.8854 42.5967 26.9418 42.4564 26.9487H41.5758C41.4362 26.9406 41.3036 26.8843 41.2003 26.7893L38.2008 23.8123C38.1075 23.7104 38.0527 23.5788 38.0457 23.4404V22.5628C38.0527 22.4232 38.1075 22.2903 38.2008 22.1867L41.1982 19.2086C41.3018 19.1146 41.4344 19.0594 41.5737 19.0524H42.4564C42.5962 19.0588 42.7293 19.114 42.833 19.2086L45.8325 22.1867C45.9258 22.2903 45.9805 22.4232 45.9876 22.5628V23.4404C45.9876 23.5573 45.919 23.7273 45.8325 23.8123ZM43.4352 22.9857C43.4256 22.8458 43.3695 22.7133 43.2759 22.6095L42.4079 21.7489C42.3037 21.6552 42.1709 21.6002 42.0314 21.5928H41.9966C41.8581 21.6004 41.7263 21.6555 41.6232 21.7489L40.7552 22.6095C40.6637 22.7139 40.6102 22.8466 40.6033 22.9857V23.0197C40.6033 23.1397 40.6698 23.3065 40.7552 23.3915L41.6232 24.2542C41.7097 24.3392 41.8764 24.4115 41.9966 24.4115H42.0314C42.1709 24.4034 42.3036 24.348 42.4079 24.2542L43.2759 23.3915C43.3699 23.2897 43.4261 23.1584 43.4352 23.0197V22.9857ZM29.875 14.0811L37.0912 18.2226C37.4804 18.4457 37.9107 18.5329 38.3231 18.4989C38.854 18.4621 39.3516 18.2246 39.716 17.8339C40.0804 17.4431 40.2847 16.9281 40.288 16.392V8.11006C40.288 6.9456 39.3367 6 38.1659 6C36.991 6 36.0418 6.9456 36.0418 8.11006V12.7445L31.9991 10.422C31.512 10.1415 30.9348 10.0652 30.3924 10.2097C29.8499 10.3543 29.3856 10.7079 29.0998 11.1944C28.9601 11.4347 28.8692 11.7006 28.8326 11.9766C28.796 12.2526 28.8143 12.5332 28.8864 12.802C28.9586 13.0708 29.0832 13.3225 29.2529 13.5423C29.4226 13.762 29.6341 13.9445 29.875 14.0811ZM45.7144 18.4989C46.1278 18.5329 46.5582 18.4457 46.9442 18.2226L54.1583 14.0822C54.399 13.9455 54.6104 13.7619 54.7801 13.5421C54.9498 13.3224 55.0744 13.0708 55.1468 12.8021C55.2191 12.5334 55.2377 12.2529 55.2014 11.9769C55.1652 11.7009 55.0748 11.4349 54.9356 11.1944C54.6496 10.7082 54.1853 10.3547 53.6429 10.2101C53.1005 10.0656 52.5235 10.1417 52.0362 10.422L47.9968 12.7445V8.11006C47.9968 6.9456 47.0475 6 45.8726 6C44.6966 6 43.7506 6.9456 43.7506 8.11006V16.3909C43.7474 17.5033 44.6133 18.417 45.7144 18.4978V18.4989ZM38.3231 27.5001C37.8941 27.4634 37.4643 27.5598 37.0912 27.7763L29.875 31.9125C29.6343 32.0495 29.423 32.2334 29.2533 32.4533C29.0837 32.6733 28.9591 32.925 28.8868 33.1938C28.8145 33.4627 28.7959 33.7433 28.8321 34.0194C28.8683 34.2956 28.9586 34.5617 29.0977 34.8024C29.3842 35.2881 29.8487 35.641 30.3911 35.7848C30.9334 35.9287 31.5102 35.8522 31.997 35.5716L36.0397 33.2544V37.8868C36.0397 39.0555 36.9889 39.9989 38.1638 39.9989C39.3345 39.9989 40.2859 39.0533 40.2859 37.8868V29.6048C40.2841 29.069 40.0807 28.5538 39.7166 28.1632C39.3525 27.7726 38.8549 27.5356 38.3242 27.5001H38.3231ZM36.3666 23.62C36.4357 23.398 36.4678 23.1661 36.4616 22.9336C36.4498 22.5715 36.3456 22.2187 36.159 21.909C35.9724 21.5993 35.7097 21.3433 35.3963 21.1657L28.1864 17.0284C27.1686 16.4462 25.8703 16.7915 25.2871 17.804C24.6975 18.8133 25.0466 20.1011 26.0644 20.6854L30.0944 23.0027L26.0623 25.3135C25.8212 25.4498 25.6096 25.6331 25.4397 25.8528C25.2697 26.0724 25.1449 26.3239 25.0726 26.5926C25.0002 26.8614 24.9817 27.142 25.0182 27.418C25.0547 27.694 25.1453 27.9599 25.285 28.2002C25.5715 28.6859 26.0359 29.0388 26.5782 29.1829C27.1205 29.3269 27.6973 29.2507 28.1843 28.9705L35.3963 24.8269C35.8857 24.5507 36.2148 24.1119 36.3666 23.62ZM125.877 11.2433H125.677C125.651 11.2433 125.625 11.2465 125.599 11.2486C125.574 11.2465 125.55 11.2433 125.522 11.2433C125.011 11.2433 124.512 11.3028 124.047 11.4558C123.581 11.6009 123.164 11.8717 122.84 12.2388L122.838 12.2356V12.2409C122.498 12.6149 122.277 13.0622 122.139 13.5711C122.004 14.0811 121.948 14.6559 121.942 15.3115V16.5779H120.539C120.31 16.5759 120.089 16.6653 119.925 16.8265C119.761 16.9877 119.667 17.2077 119.663 17.4385C119.661 17.6811 119.752 17.9151 119.919 18.0909C120.086 18.262 120.31 18.3643 120.548 18.3777H121.942V28.6784L121.94 28.7028C121.94 28.945 122.041 29.1671 122.205 29.3265C122.368 29.4826 122.596 29.5751 122.844 29.5751C123.074 29.574 123.293 29.4813 123.455 29.3175C123.617 29.1536 123.708 28.9318 123.708 28.7007V18.3724H125.217C125.454 18.3618 125.678 18.262 125.845 18.093C125.929 18.0088 125.996 17.9087 126.041 17.7983C126.087 17.688 126.11 17.5697 126.11 17.4502V17.4162C126.105 16.9519 125.714 16.5726 125.228 16.5726H123.707V15.3115C123.718 14.7537 123.776 14.3361 123.859 14.0333C123.94 13.7273 124.053 13.5361 124.152 13.4171C124.271 13.2932 124.42 13.2022 124.584 13.1525C124.793 13.0845 125.083 13.0431 125.475 13.0431H125.521C125.543 13.0431 125.569 13.0378 125.591 13.0378C125.617 13.0378 125.643 13.0431 125.672 13.0431H125.869C126.37 13.0431 126.773 12.6394 126.773 12.1432C126.774 12.0245 126.751 11.9067 126.707 11.7969C126.662 11.687 126.596 11.5873 126.512 11.5035C126.429 11.4197 126.33 11.3535 126.221 11.3088C126.111 11.2641 125.994 11.2419 125.877 11.2433ZM157.006 17.9241C157.091 17.8418 157.159 17.7432 157.206 17.6341C157.253 17.525 157.278 17.4076 157.279 17.2887C157.278 17.1724 157.253 17.0575 157.206 16.9514C157.158 16.8453 157.09 16.7503 157.004 16.6725H157.006C157.004 16.6682 157.001 16.6682 157.001 16.6682C157.001 16.6629 156.996 16.6629 156.996 16.6629H156.993C156.829 16.4978 156.606 16.4046 156.374 16.4037C156.14 16.4062 155.915 16.5012 155.75 16.6682L148.8 23.4V12.0879C148.8 11.6013 148.39 11.2029 147.886 11.2029C147.77 11.2028 147.655 11.2258 147.548 11.2707C147.441 11.3155 147.343 11.3813 147.261 11.4643C147.096 11.6291 147.001 11.8532 146.998 12.0879V28.6518C146.999 28.7678 147.023 28.8824 147.068 28.989C147.113 29.0957 147.179 29.1923 147.261 29.2733C147.428 29.4389 147.652 29.5327 147.886 29.5347C148.39 29.5347 148.8 29.1416 148.8 28.655V25.9191L151.063 23.6731L155.676 29.2053C155.761 29.3328 155.879 29.42 155.998 29.4667C156.13 29.5156 156.261 29.5347 156.376 29.5347C156.585 29.5388 156.789 29.4726 156.956 29.3466L156.967 29.3392L156.978 29.3275C157.069 29.2413 157.141 29.1377 157.191 29.0229C157.241 28.9082 157.268 28.7845 157.27 28.6592C157.267 28.4438 157.19 28.2361 157.05 28.0727V28.0706L152.354 22.3971L157.001 17.9283H157.006V17.9251V17.9241ZM144.458 16.7851C144.623 16.9445 144.724 17.1718 144.724 17.4183V28.6518C144.723 28.7674 144.7 28.8817 144.655 28.9882C144.61 29.0947 144.545 29.1912 144.463 29.2723C144.296 29.4382 144.071 29.5319 143.836 29.5336C143.601 29.5318 143.377 29.4381 143.209 29.2723C143.127 29.1918 143.06 29.0955 143.015 28.9889C142.97 28.8823 142.946 28.7677 142.946 28.6518V27.5564C141.828 28.757 140.256 29.5336 138.518 29.5336C137.693 29.5329 136.876 29.3619 136.118 29.0312C135.361 28.7004 134.679 28.217 134.114 27.6106C132.95 26.3769 132.304 24.7385 132.309 23.0367C132.309 21.2634 132.995 19.6378 134.114 18.4595C134.679 17.8541 135.362 17.3716 136.119 17.0416C136.876 16.7116 137.693 16.5412 138.518 16.5407C140.256 16.5407 141.828 17.294 142.946 18.4893V17.4205C142.945 17.3034 142.968 17.1875 143.013 17.0795C143.058 16.9715 143.124 16.8736 143.207 16.7917C143.29 16.7097 143.388 16.6453 143.496 16.6022C143.604 16.5591 143.72 16.5382 143.836 16.5407C144.072 16.5407 144.296 16.631 144.458 16.7851ZM142.946 23.0367C142.946 21.7086 142.442 20.5186 141.636 19.658C140.831 18.8017 139.725 18.2811 138.518 18.2789C137.32 18.2789 136.219 18.8017 135.405 19.658C134.552 20.5728 134.08 21.782 134.086 23.0367C134.086 24.3637 134.598 25.5441 135.405 26.3972C136.214 27.2504 137.318 27.7636 138.518 27.7636C139.103 27.7625 139.681 27.6409 140.218 27.4063C140.754 27.1717 141.238 26.829 141.638 26.3994C142.486 25.4891 142.954 24.2851 142.946 23.0367ZM71.4226 22.7211C70.7043 22.3832 69.8901 22.1367 69.0791 21.8679C68.3313 21.6161 67.574 21.4036 67.033 21.1221C66.7608 20.9776 66.5488 20.8246 66.4107 20.6525C66.2714 20.4905 66.1963 20.2824 66.1997 20.0681C66.2019 19.743 66.2947 19.4816 66.455 19.2468C66.6986 18.9005 67.1068 18.6285 67.5519 18.4553C67.9917 18.2789 68.4695 18.1961 68.8112 18.1961C69.8195 18.1982 70.4565 18.5307 70.9459 18.8782C71.1863 19.0513 71.391 19.2298 71.585 19.3743C71.6831 19.4498 71.7759 19.5188 71.8846 19.5698C71.9858 19.6187 72.106 19.6601 72.2368 19.6601C72.3201 19.6601 72.4014 19.6453 72.4773 19.6134C72.5563 19.5812 72.6294 19.536 72.6935 19.4795C72.7592 19.4177 72.8134 19.3447 72.8538 19.2638C72.8874 19.1834 72.905 19.0971 72.9055 19.0099C72.9014 18.8545 72.859 18.7027 72.7821 18.5679C72.6471 18.3257 72.4404 18.093 72.1778 17.8582C71.7376 17.4792 71.2443 17.1677 70.7138 16.9338C70.1559 16.6852 69.5589 16.5163 68.9905 16.5163C67.67 16.5163 66.5689 16.8138 65.7694 17.362C65.3518 17.6489 64.9847 17.9613 64.7274 18.3937C64.4637 18.8272 64.3161 19.3616 64.2971 20.0586V20.1521C64.2939 20.8267 64.5133 21.3845 64.8571 21.8212C65.3781 22.4789 66.1533 22.8698 66.9138 23.1493C67.67 23.4276 68.4304 23.5934 68.9071 23.7528C69.5811 23.9748 70.2962 24.2171 70.8204 24.5369C71.0809 24.7005 71.2897 24.8768 71.4321 25.0702C71.5692 25.2678 71.6483 25.474 71.6536 25.7321V25.7523C71.6515 26.1284 71.5407 26.4217 71.3561 26.6735C71.0777 27.0507 70.6179 27.3237 70.1295 27.5001C69.7046 27.6496 69.2591 27.7313 68.8091 27.7423C67.6573 27.7423 66.8885 27.4661 66.3274 27.1898C66.0468 27.0507 65.8211 26.9136 65.6144 26.7967C65.5187 26.7407 65.4193 26.6913 65.317 26.649C65.2156 26.6066 65.1071 26.5842 64.9974 26.5832C64.9209 26.5832 64.8451 26.5977 64.7738 26.6257C64.7011 26.6557 64.6344 26.6985 64.5766 26.7521C64.4984 26.8252 64.4334 26.9115 64.3846 27.0071C64.339 27.101 64.3156 27.2043 64.3161 27.3088C64.3161 27.4831 64.3793 27.6393 64.4743 27.7838C64.6188 27.9909 64.8329 28.1747 65.0976 28.3607C65.3739 28.5434 65.7009 28.7219 66.0763 28.9004C66.9148 29.2935 67.9875 29.4996 68.8038 29.5113H68.8059C70.0747 29.5113 71.1684 29.2213 72.0913 28.5466V28.5434H72.0934C72.9962 27.8688 73.5562 26.8647 73.5562 25.73C73.5562 25.1063 73.4086 24.5783 73.1534 24.1469C72.7652 23.4935 72.1356 23.0611 71.4226 22.7211ZM129.543 11.2018C129.426 11.2018 129.311 11.2249 129.204 11.2698C129.096 11.3146 128.998 11.3803 128.916 11.4632C128.833 11.5445 128.768 11.6414 128.722 11.7485C128.677 11.8555 128.653 11.9705 128.652 12.0869V28.6507C128.652 28.7669 128.676 28.8819 128.721 28.9887C128.767 29.0955 128.833 29.192 128.916 29.2723C129.084 29.4377 129.308 29.5314 129.543 29.5336C130.047 29.5336 130.455 29.1405 130.457 28.6539V12.0869C130.455 11.5981 130.049 11.2018 129.543 11.2018ZM170.456 22.618V22.7062C170.456 22.9506 170.346 23.1705 170.178 23.315C170.007 23.4626 169.788 23.5427 169.563 23.5403H159.59C159.843 25.9595 161.789 27.7476 164.107 27.7636H164.695C165.462 27.7668 166.209 27.5022 166.868 27.0677C167.53 26.6283 168.087 26.0471 168.499 25.3656C168.577 25.2314 168.691 25.1221 168.828 25.05C168.962 24.9791 169.112 24.9434 169.264 24.9464C169.416 24.9493 169.564 24.9908 169.696 25.067L169.706 25.0723L169.716 25.0798C169.839 25.1655 169.94 25.2796 170.011 25.4127C170.082 25.5457 170.12 25.6939 170.122 25.8448C170.122 26.0009 170.08 26.1582 169.997 26.3016L169.993 26.3069L169.991 26.3091C169.421 27.2159 168.664 27.9888 167.771 28.5753C166.866 29.1597 165.819 29.5283 164.695 29.5283H164.098C163.263 29.5224 162.437 29.3493 161.669 29.0191C160.902 28.689 160.207 28.2084 159.625 27.6053C158.443 26.3903 157.783 24.7566 157.785 23.0558C157.785 21.256 158.491 19.6261 159.639 18.4447C160.226 17.8399 160.927 17.3595 161.701 17.0317C162.475 16.704 163.307 16.5356 164.146 16.5365C165.83 16.5365 167.345 17.2037 168.467 18.2938C169.588 19.3839 170.32 20.9 170.451 22.6032L170.456 22.618ZM168.508 21.7819C168.002 19.743 166.202 18.2917 164.146 18.2938C162.039 18.2938 160.264 19.7143 159.713 21.7819H168.508ZM80.899 16.563C79.5651 16.5637 78.2787 17.0626 77.2888 17.9634V17.4502C77.2897 17.2198 77.2016 16.998 77.0431 16.8318C76.9631 16.7482 76.8673 16.6816 76.7613 16.636C76.6553 16.5903 76.5413 16.5666 76.4261 16.5662C76.3075 16.5648 76.1898 16.5874 76.08 16.6327C75.9702 16.6779 75.8705 16.7449 75.7869 16.8297C75.626 16.9955 75.5359 17.2183 75.5359 17.4502V28.9174L75.5728 28.9546L75.576 28.9588C75.5898 29.0014 75.6093 29.0417 75.634 29.0789C75.7553 29.2914 75.9536 29.4529 76.2004 29.5241L76.2341 29.5347H76.4261C76.5572 29.5342 76.6867 29.5052 76.8058 29.4497C76.9126 29.3965 77.0058 29.319 77.0779 29.2234H77.08C77.0905 29.2128 77.0926 29.2022 77.1021 29.1915C77.1505 29.1334 77.1909 29.0691 77.2224 29.0003C77.2477 28.9323 77.2646 28.8664 77.2772 28.8175L77.2846 28.7857V21.8998C77.3067 20.9483 77.6965 20.043 78.3709 19.3765C79.0412 18.705 79.9485 18.3287 80.8938 18.3299C81.8947 18.3299 82.7869 18.7315 83.4345 19.3765C84.0832 20.0224 84.4766 20.9096 84.4766 21.8892V28.6518C84.4766 28.8983 84.582 29.1278 84.7423 29.284C84.9112 29.4435 85.1341 29.5323 85.3657 29.5323C85.5972 29.5323 85.8201 29.4435 85.989 29.284C86.0735 29.2022 86.1406 29.104 86.1863 28.9954C86.232 28.8867 86.2553 28.7698 86.2548 28.6518V21.8892C86.2622 18.9727 83.8638 16.5684 80.899 16.563ZM98.5736 18.467C99.7363 19.6994 100.383 21.3354 100.38 23.0356C100.382 24.7352 99.7351 26.3704 98.5736 27.6031C97.4599 28.7857 95.9031 29.5315 94.1713 29.5315C92.4553 29.5315 90.8965 28.7835 89.7775 27.6021C88.6147 26.3698 87.9664 24.7347 87.9664 23.0345C87.9664 21.3344 88.6147 19.6993 89.7775 18.467C90.8965 17.2866 92.4564 16.5386 94.1713 16.5386C95.9031 16.5407 97.4599 17.2866 98.5736 18.467ZM98.5979 23.0356C98.5979 21.7213 98.0884 20.5282 97.2795 19.6676C96.4663 18.8049 95.3673 18.2789 94.1692 18.2789C92.9742 18.2789 91.87 18.807 91.0589 19.6676C90.2084 20.5793 89.7367 21.7845 89.7405 23.0356C89.7355 24.2822 90.2077 25.4828 91.0589 26.3877C91.871 27.2461 92.9742 27.7636 94.1703 27.7636C95.3642 27.7636 96.4705 27.2451 97.2795 26.3877C98.1304 25.4831 98.6026 24.2818 98.5979 23.0356ZM117.787 16.6395L117.782 16.6364L117.778 16.6342C117.677 16.5899 117.568 16.5664 117.458 16.5652C117.285 16.5679 117.117 16.6178 116.971 16.7097C116.817 16.8048 116.696 16.9444 116.624 17.1102V17.1155L113.228 26.3505L110.612 20.1616L110.607 20.1584C110.532 19.9918 110.409 19.8521 110.253 19.7579C110.104 19.6627 109.93 19.6123 109.754 19.6127C109.577 19.613 109.404 19.6641 109.255 19.76C109.101 19.8543 108.979 19.9932 108.905 20.1584L108.903 20.1616L106.281 26.3558L102.871 17.123H102.869C102.811 16.9527 102.696 16.8076 102.545 16.7118C102.396 16.6181 102.225 16.5674 102.05 16.5652C101.935 16.5652 101.821 16.5887 101.716 16.6342H101.71L101.706 16.6385C101.553 16.71 101.423 16.8243 101.332 16.9677C101.241 17.1112 101.192 17.2778 101.192 17.4481C101.192 17.5554 101.214 17.6627 101.256 17.77L105.453 28.9758V28.9812C105.488 29.0716 105.538 29.1554 105.601 29.2287C105.656 29.2899 105.723 29.3397 105.797 29.3753C105.812 29.387 105.832 29.4072 105.867 29.4263C105.905 29.4487 105.947 29.4635 105.991 29.4699C106.065 29.4943 106.166 29.5315 106.299 29.5315C106.468 29.5315 106.626 29.4699 106.769 29.3796C106.911 29.2864 107.022 29.1504 107.084 28.9907L107.093 28.9833L109.749 22.6744L112.407 28.9567H112.409C112.47 29.1257 112.579 29.2542 112.702 29.3477C112.834 29.4455 112.982 29.5071 113.137 29.5315H113.248C113.353 29.5315 113.456 29.5113 113.543 29.4784C113.624 29.4471 113.7 29.4042 113.769 29.3509C113.912 29.2411 114.023 29.0947 114.091 28.927V28.9227L118.262 17.7658C118.346 17.553 118.344 17.315 118.255 17.1039C118.166 16.8929 117.998 16.7259 117.787 16.6395Z\"\n                fill=\"currentColor\"\n              />\n            </svg>\n          </a>\n          <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex items-center justify-center bg-white p-8 text-black transition-colors\"\n            href=\"https://vercel.com\"\n          >\n            <svg\n              width=\"200\"\n              height=\"46\"\n              viewBox=\"0 0 200 46\"\n              fill=\"none\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <path\n                d=\"M43.6128 8.93306C42.6902 10.5518 40.4593 14.4256 38.6691 17.5524C36.8789 20.6653 33.9595 25.7428 32.1969 28.828C30.4342 31.9133 28.3135 35.5934 27.4872 37.0184C26.6748 38.4435 26 39.6333 26 39.6748C26 39.7163 34.7169 39.7578 45.3617 39.7578C62.107 39.7578 64.7097 39.7301 64.6546 39.5641C64.6133 39.4534 64.09 38.5265 63.5116 37.5165C62.9333 36.5065 61.8591 34.6526 61.1431 33.3936C60.427 32.1346 58.9535 29.589 57.8794 27.7212C56.8053 25.8535 54.3678 21.5922 52.4399 18.2441C50.5258 14.896 48.6667 11.6586 48.3087 11.0498C47.9506 10.4411 47.1519 9.05757 46.5323 7.96459C45.8988 6.88545 45.3755 6 45.348 6C45.3204 6 44.5493 7.32818 43.6128 8.93306Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M65.5222 8.7117C65.5222 8.79471 68.2626 13.6232 69.323 15.4079C69.5708 15.823 71.9807 20.0289 74.6936 24.7467C80.2157 34.3759 79.8852 33.8087 79.9816 33.8087C80.0366 33.8087 80.436 33.1723 80.8767 32.3837C81.3173 31.6089 82.0885 30.2531 82.598 29.3814C83.1075 28.5098 84.7187 25.7013 86.1784 23.1556C87.6381 20.6099 89.2906 17.7461 89.8277 16.7914C92.513 12.1567 94.4409 8.76704 94.4409 8.69787C94.4409 8.65636 93.2153 8.62869 91.728 8.62869H89.0014L88.1201 10.1921C87.6243 11.036 86.3574 13.2635 85.2833 15.1312C84.2092 16.999 82.598 19.8075 81.6891 21.3847C80.794 22.9619 80.0091 24.2624 79.9678 24.2624C79.9265 24.2624 77.8884 20.7483 75.4234 16.4456L70.9617 8.64253L68.2488 8.62869C66.7478 8.62869 65.5222 8.6702 65.5222 8.7117Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M170.318 20.7344V32.8402H172.659H175V20.7344V8.62866H172.659H170.318V20.7344Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M98.3105 14.5916C95.3773 15.1727 92.9123 16.9713 91.728 19.4201C91.0257 20.8728 90.7228 22.201 90.7228 23.8889C90.7228 28.2884 93.5182 31.7749 98.0213 33.0062C99.5636 33.4213 102.662 33.449 104.149 33.0616C106.063 32.5635 108.17 31.3599 109.368 30.1009L109.754 29.6858L109.437 29.4783C109.258 29.3676 108.336 28.828 107.399 28.2884L105.705 27.3062L105.223 27.7212C103.337 29.3953 99.8804 29.5751 97.7046 28.1224C96.9885 27.652 96.1898 26.7389 95.9695 26.1578L95.818 25.7843H103.13C109.947 25.7843 110.429 25.7704 110.525 25.5353C110.704 25.0787 110.608 22.284 110.374 21.357C109.479 17.9121 106.78 15.3941 103.172 14.647C102.084 14.4256 99.3295 14.3841 98.3105 14.5916ZM102.758 19.0189C103.709 19.3648 104.81 20.3609 105.265 21.2325C105.444 21.6061 105.595 21.9381 105.595 21.9796C105.595 22.0211 103.392 22.0488 100.707 22.0488C96.8921 22.0488 95.818 22.0073 95.818 21.8689C95.818 21.5231 96.6442 20.2641 97.1537 19.849C98.6272 18.6315 100.844 18.2995 102.758 19.0189Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M134.665 14.5778C130.933 15.3664 128.234 17.8429 127.339 21.3155C126.967 22.7821 126.967 25.0372 127.339 26.4899C128.179 29.7827 130.424 31.9963 133.922 33.0063C134.817 33.2691 135.34 33.3245 136.993 33.3245C139.334 33.3245 140.463 33.0616 142.267 32.1208C143.396 31.5259 144.952 30.1424 145.42 29.2984L145.654 28.8834L143.644 27.7212L141.633 26.5591L141.413 26.8911C140.904 27.6797 140.353 28.1639 139.499 28.579C138.618 29.0079 138.507 29.0356 136.993 29.0356C135.574 29.0356 135.326 28.9941 134.652 28.6758C133.206 27.9979 132.366 26.988 131.966 25.4246C131.429 23.3355 131.98 21.2187 133.412 19.932C134.431 19.0189 135.368 18.6869 136.993 18.6869C138.7 18.6869 139.637 19.0327 140.642 20.0289C141.041 20.4162 141.427 20.8451 141.496 20.9973C141.62 21.2325 141.661 21.2325 141.964 21.0388C142.143 20.9281 143.052 20.3886 143.988 19.849L145.668 18.8529L145.228 18.2165C144.167 16.7361 142.391 15.4909 140.366 14.8545C139.582 14.6055 139.003 14.5363 137.337 14.4948C136.208 14.4671 134.996 14.5086 134.665 14.5778Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M154.413 14.7161C152.388 15.2419 151.121 15.9336 149.868 17.165C148.037 18.9774 147.059 21.6752 147.238 24.4008C147.403 26.9188 148.174 28.6205 149.923 30.3499C151.273 31.6919 152.361 32.3421 154.275 32.9232C155.39 33.2691 155.735 33.2968 157.649 33.3106C159.48 33.3244 159.948 33.2691 160.885 33.0062C162.634 32.5082 164.135 31.6919 165.264 30.6266C165.815 30.1147 166.228 29.6581 166.187 29.6305C166.145 29.5889 165.236 29.0632 164.162 28.4268L162.221 27.2923L161.821 27.6659C161.615 27.8734 160.981 28.2608 160.417 28.5236C159.535 28.9525 159.205 29.0355 158.2 29.077C155.638 29.2016 153.738 28.2746 152.609 26.3654L152.264 25.7843H159.618H166.985L167.068 25.3554C167.247 24.4561 167.151 22.5053 166.875 21.4124C165.994 17.9259 163.57 15.5878 159.948 14.7161C158.434 14.3426 155.79 14.3426 154.413 14.7161ZM158.282 18.7422C159.287 18.922 159.893 19.1987 160.651 19.8075C161.202 20.2502 162.055 21.4954 162.055 21.8689C162.055 22.0073 160.981 22.0488 157.167 22.0488C154.481 22.0488 152.278 22.0211 152.278 21.9796C152.278 21.9381 152.43 21.6061 152.609 21.2463C153.256 19.9735 154.536 19.0327 156.024 18.7422C156.878 18.5762 157.346 18.5762 158.282 18.7422Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M113.816 14.9375C113.761 14.9928 113.72 19.0465 113.72 23.9442V32.8402H116.13H118.54V28.496C118.54 24.6083 118.567 24.0549 118.801 23.3078C119.228 21.9381 120.068 20.9558 121.432 20.2779C122.12 19.932 122.327 19.9043 123.814 19.9043H125.425V17.3725V14.8545L124.64 14.9375C121.982 15.228 119.6 16.57 118.87 18.2026L118.609 18.7975L118.54 16.8606L118.471 14.9237L116.185 14.8822C114.932 14.8683 113.871 14.896 113.816 14.9375Z\"\n                fill=\"currentColor\"\n              />\n            </svg>\n          </a>\n          <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex items-center justify-center bg-white p-12 transition-colors\"\n            href=\"https://www.foundamental.com\" // Correct URL format\n          >\n            {/* SVG with updated CSS class */}\n            <svg\n              id=\"Layer_1\"\n              data-name=\"Layer 1\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"0 0 277 27\"\n              className=\"h-24 w-36\" // Set width and height as needed\n            >\n              <path\n                strokeWidth={\"0px\"}\n                fill=\"#6f00ff\"\n                d=\"M55.4,4.6v8.1c0,3.4-2.5,6.4-5.8,6.7-.2,0-.4,0-.6,0h-.2c-1,0-1.9-.3-2.8-.7-.5-.2-.9-.5-1.3-.9-.7-.6-1.3-1.4-1.7-2.2-.4-.8-.6-1.8-.6-2.7,0,0,.2-4.6-.9-6.5C39,2.2,34.2-.4,28.9,0c-6.5.6-11.7,6-12.1,12.5-.1,1.8.2,3.6.8,5.3.6,1.7,1.6,3.3,2.8,4.6,1.2,1.3,2.7,2.4,4.4,3.1,1.7.7,3.4,1.1,5.2,1.1,2.5,0,4.9-.7,7-2.1l-3-6.5c-.7.6-1.6,1-2.4,1.2-.9.2-1.8.2-2.7,0-1.2-.2-2.3-.8-3.2-1.7-.9-.9-1.5-2-1.7-3.2-.2-1-.2-2,.1-2.9.3-1,.7-1.8,1.4-2.6.7-.7,1.5-1.3,2.4-1.6.9-.4,1.9-.5,2.9-.4h0s.2,0,.2,0c.8.1,1.5.4,2.2.8,1.1.6,2,1.6,2.6,2.7.6,1.2.8,2.5.6,3.7-.2,1.3-.2,2.5.2,3.7.6,1.8,1.6,3.4,2.9,4.7.5.5.9.9,1.4,1.3,2.5,1.9,5.7,2.9,8.9,2.6,6.8-.5,11.9-6.5,11.9-13.3V.5l-6.5,4.1Z\"\n              />\n              <path\n                strokeWidth={\"0px\"}\n                fill=\"#6f00ff\"\n                d=\"M54.1,16.6c-.3.4-.6.8-1,1.1h0s0,.1,0,.1c-1.1,1-2.6,1.6-4.2,1.6h-.2c-1,0-1.9-.3-2.8-.7-4.4-2.1-8-8.3-12.2-10.8.9.6,1.7,1.5,2.2,2.5.6,1.2.8,2.5.6,3.7-.2,1.3-.2,2.5.2,3.7,1,2.7,2.8,5,5.2,6.5,2.4,1.5,5.2,2.3,8.1,2,2.5-.2,4.9-1.2,6.9-2.7h0c0-.1-2.7-7.1-2.7-7.1Z\"\n              />\n              <path\n                strokeWidth={\"0px\"}\n                fill=\"#6f00ff\"\n                d=\"M81.5,16L68.8.5h-4.9v25.6h5.8v-15.5l12.7,15.5h4.8V.5h-5.8v15.5ZM206.6,16L193.9.5h-4.9v25.6h5.8v-15.5l12.7,15.5h4.8V.5h-5.8v15.5ZM237.1.5h-22.2v4.8h8.2v20.8h5.9V5.3h8.2V.5ZM113.2,6.6c-1.2-1.9-2.8-3.4-4.9-4.5-2.1-1.1-4.6-1.6-7.3-1.6h-11.6v25.6h11.6c2.8,0,5.2-.5,7.3-1.6.7-.4,1.4-.8,2.1-1.3l4.6-10c0-2.5-.6-4.7-1.8-6.6ZM106.7,19.1c-1.5,1.4-3.5,2.1-6.1,2.1h-5.4V5.4h5.4c2.5,0,4.6.7,6.1,2.1,1.5,1.4,2.3,3.4,2.3,5.8s-.8,4.4-2.3,5.8ZM162.7.5l-9.4,15.9L143.7.5h-4.9v16.8l4.1,8.8h1.5v-15l7.5,12.3h2.7l7.5-12.7v15.4h5.6V.5h-4.9ZM123,.5l-11.9,25.6h5.6l2.5-5.4h13.3l2.5,5.4h5.6L128.8.5h-5.8ZM121.5,15.7l4.4-9.4,4.4,9.4h-8.7ZM.8,26.1h5.9v-10.5h8.3v-5H6.7v-5.1h9.7V.5H.8v25.6ZM175.6,15.6h10.4v-5h-10.4v-5.1h11.1V.5h-17v25.6h17s0-5,0-5h-11.2s0-5.5,0-5.5ZM243.1.5l-11.9,25.6h5.6l2.5-5.4h13.3l2.5,5.4h5.6L248.9.5h-5.8ZM241.6,15.7l4.4-9.4,4.4,9.4h-8.7ZM264.3,21.2V.5h-5.5v16.8l4.1,8.8h14v-4.8h-12.6Z\"\n              />\n            </svg>\n          </a>\n          {/* <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex items-center justify-center bg-white p-8 text-[#d82227] transition-colors\"\n            href=\"https://realtor.com\"\n          >\n            <svg\n              width=\"200\"\n              height=\"29\"\n              viewBox=\"0 0 200 29\"\n              fill=\"none\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <path\n                d=\"M83.2559 3.14858C83.2559 3.23112 83.52 3.34667 83.8501 3.39619C84.9066 3.61079 84.9066 3.52826 84.9066 13.532C84.9066 19.4252 84.8406 22.5947 84.7251 22.9414C84.5765 23.3871 84.1803 23.6842 83.2559 24.0144C83.1238 24.0639 84.494 24.1134 86.3098 24.1134L89.6114 24.1299L88.9345 23.8163C87.6304 23.222 87.713 23.9319 87.713 12.8716V3.00001H85.4844C84.2628 3.00001 83.2559 3.06604 83.2559 3.14858Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M93.3917 6.3181C93.2102 7.40761 91.8235 9.33902 90.8 9.9333C90.3213 10.1974 89.7435 10.4615 89.4959 10.5276C89.2318 10.5771 89.0337 10.7257 89.0337 10.8577C89.0337 11.0393 89.2978 11.0888 90.1232 11.0888H91.2292L91.1467 16.2888C91.0476 21.7529 91.1137 22.5782 91.6254 23.2881C92.4838 24.4601 94.7454 24.8398 96.3796 24.0639C97.1555 23.7173 98.113 22.8259 98.113 22.4627C98.113 22.2316 97.8323 22.2811 97.7333 22.5452C97.6012 22.8754 96.2806 23.4697 95.6533 23.4697C95.3727 23.4697 94.9105 23.3541 94.6463 23.222C93.8209 22.7928 93.7549 22.2646 93.8705 17.1142C93.9365 14.572 93.986 12.1784 93.986 11.7822V11.0888H95.7193C97.3371 11.0888 97.4527 11.0723 97.4527 10.7587C97.4527 10.445 97.3371 10.4285 95.7193 10.4285H94.0025L93.953 8.08443C93.92 6.41714 93.8375 5.72382 93.7054 5.6908C93.5733 5.64128 93.4578 5.87239 93.3917 6.3181Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M31.8012 8.31551C30.4476 9.70216 28.3511 11.8482 27.1625 13.0697L25 15.2983L26.3041 15.3478L27.6247 15.3973V19.7554V24.1299H34.2278H40.8309V19.7554V15.3973L42.2341 15.3478L43.6208 15.2983L41.4417 13.0367C38.0907 9.5701 34.3929 5.80633 34.3434 5.80633C34.3104 5.80633 33.1713 6.94536 31.8012 8.31551ZM37.9916 12.9872C38.1897 13.2183 38.3548 13.598 38.3548 13.7961C38.3548 14.3408 37.744 15.0507 37.2653 15.0507C36.8691 15.0507 36.2088 14.5389 36.2088 14.2418C36.2088 13.9447 35.697 13.5815 35.4494 13.6805C35.3008 13.7301 34.9707 14.2253 34.7066 14.7865C34.2443 15.7605 34.2278 15.8761 34.2278 18.3192C34.2278 21.076 34.4094 21.8189 35.0697 21.8189C35.2678 21.8189 35.3834 21.9014 35.3504 22.017C35.3008 22.182 34.6405 22.2481 33.0228 22.2811C31.0914 22.3141 30.7612 22.2811 30.7612 22.0665C30.7612 21.9344 30.8933 21.8189 31.0583 21.8189C31.2399 21.8189 31.4876 21.6868 31.6526 21.5217C31.8837 21.2411 31.9168 20.7624 31.8837 17.2627L31.8342 13.3174L31.306 13.1523C30.3155 12.8716 30.7447 12.7231 32.4615 12.7726L34.1453 12.8221V13.664L34.1288 14.5059L34.624 13.7631C35.5154 12.4259 37.1662 12.0463 37.9916 12.9872Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M55.1268 10.3294C54.7801 10.4615 54.2849 10.7751 54.0042 11.0227C53.41 11.617 52.5516 12.9211 52.5516 13.2843C52.5516 13.4329 52.469 13.598 52.37 13.6475C52.2544 13.73 52.2049 13.2513 52.2379 12.1288L52.2709 10.511L49.9763 10.478L47.6818 10.4285L48.4246 10.8081C48.8373 11.0062 49.25 11.3529 49.3656 11.584C49.6462 12.1618 49.6462 21.9839 49.3656 22.8093C49.217 23.2385 48.9859 23.4696 48.4246 23.7502L47.6818 24.1134H50.9008C53.7071 24.1134 54.0538 24.0969 53.6246 23.8988C53.3604 23.7832 52.9973 23.5356 52.8322 23.3375C52.2709 22.7102 52.0728 19.7554 52.3865 16.5528C52.535 14.8856 52.9808 13.598 53.7566 12.4424C54.3839 11.5015 54.9452 11.518 55.4569 12.4919C56.0842 13.73 57.801 13.6805 58.1807 12.4094C58.6759 10.7916 56.9921 9.63609 55.1268 10.3294Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M62.9843 10.3625C62.5056 10.478 61.8783 10.7091 61.5977 10.8577C60.7723 11.2869 59.4682 12.7231 58.8244 13.8951L58.2466 14.9681V17.3618C58.2466 19.6233 58.2796 19.8049 58.6923 20.7128C59.3196 22.05 60.7888 23.4696 62.0434 23.9649C62.8357 24.2785 63.364 24.3611 64.6021 24.3611C65.9722 24.3776 66.2859 24.3115 67.1278 23.9319C68.4814 23.3046 69.8185 21.7363 70.1157 20.4487C70.2642 19.7884 69.835 19.8709 69.5874 20.5643C69.2738 21.4392 68.2998 22.5947 67.4744 23.0735C65.9392 23.9649 63.7767 23.7503 62.6046 22.5947C61.6307 21.6208 61.251 20.4817 61.1685 18.3852L61.0859 16.5364H65.6751H70.2642L70.1652 15.1497C70.0496 13.4659 69.802 12.8386 68.8776 11.8317C68.0522 10.9402 67.3424 10.5441 66.1208 10.2799C64.9818 10.0488 64.0738 10.0653 62.9843 10.3625ZM66.1043 10.9568C66.9957 11.452 67.2928 12.1783 67.3754 14.0107C67.4744 16.0742 67.5074 16.0577 63.9253 16.0081L61.218 15.9586L61.251 15.0342C61.284 14.0437 61.7297 12.7066 62.324 11.7987C63.0668 10.6596 64.8662 10.2634 66.1043 10.9568Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M75.0846 10.3295C73.9621 10.6101 72.9881 11.1383 72.4599 11.7491C71.5189 12.8717 72.1793 14.3739 73.5164 14.1593C74.3088 14.0437 74.6059 13.598 74.738 12.4259C74.837 11.551 74.9195 11.3694 75.4148 11.0063C75.844 10.6761 76.1576 10.5771 76.8344 10.5606C78.4522 10.5606 78.8649 11.2539 78.7659 13.8291C78.7328 15.0672 78.6503 15.4138 78.3862 15.711C77.8084 16.3713 76.9335 16.8005 74.9361 17.4608C72.4599 18.2697 71.6675 18.897 71.3539 20.2341C70.8586 22.4627 72.1627 24.163 74.5729 24.4106C75.9265 24.5426 77.1811 24.1134 78.0725 23.2055C78.7824 22.4792 78.7989 22.4627 78.7989 22.8754C78.7989 23.9979 79.8719 24.5922 81.5061 24.3941C82.5296 24.262 82.8103 24.0474 82.183 23.8988C81.5226 23.7338 81.4401 22.9909 81.4401 17.4443C81.4401 12.6736 81.4236 12.2939 81.1265 11.7986C80.2846 10.445 77.4452 9.75168 75.0846 10.3295ZM78.7824 18.7154C78.7824 21.7033 78.6503 22.182 77.6763 22.9249C76.884 23.5192 75.91 23.6512 75.0846 23.2715C73.797 22.6938 73.6484 19.4583 74.87 18.4348C75.1341 18.2202 75.976 17.7249 76.7354 17.3453C77.4948 16.9821 78.2046 16.5694 78.3201 16.4373C78.4192 16.3053 78.5678 16.2062 78.6503 16.2062C78.7328 16.2062 78.7989 17.3453 78.7824 18.7154Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M102.339 10.445C100.49 11.0723 99.2519 12.2113 98.41 14.0107C96.5446 17.989 98.4595 22.9249 102.355 24.1299C105.574 25.1204 108.925 23.7668 110.494 20.8449C111.121 19.6398 111.336 16.784 110.923 15.0011C110.048 11.3529 105.971 9.1904 102.339 10.445ZM105.558 10.7751C106.218 11.0063 106.912 11.617 107.39 12.3764C108.331 13.8951 108.447 19.6893 107.588 21.7528C107.258 22.5287 106.383 23.4696 105.756 23.7337C104.964 24.0639 103.775 24.0144 102.95 23.6182C101.745 23.0404 101.068 21.5547 100.754 18.8474C100.424 16.0411 100.919 13.2348 101.992 11.8151C102.818 10.7421 104.221 10.3294 105.558 10.7751Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M117.84 10.5771C117.031 11.0393 116.09 12.2444 115.628 13.4164C115.512 13.7136 115.463 13.3669 115.463 12.1123L115.446 10.4285H113.217C111.847 10.4285 110.989 10.4945 110.989 10.5936C110.989 10.6761 111.171 10.7587 111.385 10.7587C111.6 10.7587 111.946 10.9072 112.161 11.0723L112.557 11.386L112.607 16.817C112.656 23.2055 112.623 23.4201 111.517 23.7833C111.121 23.8988 110.84 24.0309 110.873 24.0804C110.989 24.1795 117.262 24.229 117.262 24.1134C117.262 24.0639 116.998 23.8988 116.668 23.7503C115.661 23.255 115.529 22.7103 115.545 19.0951C115.545 16.718 115.611 15.7605 115.809 15.1167C116.42 13.1358 117.311 11.7491 117.955 11.7491C118.12 11.7491 118.384 12.0133 118.582 12.4094C118.962 13.1523 119.325 13.3999 120.052 13.3999C120.762 13.3999 121.042 13.2513 121.306 12.7231C121.636 12.1123 121.62 11.5015 121.273 10.9237C120.712 9.94979 119.193 9.80122 117.84 10.5771Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M128.24 10.313C125.285 11.2869 123.518 13.9282 123.518 17.4113C123.518 21.6208 126.077 24.4601 129.89 24.4601C132.399 24.4601 134.083 23.2386 135.156 20.6303C135.387 20.1021 135.387 20.0196 135.173 19.937C134.991 19.871 134.793 20.1516 134.43 20.8945C133.456 23.0075 131.244 24.097 129.296 23.4532C128.652 23.2386 127.794 22.4792 127.315 21.7033C126.275 20.0361 126.242 14.3904 127.249 12.4095C127.909 11.1054 128.718 10.5936 130.055 10.5936C131.128 10.5936 131.69 11.1549 132.135 12.6076C132.333 13.2514 132.631 13.9117 132.796 14.0768C133.506 14.7866 135.09 14.1923 135.09 13.2184C135.09 12.1949 134.232 11.1879 132.862 10.5771C131.838 10.1149 129.263 9.98283 128.24 10.313Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M141.281 10.313C138.953 11.0888 137.533 12.5085 136.824 14.7536C135.751 18.1707 136.906 21.8354 139.547 23.4366C140.917 24.2455 142.007 24.5096 143.608 24.4106C147.356 24.1795 149.947 21.2246 149.947 17.1967C149.947 14.2583 148.429 11.8152 145.886 10.6431C144.747 10.1314 142.37 9.9498 141.281 10.313ZM144.285 10.7917C145.21 11.1879 145.672 11.6171 146.084 12.5085C147.24 14.9516 147.24 19.6068 146.084 22.0665C145.441 23.4036 144.648 23.8824 143.097 23.8824C142.057 23.8824 141.842 23.8328 141.33 23.4366C139.977 22.3967 139.498 20.8119 139.481 17.3618C139.465 13.5815 140.092 11.7491 141.71 10.9568C142.519 10.5606 143.575 10.4946 144.285 10.7917Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M157.442 10.3295C156.55 10.6101 155.576 11.4025 154.982 12.3269C154.735 12.7396 154.487 13.0698 154.454 13.0698C154.421 13.0698 154.454 12.4755 154.503 11.7492L154.619 10.4285H152.275C150.756 10.4285 149.964 10.4946 150.03 10.5936C150.079 10.6761 150.277 10.7587 150.459 10.7587C150.641 10.7587 150.971 10.9403 151.185 11.1714L151.598 11.5676V17.1637C151.598 22.5287 151.582 22.7763 151.268 23.189C151.086 23.4201 150.674 23.7008 150.343 23.7998C150.03 23.9154 149.799 24.0309 149.832 24.0639C149.964 24.2125 156.055 24.1465 156.055 24.0144C156.055 23.9484 155.841 23.8328 155.56 23.7503C154.619 23.5027 154.569 23.3046 154.569 18.93V14.9517L155.131 13.8126C155.775 12.4425 156.369 11.8152 157.211 11.5676C158.531 11.1714 159.687 11.8482 159.968 13.2018C160.034 13.532 160.1 15.744 160.083 18.1046C160.083 22.9249 160.034 23.1725 158.994 23.6843C158.647 23.8493 158.366 24.0474 158.366 24.097C158.366 24.163 159.802 24.1795 161.536 24.163C164.276 24.1135 164.639 24.0639 164.144 23.8989C163.715 23.7503 163.467 23.5027 163.187 22.9579C162.807 22.2481 162.807 22.1325 162.873 18.8475C162.955 14.8196 163.17 13.8126 164.194 12.6406C165.25 11.452 166.059 11.1714 167.181 11.6501C167.528 11.7987 167.825 12.0958 168.04 12.5085C168.32 13.1028 168.353 13.5485 168.353 17.857C168.353 23.189 168.304 23.4366 167.198 23.7998C166.868 23.9154 166.637 24.0309 166.67 24.0639C166.769 24.163 172.91 24.163 173.009 24.0639C173.042 24.0309 172.811 23.9154 172.48 23.7998C171.358 23.4201 171.341 23.3376 171.242 17.6919C171.16 12.9707 171.143 12.6076 170.797 11.8812C170.301 10.7917 169.377 10.2469 167.908 10.1314C167.247 10.0819 166.472 10.1479 166.026 10.2635C165.002 10.5606 163.715 11.6006 163.286 12.492C162.922 13.2514 162.658 13.3174 162.658 12.6736C162.658 12.0133 161.717 10.7752 160.958 10.4285C160.149 10.0489 158.482 10.0158 157.442 10.3295Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M173.19 11.32C172.447 11.8812 172.563 13.0038 173.405 13.3009C174.395 13.6476 175.155 13.0038 174.973 11.9968C174.824 11.2209 173.834 10.8247 173.19 11.32ZM174.61 11.7987C174.758 11.9968 174.824 12.3269 174.775 12.525C174.692 12.8387 174.098 13.2349 173.718 13.2349C173.438 13.2349 172.893 12.5911 172.893 12.2609C172.893 11.4355 174.164 11.1054 174.61 11.7987Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M173.405 12.2774C173.421 12.6736 173.454 12.8551 173.504 12.6901C173.537 12.5415 173.652 12.4094 173.751 12.4094C173.85 12.4094 174.032 12.5415 174.148 12.6901C174.329 12.9542 174.362 12.9047 174.329 12.3269C174.296 11.7326 174.247 11.6666 173.85 11.6171C173.405 11.5675 173.388 11.584 173.405 12.2774ZM173.95 12.0958C173.9 12.1783 173.784 12.2113 173.702 12.1453C173.619 12.0958 173.586 11.9802 173.652 11.8977C173.702 11.8151 173.817 11.7821 173.9 11.8482C173.983 11.8977 174.016 12.0132 173.95 12.0958Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M120.035 21.5713C119.408 21.9179 119.16 22.7763 119.474 23.5192C119.903 24.5592 121.026 24.7903 121.867 23.9979C122.528 23.4036 122.627 22.8589 122.214 22.182C121.702 21.3566 120.877 21.1255 120.035 21.5713Z\"\n                fill=\"currentColor\"\n              />\n            </svg>\n          </a> */}\n          <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex items-center justify-center bg-white p-8 text-black transition-colors\"\n            href=\"https://www.doordash.com\"\n          >\n            <svg\n              height=\"24\"\n              width=\"900\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              viewBox=\"-0.10467479090509069 -0.01 154.13651397511398 17.862000000000002\"\n            >\n              <path\n                d=\"M44.674 5.074v7.865h1.961a3.743 3.743 0 0 0 2.667-1.174A3.939 3.939 0 0 0 50.377 9a3.872 3.872 0 0 0-1.06-2.777 3.677 3.677 0 0 0-2.682-1.15h-1.96zm1.961-2.466c3.655 0 6.42 2.832 6.42 6.392s-2.765 6.408-6.42 6.408H42.31a.314.314 0 0 1-.31-.318V2.939c0-.176.139-.319.31-.319zm15.841 10.605c1.66.005 3.159-1.016 3.798-2.587a4.285 4.285 0 0 0-.878-4.587 4.027 4.027 0 0 0-4.47-.922C59.392 5.765 58.39 7.298 58.387 9c0 2.32 1.828 4.202 4.09 4.213m0-10.895c3.847 0 6.788 3.032 6.788 6.682s-2.941 6.682-6.789 6.682c-3.847 0-6.763-3.017-6.763-6.682s2.941-6.682 6.763-6.682M78.61 13.213c2.263-.006 4.094-1.89 4.092-4.21-.003-2.322-1.838-4.202-4.1-4.203-2.264 0-4.1 1.879-4.103 4.2 0 1.118.433 2.19 1.204 2.98a4.056 4.056 0 0 0 2.906 1.233m0-10.895c3.835 0 6.776 3.017 6.776 6.682s-2.953 6.682-6.776 6.682c-3.822 0-6.788-3.017-6.788-6.682s2.941-6.682 6.788-6.682m15.746 2.756H91.59v3.385h2.765a1.6 1.6 0 0 0 1.188-.453c.32-.31.502-.74.506-1.192a1.703 1.703 0 0 0-.48-1.246 1.616 1.616 0 0 0-1.214-.491zm-5.44-2.135c0-.176.14-.319.311-.319h5.206c2.482 0 4.278 1.865 4.278 4.207.019 1.584-.846 3.039-2.227 3.748l2.401 4.353a.325.325 0 0 1 .012.34.308.308 0 0 1-.294.156h-2.107a.31.31 0 0 1-.282-.172l-2.314-4.308H91.58v4.136a.314.314 0 0 1-.31.319h-2.03a.314.314 0 0 1-.31-.319l-.014-12.14zm16.767 2.195V13h1.96a3.743 3.743 0 0 0 2.668-1.17 3.938 3.938 0 0 0 1.077-2.763 3.872 3.872 0 0 0-1.057-2.782 3.677 3.677 0 0 0-2.687-1.15zm1.96-2.469c3.655 0 6.42 2.835 6.42 6.402s-2.765 6.402-6.42 6.402h-4.312a.314.314 0 0 1-.31-.319V3c0-.177.139-.32.31-.32l4.313-.015zm14.576 3.128l-1.517 4.206h3.031zm-2.407 6.583l-.993 2.8a.31.31 0 0 1-.31.22h-2.154a.307.307 0 0 1-.283-.134.325.325 0 0 1-.027-.318l4.654-12.139a.31.31 0 0 1 .31-.21h2.41a.31.31 0 0 1 .311.21l4.654 12.139a.325.325 0 0 1-.028.318.307.307 0 0 1-.283.133h-2.153a.31.31 0 0 1-.31-.22l-.993-2.8h-4.805zm11.069-6.31c0-2.01 1.694-3.748 4.369-3.748a5.457 5.457 0 0 1 3.88 1.499.322.322 0 0 1 0 .468L137.936 5.5a.306.306 0 0 1-.44 0 3.097 3.097 0 0 0-2.07-.837c-1.07 0-1.861.637-1.861 1.372 0 2.376 6.323 1.005 6.323 5.577-.003 2.317-1.71 4.07-4.728 4.07a6.25 6.25 0 0 1-4.511-1.858.322.322 0 0 1 0-.468l1.154-1.184a.307.307 0 0 1 .45 0 3.935 3.935 0 0 0 2.727 1.14c1.337 0 2.218-.732 2.218-1.649 0-2.377-6.32-1.005-6.32-5.578m20.476-3.162v4.804h-5.352V2.923a.314.314 0 0 0-.31-.318h-2.03a.314.314 0 0 0-.31.318V15.06c0 .176.14.319.31.319h2.03c.17 0 .31-.143.31-.319v-4.88h5.348v4.88c0 .176.14.319.31.319h2.03c.171 0 .31-.143.31-.319V2.923a.314.314 0 0 0-.31-.318h-2.03a.314.314 0 0 0-.306.318zM30.605 4.225C29.197 1.615 26.442-.01 23.446 0H.778a.78.78 0 0 0-.716.478.763.763 0 0 0 .168.836l4.938 4.9c.436.434 1.03.677 1.648.677h15.98c1.139-.012 2.07.89 2.082 2.013s-.901 2.043-2.04 2.054H11.821a.78.78 0 0 0-.718.476.763.763 0 0 0 .167.838l4.941 4.904a2.34 2.34 0 0 0 1.648.676h4.983c6.483 0 11.385-6.84 7.763-13.63\"\n                fill=\"#ff3008\"\n              />\n            </svg>\n          </a>\n\n          {/* <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex h-[100px] items-center justify-center bg-white p-8 text-[#8C1515] transition-colors\"\n            href=\"https://www.stanford.edu\"\n          >\n            <svg\n              width=\"200\"\n              height=\"66\"\n              viewBox=\"0 0 200 66\"\n              fill=\"none\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n            >\n              <path\n                d=\"M128.391 9.13505C126.21 9.64297 124.851 11.0173 124.358 13.1984C124.268 13.5719 124.163 14.6773 124.104 15.6483L124.014 17.4111L123.073 17.8742C122.281 18.2776 122.117 18.412 122.117 18.7257C122.117 19.0843 122.147 19.0992 123.088 19.0992H124.059V24.1186C124.059 27.3304 123.999 29.3621 123.895 29.7953C123.7 30.5423 123.342 30.8709 122.535 31.0054C122.072 31.0651 121.967 31.1548 121.967 31.4087V31.7374L126.001 31.7523H130.034V31.4237C130.034 31.1398 129.93 31.0801 129.258 30.9904C128.361 30.8709 127.734 30.5722 127.554 30.1987C127.495 30.0493 127.405 27.4948 127.375 24.507L127.315 19.0992H128.974H130.632V18.2029V17.3065H128.974H127.301L127.375 14.7968C127.435 12.6457 127.48 12.1975 127.734 11.7792C128.182 11.0024 128.705 10.7036 129.541 10.7036C130.512 10.7036 131.17 11.0622 131.648 11.8688C131.872 12.2274 132.141 12.5112 132.26 12.4813C132.38 12.4664 132.813 11.9884 133.231 11.4207L133.978 10.3899L133.47 9.97162C133.201 9.7326 132.664 9.44877 132.29 9.31432C131.349 8.98566 129.407 8.89603 128.391 9.13505Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M171.639 9.31432C171.146 9.41889 170.085 9.59816 169.278 9.68779C167.919 9.86705 167.814 9.89693 167.859 10.1957C167.889 10.4347 168.038 10.5244 168.472 10.5991C168.785 10.6439 169.189 10.8381 169.368 11.0173C169.682 11.3161 169.697 11.4954 169.742 14.2292L169.801 17.1123L167.829 17.0377C165.589 16.963 164.647 17.1123 163.228 17.7846C161.495 18.5913 160.091 20.3989 159.553 22.4754C159.24 23.6854 159.24 26.225 159.553 27.3902C160.196 29.87 162.108 31.588 164.677 31.9913C166.35 32.2453 167.605 31.9764 169.667 30.9307C169.727 30.9008 169.771 31.11 169.771 31.3938C169.771 32.0362 169.966 32.1258 170.907 31.8718C171.325 31.7673 172.416 31.6029 173.327 31.4984L175 31.3042V30.8859C175 30.4975 174.955 30.4526 174.492 30.4526C174.163 30.4526 173.82 30.3182 173.551 30.0941L173.133 29.7356V19.4129C173.133 9.17987 173.133 9.09024 172.834 9.10518C172.67 9.12012 172.132 9.20975 171.639 9.31432ZM168.591 19.0245C169.846 19.4727 169.771 19.1291 169.771 24.5668V29.3771L169.099 29.631C168.173 29.9895 166.052 29.885 165.215 29.4518C163.467 28.5256 162.601 26.4939 162.795 23.7601C163.049 20.3541 164.319 18.8004 166.873 18.8004C167.456 18.8004 168.233 18.905 168.591 19.0245Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M28.182 12.3469L25 15.4392L25.0149 23.6556V31.8719L27.8234 34.6803L30.617 37.4739H31.8569C33.037 37.4739 33.0819 37.4888 33.0819 37.8175C33.0819 38.2955 33.3508 38.5196 33.9782 38.5345C34.4264 38.5345 34.4562 38.5495 34.1724 38.669C33.9931 38.7437 33.769 38.9379 33.6794 39.1022C33.53 39.401 33.4105 39.4159 29.2725 39.4159H25.0149V43.6287V47.8563L28.2417 50.9486L31.4685 54.0559H39.9238H48.3941L51.576 50.9785L54.7431 47.9011V39.879V31.8719L52.0093 29.1381L49.2904 26.4192H46.8853H44.4801L43.8676 25.986C43.539 25.747 43.2253 25.4781 43.1805 25.3884C43.0759 25.2241 44.5698 25.3287 45.242 25.5378C45.7798 25.7022 46.3774 25.3735 46.3774 24.9253C46.3774 24.6415 46.4969 24.6266 50.5602 24.6266H54.7431V19.9507V15.2899L51.576 12.2573L48.424 9.23966H39.8939H31.3639L28.182 12.3469ZM51.0382 12.7801L53.9662 15.5886L53.9812 19.7267L53.9961 23.8796H49.7834C45.72 23.8796 45.5856 23.8946 45.8246 24.1485C46.1831 24.5369 46.1533 25.1793 45.7798 25.2988C45.6155 25.3436 45.4362 25.3586 45.3765 25.3287C45.3018 25.2839 44.8835 25.2092 44.4353 25.1494C43.9872 25.1046 43.3597 24.9851 43.0311 24.9104C42.7174 24.8208 42.4335 24.7909 42.3887 24.8208C42.2393 24.9851 43.3597 26.0308 44.271 26.5686L45.2719 27.1662H47.0496H48.8422L51.4117 29.7356L53.9961 32.32V39.9537V47.5725L51.0233 50.4407L48.0505 53.309H39.9238H31.7971L28.7944 50.4258L25.7918 47.5576L25.7768 43.8527L25.7619 40.1629H29.9746C33.2462 40.1629 34.1425 40.1181 34.0081 39.9836C33.9035 39.879 33.8288 39.655 33.8288 39.4757C33.8288 39.147 34.0678 39.0126 35.2629 38.6839C35.8754 38.5196 36.6522 37.8623 36.473 37.6681C36.4132 37.6083 36.1144 37.683 35.8007 37.8324C35.5019 37.9818 34.8596 38.1461 34.3965 38.176C33.3806 38.2806 33.1864 38.0565 33.5748 37.2349L33.8288 36.727H32.3648H30.9157L28.3463 34.1575L25.7619 31.5731V23.6556V15.738L28.7048 12.8698L31.6627 10.0015L39.879 9.98659H48.1102L51.0382 12.7801Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M29.2875 13.3777L26.5089 16.0367V23.6406V31.2593L28.8543 33.6197L31.1997 35.98H33.0969C34.6804 35.98 35.0987 35.9352 35.756 35.6663C36.3386 35.4123 36.5179 35.2629 36.5179 35.0239C36.5179 34.7699 36.4133 34.6803 36.0398 34.6355C35.517 34.5757 34.8298 34.5907 34.3518 34.6654C34.1277 34.6952 34.038 34.6206 34.0082 34.3218C33.9484 33.769 34.5609 33.0968 35.8307 32.3648C37.5337 31.3639 38.4151 30.3033 37.0109 30.9307C36.2938 31.2444 35.1435 31.2295 34.8149 30.9008C34.5609 30.6468 35.2033 29.9746 36.2191 29.4069C37.2798 28.8243 37.6383 28.3612 36.7569 28.7347C36.4581 28.8691 36.1892 28.9588 36.1444 28.9588C35.2033 28.9438 34.9941 29.0036 34.531 29.4069C33.5899 30.2286 33.2762 29.9148 33.5451 28.4509C33.7841 27.0615 34.1874 26.4939 35.4273 25.7171C36.8764 24.8207 38.1313 23.3866 36.966 23.9842C36.7121 24.1037 36.1295 24.2232 35.6514 24.2531C35.1883 24.268 34.6207 24.3427 34.4115 24.4024C33.9185 24.522 33.6048 24.1784 33.7393 23.6555C33.8588 23.1924 35.3228 22.0869 36.981 21.1906C37.6383 20.847 38.1611 20.5184 38.1611 20.4586C38.1611 20.3839 37.698 20.369 37.1154 20.4138C36.2191 20.4736 36.0548 20.4437 35.9801 20.2196C35.8158 19.8163 36.0847 19.5175 37.0109 19.0245C37.9221 18.5465 38.1014 18.2626 37.474 18.2776C37.2499 18.2776 37.0109 18.1581 36.9212 18.0236C36.7868 17.8145 36.8316 17.68 37.1154 17.3663C37.3246 17.1422 37.698 16.8434 37.9371 16.6941C38.2209 16.5148 38.2956 16.4102 38.1462 16.4102C38.0117 16.4102 37.8026 16.3056 37.698 16.1712C37.5188 15.9621 37.5935 15.8425 38.2358 15.2898C38.9529 14.6624 38.9678 14.6325 38.7139 14.3487C38.3703 13.9752 38.4002 13.6764 38.7886 13.4972C39.0126 13.3926 39.1919 13.0938 39.3562 12.5261C39.5355 11.8987 39.655 11.7045 39.8791 11.7045C40.1181 11.7045 40.2077 11.8539 40.3123 12.4216C40.4019 12.9743 40.5215 13.1685 40.7904 13.2731C41.1788 13.4225 41.2684 13.8408 40.9696 14.1395C40.8501 14.259 40.8949 14.3487 41.1937 14.4532C41.6717 14.6474 41.7016 14.8566 41.2833 15.3048L40.9846 15.6334L41.5821 16.1264C42.2095 16.6194 42.329 16.963 42.0153 17.2767C41.8809 17.4111 41.9556 17.5754 42.3888 17.9638C43.0312 18.5614 43.1208 19.0544 42.583 19.1888C42.3739 19.2336 42.1348 19.2336 42.0602 19.1739C41.9705 19.1291 41.8958 19.1739 41.8958 19.2934C41.8958 19.4129 42.1797 19.622 42.5382 19.7714C43.4495 20.1748 44.1366 20.7126 44.1366 21.0412C44.1366 21.4745 43.6138 21.6836 42.8519 21.5641C42.4934 21.5193 42.1946 21.5043 42.1946 21.5491C42.1946 21.579 42.583 21.7882 43.0461 22.0122C43.5092 22.2214 44.1217 22.565 44.3757 22.7592L44.8686 23.1327H49.0515H53.2493V19.5772L53.2642 16.0367L50.5304 13.3926L47.7966 10.7484L39.9239 10.7335H32.0512L29.2875 13.3777Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M39.6102 13.1237C39.4309 13.6017 39.2516 13.8706 39.1022 13.8706C38.908 13.8706 38.923 13.9453 39.2068 14.2441C39.6102 14.6624 39.5205 14.8267 38.5495 15.5438C38.0565 15.9023 38.0117 15.9621 38.2956 15.9621C38.6989 15.9621 39.2068 16.216 39.2068 16.4252C39.2068 16.4999 38.923 16.6941 38.5794 16.8435C38.2209 16.9928 37.7727 17.2916 37.5785 17.5157L37.22 17.9041H37.7727C38.3105 17.9041 38.3254 17.919 38.2806 18.3821C38.2358 18.8154 38.1312 18.9199 37.459 19.2486C36.1294 19.891 36.1145 20.0254 37.4142 19.9657L38.5346 19.9208V20.369C38.5346 20.7574 38.4151 20.8769 37.3096 21.4894C35.6813 22.3857 34.1276 23.5211 34.1276 23.8049C34.1276 23.9842 34.2173 24.029 34.4563 23.9394C34.6356 23.8945 35.1734 23.8348 35.6514 23.8049C36.2788 23.79 36.6523 23.6854 36.966 23.4613C37.5187 23.043 37.6831 23.043 37.8026 23.4912C37.9818 24.2232 37.1453 25.2092 35.5169 26.1802C34.4862 26.7927 34.0828 27.435 33.9036 28.7347L33.799 29.4816L34.1575 29.1679C34.6804 28.6899 35.0987 28.5106 35.7111 28.5106C36.0099 28.5106 36.5477 28.3612 36.9212 28.182C37.4291 27.928 37.6084 27.8981 37.7578 28.0475C38.1013 28.3911 37.444 29.2725 36.3834 29.8551C35.8605 30.1389 35.4124 30.4377 35.4124 30.5274C35.3825 30.8261 36.3834 30.7664 37.0556 30.4377C37.6532 30.1539 37.7578 30.1389 37.9669 30.3331C38.1611 30.5274 38.1611 30.617 37.952 31.095C37.7876 31.5133 37.444 31.827 36.5627 32.3798C35.263 33.2014 34.4264 33.8587 34.4264 34.038C34.4264 34.0977 34.7849 34.1724 35.2182 34.1873C36.8913 34.2471 36.8913 34.2471 36.9361 34.6206C37.0556 35.3675 36.8614 35.6065 35.6215 36.2041C34.516 36.7269 33.8289 37.2797 33.8289 37.6681C33.8289 37.8772 35.009 37.7129 35.6663 37.3992C36.3236 37.0855 36.966 37.2647 36.966 37.7577C36.966 38.1611 36.0249 38.9229 35.2929 39.1171C34.1127 39.4159 34.0679 39.4757 34.5758 39.9388C35.1136 40.4168 34.9642 40.8351 34.0231 41.4625C33.067 42.0899 32.6487 42.7771 32.5143 43.8527C32.4396 44.5698 32.5889 44.6594 33.0222 44.1814C33.4405 43.7033 35.248 42.8518 35.8008 42.8518C36.5627 42.8518 37.2648 42.5979 37.8773 42.1049C38.191 41.8659 38.5196 41.6567 38.5943 41.6567C38.8184 41.6567 38.8184 47.9161 38.5943 48.4837C38.5196 48.7078 38.0864 49.2755 37.6532 49.7236C37.22 50.1718 36.712 50.7843 36.5178 51.0681C36.3236 51.352 35.9651 51.8151 35.7111 52.084L35.263 52.562H39.9687C44.5997 52.562 44.6595 52.562 44.2113 52.2782C43.9723 52.1288 43.6287 51.8599 43.4793 51.6956C43.3299 51.5163 42.8817 51.1578 42.4784 50.9038C41.9705 50.5752 41.6568 50.2316 41.3879 49.6788C41.0144 48.9468 40.9995 48.8124 40.9995 46.1981C40.9995 43.2104 41.0443 43.0908 41.836 43.6137C42.1497 43.8228 42.5382 43.8975 43.315 43.8975C44.4653 43.8975 45.0927 44.1216 46.3924 45.003C47.4381 45.7051 47.5875 45.5557 47.1542 44.2262C46.8256 43.2104 46.467 42.7473 45.6006 42.2095C45.1375 41.9256 45.0628 41.4476 45.4064 40.9994C45.7649 40.5214 45.6753 40.3272 45.0031 40.1628C43.9424 39.894 43.554 39.6997 43.1656 39.2366C42.7025 38.6839 42.6875 38.415 43.1656 38.1909C43.4644 38.0565 43.6586 38.0864 44.2711 38.3553C45.018 38.669 45.9293 38.7735 45.9293 38.5345C45.9293 38.176 45.1674 37.5336 44.3009 37.1303C43.061 36.5775 42.7921 36.3385 42.7921 35.8007C42.7921 35.2779 43.1506 35.0986 43.5241 35.4571C43.6884 35.5916 44.1665 35.7559 44.6146 35.8157C45.0479 35.8754 45.511 35.98 45.6155 36.0547C45.7799 36.1443 45.7948 36.0846 45.7201 35.741C45.5259 34.9492 44.9732 34.3666 43.808 33.6645C42.4933 32.8727 41.7464 32.2005 41.7464 31.7822C41.7464 31.4536 41.9555 31.4237 42.7324 31.6926C43.0909 31.827 43.6137 31.8569 44.1217 31.8121C44.8835 31.7225 44.9134 31.7075 44.6296 31.4685C44.4802 31.3191 43.9125 30.9905 43.4046 30.7216C42.1199 30.0493 41.5373 28.9887 42.3141 28.7496C42.4635 28.7048 43.2702 28.9289 44.1366 29.2576C44.9881 29.5862 45.75 29.8551 45.8247 29.8551C46.0039 29.8551 45.8097 29.0036 45.4662 28.2567C45.2271 27.7786 44.9284 27.4798 44.0619 26.9421C42.7473 26.1354 41.8958 25.2092 41.8958 24.5967C41.8958 24.1336 41.9854 24.1037 42.5979 24.3427C43.1955 24.5817 45.6305 24.9104 45.6305 24.7461C45.6305 24.1933 43.9573 22.8488 42.4635 22.1766C41.9257 21.9525 41.597 21.7135 41.597 21.5641C41.597 21.2205 42.2394 21.0263 43.0311 21.1458C43.9872 21.2803 43.8677 20.9516 42.7025 20.3391C41.5821 19.7416 41.4178 19.5922 41.4775 19.2038C41.5074 18.9498 41.6568 18.8602 42.0452 18.8004L42.568 18.7108L42.1946 18.3821C41.9854 18.2029 41.6568 17.919 41.4476 17.7547L41.0742 17.4559L41.4476 17.1721L41.8211 16.8733L41.1787 16.3355C40.8351 16.0368 40.5513 15.7081 40.5513 15.6185C40.5513 15.5288 40.7007 15.3048 40.88 15.1106C41.1937 14.767 41.1937 14.767 40.8949 14.767C40.7306 14.767 40.5065 14.6624 40.4019 14.5429C40.2525 14.3487 40.2525 14.2591 40.4617 14.035C40.7007 13.781 40.6858 13.7511 40.4019 13.5569C40.2227 13.4374 40.0284 13.1237 39.9836 12.8548L39.8791 12.3618L39.6102 13.1237Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M46.0038 28.167C46.0636 28.3164 46.1383 28.8243 46.1831 29.2874C46.2578 30.1091 46.2578 30.1389 45.8843 30.2286C45.6752 30.2883 45.3764 30.2585 45.2121 30.1688C44.9581 30.0344 42.5082 29.1082 42.3887 29.1082C42.3737 29.1082 42.4335 29.2874 42.5231 29.4966C42.6277 29.7356 43.031 30.0493 43.5539 30.3033C44.9731 31.0353 45.7051 31.842 45.1971 32.1557C45.0029 32.2752 43.046 32.2901 42.6277 32.1706C42.299 32.081 42.299 32.0959 42.7173 32.4545C42.9713 32.6636 43.5389 33.0371 44.002 33.2761C44.4651 33.53 45.0179 33.9185 45.2569 34.1425C45.7648 34.6505 46.213 35.6065 46.2279 36.1891C46.2279 36.5775 46.1831 36.6074 45.8246 36.5327C45.5855 36.4879 45.0627 36.3833 44.6295 36.2937C44.2112 36.219 43.7182 36.0696 43.5389 35.98C43.27 35.8306 43.2252 35.8456 43.27 36.0099C43.3149 36.1144 43.8377 36.4431 44.4651 36.7419C45.72 37.3544 46.3773 38.0266 46.3773 38.6839C46.3773 39.1022 46.3474 39.1171 45.7051 39.1171C45.3465 39.1171 44.6743 38.9827 44.2112 38.8034C43.7481 38.6242 43.3298 38.5345 43.285 38.5793C43.2252 38.6242 43.3447 38.8333 43.5389 39.0424C43.8676 39.386 44.2859 39.5653 45.3764 39.7894C46.0038 39.9238 46.198 40.4766 45.8395 41.1488C45.5706 41.6567 45.5706 41.6866 45.8246 41.8211C46.5117 42.2244 47.1242 42.9265 47.4081 43.6585C47.8712 44.8984 47.9309 45.4512 47.6471 45.735C47.3483 46.0338 46.9599 45.9143 45.6453 45.0478C44.7639 44.4801 44.5996 44.4204 43.4045 44.3457C42.7024 44.3009 41.9704 44.1814 41.7911 44.0768L41.4475 43.8826V46.3774C41.4475 49.3352 41.6268 49.9179 42.7024 50.5453C43.031 50.7395 43.5539 51.1428 43.8377 51.4416C44.495 52.1138 45.6752 52.562 46.8255 52.562H47.7218L50.4855 49.9029L53.2342 47.2588L53.2491 39.9388V32.6188L50.8888 30.2585L48.5434 27.9131H47.2139C46.0188 27.9131 45.9142 27.943 46.0038 28.167Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M26.5088 44.0021L26.4939 47.1094L29.3024 49.8282L32.1109 52.5471L33.0221 52.562C34.5758 52.562 35.2331 52.2184 36.0995 50.9187C36.3535 50.5453 36.9211 49.873 37.3693 49.4099C38.1013 48.6481 38.1909 48.4987 38.3254 47.6173C38.4001 47.0944 38.4449 45.6603 38.43 44.4353L38.3851 42.2095L37.937 42.5829C37.3842 43.046 37.1452 43.1357 36.1742 43.2402C35.2181 43.3448 34.0529 43.8527 33.3508 44.4801C32.5441 45.1823 32.0362 45.0329 32.0362 44.0917C32.0362 42.9863 32.8279 41.597 33.7541 41.0891C33.9782 40.9546 33.1118 40.9247 30.2883 40.9098H26.5088V44.0021Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M70.0553 11.0472C67.0228 11.5551 65.2899 13.4822 65.3048 16.3505C65.3198 18.7706 66.1414 19.8312 69.9209 22.3857C73.1477 24.5817 73.7153 25.2689 73.7153 26.957C73.7153 28.2268 73.1029 29.2127 71.9974 29.7207C70.6828 30.3033 68.5316 30.2136 67.1423 29.5115C66.4103 29.138 65.7978 28.1969 65.7978 27.435C65.7978 27.0616 65.738 27.0167 65.3198 27.0167H64.8268L64.8716 29.0634C64.9164 30.4228 64.9911 31.1548 65.0957 31.2295C65.5438 31.5133 67.5307 31.842 69.159 31.9017C73.7004 32.081 76.3147 30.4825 76.8824 27.196C77.211 25.2838 76.6882 23.4613 75.4632 22.1915C75.1196 21.8479 73.6855 20.7873 72.2962 19.8611C70.8919 18.9349 69.5325 17.9639 69.2785 17.7099C67.934 16.4252 68.1133 13.9155 69.6072 13.2133C70.9218 12.5859 73.2821 12.6008 74.5818 13.2283C75.1943 13.5271 75.8068 14.5279 75.8068 15.2599C75.8068 15.6334 75.8516 15.6782 76.1952 15.6334L76.5985 15.5886L76.6135 13.4524C76.6284 10.9725 76.8973 11.2564 74.3876 11.0323C72.5053 10.868 71.1309 10.868 70.0553 11.0472Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M82.3049 14.7221C81.3489 16.3803 80.1239 17.6202 78.8391 18.2477C78.3611 18.4867 78.1968 18.651 78.1968 18.905C78.1968 19.2336 78.2565 19.2485 79.1379 19.2485H80.0641L80.0193 23.4912C79.9894 26.0009 80.0193 28.152 80.1239 28.7496C80.348 30.2136 80.8858 31.0053 82.0062 31.573C82.8129 31.9764 83.0369 32.0212 84.3366 32.0212C85.6363 32.0212 85.8454 31.9764 86.7268 31.5431C87.9219 30.9456 88.2804 30.5871 88.146 30.109L88.0564 29.7505L87.2795 30.0194C86.2637 30.3779 85.457 30.363 84.6653 30.0194C83.5299 29.4965 83.5 29.392 83.4552 23.9991L83.4104 19.2635L85.8454 19.2187L88.2804 19.1739L88.3253 18.2327L88.3701 17.3065H85.9052H83.4403L83.3955 15.6931C83.3506 14.1992 83.3357 14.0947 83.0369 14.0499C82.7979 14.02 82.6186 14.1694 82.3049 14.7221Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M94.1963 17.0825C93.7481 17.2169 93.1655 17.441 92.9116 17.6053C92.2542 18.0087 91.2982 19.0693 91.0591 19.6669C90.8799 20.115 90.8799 20.1748 91.1338 20.369C91.5521 20.6827 91.6866 20.6379 92.0451 20.0852C92.4933 19.383 93.5838 18.8751 94.6295 18.8901C96.3475 18.9199 97.3185 19.9358 97.3185 21.6985V22.5052L94.7042 23.3567C91.7314 24.3128 90.8948 24.7909 90.2226 25.8814C89.8043 26.5686 89.7744 26.718 89.7744 28.0475C89.7744 29.3322 89.8192 29.5563 90.1778 30.1688C90.9396 31.4535 92.2991 32.0362 94.3009 31.9166C95.496 31.842 96.4371 31.4834 97.1243 30.856C97.3185 30.6917 97.4828 30.5722 97.5127 30.617C97.5426 30.6469 97.6621 30.856 97.7965 31.0651C98.3493 31.9316 99.9626 32.2154 101.158 31.6328C101.516 31.4685 102.039 31.0502 102.323 30.7066L102.846 30.0941L102.547 29.7953C102.323 29.5563 102.218 29.5264 102.069 29.6758C101.785 29.9597 101.277 29.885 100.964 29.5414C100.695 29.2426 100.68 28.9886 100.725 25.4033C100.784 20.2794 100.575 19.0843 99.4547 18.0236C98.409 17.0227 95.9441 16.5895 94.1963 17.0825ZM97.3185 26.8673V29.2426L96.8404 29.4966C96.1981 29.8252 94.5249 29.9298 94.032 29.6758C93.4493 29.3621 92.9862 28.5554 92.9862 27.8533C93.0012 27.0616 93.4493 26.0607 93.9573 25.732C94.3905 25.4482 96.8255 24.4921 97.1392 24.4771C97.2737 24.4771 97.3185 25.0448 97.3185 26.8673Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M137.578 17.0227C134.65 17.5157 132.604 19.4578 131.902 22.4007C131.618 23.6257 131.618 25.9711 131.902 27.0616C132.753 30.3182 135.158 32.0959 138.714 32.0959C140.73 32.0959 142.792 31.349 143.957 30.1838C144.585 29.5564 145.406 28.0774 145.75 26.9421C146.004 26.1354 146.078 25.5229 146.078 24.2531C146.078 21.7135 145.451 20.0254 144.047 18.7108C142.493 17.2468 140.028 16.6045 137.578 17.0227ZM140.118 18.9349C141.044 19.3532 141.776 20.2645 142.165 21.5193C142.628 23.0132 142.657 26.1802 142.224 27.5247C141.627 29.3173 140.491 30.3033 138.953 30.3033C137.862 30.3033 137.265 30.0493 136.592 29.3024C134.635 27.1512 134.695 21.4745 136.682 19.5474C137.578 18.681 138.998 18.427 140.118 18.9349Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M108.074 17.3364C107.626 17.5008 106.536 17.7099 105.654 17.7995L104.041 17.9639V18.3822C104.041 18.7407 104.101 18.8004 104.474 18.8004C105.266 18.8004 105.654 19.0395 105.834 19.6519C106.088 20.4885 105.983 29.8103 105.714 30.2585C105.505 30.6469 104.803 31.0502 104.34 31.0502C104.131 31.0502 104.041 31.1398 104.041 31.3938V31.7225L107.626 31.7374L111.212 31.7673V31.4087C111.212 31.1398 111.122 31.0502 110.913 31.0502C110.405 31.0502 109.718 30.617 109.538 30.1838C109.419 29.9149 109.344 28.1222 109.299 24.7311L109.24 19.6968L109.822 19.4428C110.136 19.3084 110.943 19.1739 111.66 19.144C113.333 19.0544 114.095 19.4129 114.648 20.5184C115.006 21.2504 115.021 21.355 115.066 25.0299C115.126 29.5115 115.021 30.3182 114.304 30.7664C114.035 30.9158 113.677 31.0502 113.497 31.0502C113.243 31.0502 113.154 31.1398 113.154 31.3938V31.7523H116.739H120.324V31.4087C120.324 31.1398 120.235 31.0502 120.025 31.0502C119.518 31.0502 118.83 30.617 118.651 30.1838C118.561 29.9447 118.457 27.8832 118.397 25L118.308 20.2196L117.889 19.3681C117.396 18.3523 116.664 17.7099 115.544 17.2916C114.857 17.0377 114.468 16.9928 113.154 17.0377C111.72 17.0825 111.495 17.1273 110.435 17.6352L109.285 18.173L109.24 17.6502C109.21 17.3514 109.135 17.0825 109.045 17.0675C108.971 17.0526 108.523 17.1721 108.074 17.3364Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M152.144 17.1422C151.755 17.3813 149.47 17.7547 148.469 17.7547H147.513V18.2776C147.513 18.7556 147.543 18.8004 147.961 18.8004C148.588 18.8004 149.246 19.1739 149.365 19.5773C149.485 20.0254 149.485 29.0783 149.365 29.6908C149.246 30.3033 148.648 30.8709 148.021 30.9905C147.632 31.0652 147.513 31.1548 147.513 31.4087V31.7374L151.471 31.7523L155.43 31.7673V31.349C155.43 30.9456 155.4 30.9307 154.534 30.8709C152.816 30.7514 152.771 30.5722 152.756 24.1634L152.741 19.876L153.234 19.6818C153.862 19.4129 155.102 19.4428 155.684 19.7565C155.938 19.876 156.282 20.1599 156.461 20.3839C156.954 21.0114 157.342 20.8321 158 19.6221C158.717 18.2925 158.702 17.9639 157.835 17.5008C156.61 16.8584 154.414 17.0825 153.249 17.9788L152.756 18.3523L152.711 17.7248C152.667 17.0825 152.517 16.9331 152.144 17.1422Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M102.024 39.64C101.218 40.4318 101.755 41.6567 102.906 41.6567C103.533 41.6567 104.19 41.1339 104.19 40.626C104.19 39.4607 102.816 38.8333 102.024 39.64Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M141.597 39.4458C141.253 39.6101 140.94 40.1628 140.94 40.596C140.94 40.7454 141.074 41.0442 141.253 41.2683C141.672 41.7911 142.673 41.821 143.151 41.3131C144.137 40.2674 142.912 38.8034 141.597 39.4458Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M76.5536 39.9089C76.5536 40.0732 76.7329 40.2375 77.0167 40.3421C77.2706 40.4317 77.5246 40.6558 77.6142 40.8649C77.6889 41.0592 77.7487 43.0012 77.7487 45.1673C77.7487 47.4529 77.8234 49.4249 77.913 49.873C78.1371 50.9337 78.8392 51.8001 79.8849 52.323C80.6319 52.6964 80.9755 52.7711 82.2901 52.8309C84.1574 52.9056 85.2928 52.6964 86.219 52.069C87.7427 51.0532 88.0564 49.7087 88.0564 44.1216C88.0564 40.7305 88.1311 40.3869 88.8482 40.2823C89.0424 40.2525 89.1918 40.118 89.2217 39.9387C89.2665 39.6549 89.1918 39.64 87.0854 39.6848C85.3675 39.7147 84.9193 39.7744 84.9193 39.9387C84.9193 40.0433 85.1583 40.2226 85.4421 40.3122C86.1741 40.5512 86.2638 41.0293 86.2638 44.8685C86.2638 50.7843 85.8903 51.591 83.1266 51.591C81.573 51.591 80.841 51.2773 80.3779 50.3959C79.9746 49.6041 79.7953 47.1542 79.8999 43.7631C79.9895 40.8201 80.109 40.3869 80.7663 40.2823C80.9755 40.2525 81.1248 40.118 81.1547 39.9387C81.1995 39.6549 81.1099 39.64 78.8691 39.64C76.7329 39.64 76.5536 39.6549 76.5536 39.9089Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M148.021 41.9854C147.468 42.8817 146.781 43.5689 146.138 43.9125C145.376 44.3009 145.391 44.6445 146.168 44.6445H146.766L146.736 47.8115C146.691 51.352 146.781 51.7852 147.707 52.4425C148.603 53.0849 150.665 52.9504 151.591 52.2035C152.114 51.7702 152.024 51.3071 151.442 51.5312C150.949 51.7105 149.948 51.7105 149.574 51.5013C148.977 51.1876 148.857 50.5154 148.857 47.4828V44.6445H150.426H151.994V44.0469V43.4494H150.426H148.857V42.4036C148.857 41.1488 148.618 41.0293 148.021 41.9854Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M94.4652 43.3597C94.0021 43.4493 93.4494 43.6585 93.2253 43.7929C92.8519 44.0469 92.8369 44.0469 92.8369 43.7631C92.8369 43.3597 92.6427 43.2701 92.1348 43.4643C91.9107 43.5539 91.2086 43.6734 90.5961 43.7332C89.7147 43.8079 89.4757 43.8826 89.4309 44.0768C89.401 44.256 89.5205 44.3755 89.879 44.4652C90.7007 44.6743 90.7604 44.9582 90.7007 48.3791C90.6708 50.0672 90.5961 51.5611 90.5363 51.6955C90.4766 51.8449 90.1928 52.0391 89.9089 52.1437C89.6251 52.2333 89.401 52.4126 89.401 52.5172C89.401 52.6665 89.9239 52.7114 91.7165 52.7114C93.4942 52.7114 94.032 52.6665 94.032 52.5172C94.032 52.4126 93.8378 52.2483 93.5839 52.1586C93.3299 52.069 93.0759 51.815 92.9863 51.591C92.8967 51.3669 92.8369 49.8132 92.8369 48.0804V44.9731L93.3 44.8088C93.5689 44.7191 94.1366 44.6444 94.5698 44.6444C96.2429 44.6444 96.6911 45.7648 96.5268 49.4995C96.482 50.5751 96.3923 51.5611 96.3475 51.6955C96.2878 51.8449 96.0189 52.0391 95.735 52.1437C95.4512 52.2333 95.2271 52.4126 95.2271 52.5172C95.2271 52.6665 95.75 52.7114 97.5426 52.7114C99.3203 52.7114 99.8581 52.6665 99.8581 52.5172C99.8581 52.4126 99.6639 52.2483 99.41 52.1586C99.156 52.069 98.902 51.815 98.8124 51.591C98.7377 51.3818 98.663 50.0075 98.663 48.5584C98.663 45.8097 98.5435 45.0179 98.0207 44.2859C97.438 43.4643 95.8844 43.0609 94.4652 43.3597Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M103.249 43.4045C102.935 43.5091 102.203 43.6435 101.636 43.6884C100.769 43.778 100.605 43.8377 100.605 44.0618C100.605 44.256 100.71 44.3457 100.949 44.3457C101.143 44.3457 101.427 44.4353 101.591 44.5548C101.845 44.749 101.875 45.003 101.92 47.7517C101.979 51.3221 101.89 51.8599 101.128 52.1138C100.844 52.2034 100.605 52.3827 100.605 52.5022C100.605 52.6665 101.023 52.7114 102.995 52.7114C104.967 52.7114 105.385 52.6665 105.385 52.5022C105.385 52.3827 105.191 52.2483 104.937 52.1885C104.071 51.9943 104.041 51.8599 104.041 47.3782C104.041 45.1524 103.996 43.285 103.936 43.2551C103.862 43.2253 103.563 43.3 103.249 43.4045Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M118.143 43.539C115.275 44.5548 114.453 49.9776 116.858 51.9645C118.322 53.1745 121.206 53.1596 122.64 51.9047C123.148 51.4715 123.207 51.3669 123.028 51.1727C122.849 50.9486 122.789 50.9486 122.296 51.1578C121.982 51.2773 121.34 51.3968 120.847 51.3968C118.845 51.4416 117.934 50.6349 117.65 48.5584L117.531 47.7816H120.429H123.312V46.945C123.312 45.3317 122.64 44.1216 121.459 43.5838C120.713 43.2402 119.039 43.2253 118.143 43.539ZM120.578 44.8387C120.832 45.0777 120.981 45.4362 121.026 45.8993L121.116 46.5865H119.368H117.635V46.1981C117.635 45.6155 118.068 44.8088 118.517 44.5548C119.084 44.2411 120.115 44.3756 120.578 44.8387Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M126.912 43.4643C126.718 43.539 126.061 43.6435 125.448 43.7033C124.537 43.778 124.358 43.8377 124.358 44.0618C124.358 44.2262 124.537 44.3905 124.866 44.4951C125.149 44.5847 125.418 44.7639 125.463 44.8835C125.627 45.3017 125.553 51.0083 125.388 51.4864C125.284 51.815 125.09 51.9943 124.716 52.1138C124.432 52.2034 124.208 52.3827 124.208 52.5022C124.208 52.6815 124.656 52.7114 126.748 52.7114C129.183 52.7114 129.287 52.6964 129.287 52.4126C129.287 52.1736 129.183 52.1138 128.809 52.1138C127.764 52.1138 127.644 51.7105 127.644 47.9758V45.1225L128.167 44.9432C128.78 44.7341 129.541 44.9283 129.96 45.3914C130.109 45.5557 130.318 45.6902 130.423 45.6902C130.632 45.6902 131.379 44.3606 131.379 43.9871C131.379 43.8676 131.08 43.6585 130.707 43.5091C129.9 43.2103 128.884 43.3 128.152 43.7631L127.644 44.0768V43.6884C127.644 43.2701 127.495 43.2253 126.912 43.4643Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M133.918 43.6286C132.977 44.1066 132.574 44.7789 132.574 45.8395C132.574 47.0197 132.932 47.4977 134.815 48.7526C136.473 49.8581 136.682 50.1568 136.413 50.9635C136.234 51.5163 135.89 51.6656 134.845 51.6656C133.769 51.6656 133.246 51.322 133.022 50.4556C132.903 50.0373 132.783 49.873 132.574 49.873C132.305 49.873 132.275 49.9776 132.29 50.7245C132.32 52.2931 132.41 52.4574 133.246 52.6815C134.382 52.9802 136.279 52.8309 137.071 52.3976C138.027 51.8748 138.4 51.2175 138.4 50.112C138.4 48.6779 138.027 48.2148 135.741 46.8852C135.143 46.5416 134.591 46.1234 134.516 45.974C134.247 45.481 134.352 44.9731 134.785 44.6444C135.114 44.3755 135.338 44.3307 135.965 44.3755C136.861 44.4502 137.295 44.7639 137.429 45.4063C137.504 45.72 137.608 45.8395 137.847 45.8395C138.161 45.8395 138.176 45.7648 138.191 44.6594V43.4792L137.668 43.3896C137.369 43.3448 136.563 43.2999 135.86 43.2999C134.83 43.2999 134.456 43.3597 133.918 43.6286Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M142.493 43.4493C142.314 43.524 141.687 43.6286 141.104 43.6884C140.208 43.778 140.043 43.8228 140.043 44.0618C140.043 44.2411 140.148 44.3457 140.312 44.3457C140.462 44.3457 140.73 44.4502 140.91 44.5847C141.238 44.8088 141.238 44.8685 141.238 48.0505C141.238 51.6358 141.179 51.9345 140.372 52.1736C140.118 52.2483 139.894 52.3977 139.894 52.5022C139.894 52.6665 140.372 52.7114 142.284 52.7114C144.256 52.7114 144.674 52.6665 144.674 52.5022C144.674 52.3827 144.48 52.2483 144.241 52.1885C143.405 52.0092 143.405 51.9794 143.405 47.4529C143.405 43.3896 143.405 43.3 143.106 43.3149C142.941 43.3149 142.658 43.3746 142.493 43.4493Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M105.535 43.7481C105.535 43.9423 105.639 44.0469 105.834 44.0469C106.282 44.0469 106.506 44.525 108.239 48.902L109.792 52.8608L110.315 52.8309L110.823 52.7861L112.661 48.6779C114.379 44.8237 114.827 44.0469 115.335 44.0469C115.454 44.0469 115.544 43.9125 115.544 43.7481C115.544 43.4643 115.439 43.4494 113.676 43.4494C111.914 43.4494 111.809 43.4643 111.809 43.7481C111.809 43.9423 111.914 44.0469 112.078 44.0469C112.436 44.0469 112.705 44.3308 112.705 44.7192C112.705 45.1972 110.793 49.8282 110.704 49.5743C110.659 49.4547 110.255 48.3642 109.792 47.1542C109.344 45.9441 108.971 44.8237 108.971 44.6743C108.971 44.3308 109.255 44.0469 109.598 44.0469C109.762 44.0469 109.867 43.9423 109.867 43.7481C109.867 43.4643 109.762 43.4494 107.701 43.4494C105.639 43.4494 105.535 43.4643 105.535 43.7481Z\"\n                fill=\"currentColor\"\n              />\n              <path\n                d=\"M153.04 43.7332C153.04 43.9125 153.174 44.0469 153.399 44.1067C153.802 44.2112 153.877 44.3756 155.968 49.7087C157.432 53.4733 157.387 53.04 156.446 55.0717C155.893 56.2369 155.341 56.6403 154.459 56.5357C154.101 56.4909 153.757 56.3415 153.608 56.1622C153.473 55.983 153.279 55.8485 153.16 55.8485C152.876 55.8485 152.099 56.8644 152.203 57.1183C152.323 57.432 153.13 57.7906 153.936 57.8951C155.415 58.0744 156.73 57.447 157.432 56.2519C157.641 55.9083 158.717 53.1745 159.837 50.1718C161.749 45.0478 162.227 44.0469 162.705 44.0469C162.81 44.0469 162.9 43.9125 162.9 43.7481C162.9 43.4643 162.795 43.4494 161.107 43.4494C159.449 43.4494 159.314 43.4643 159.314 43.7332C159.314 43.9125 159.449 44.0469 159.673 44.1067C159.867 44.1515 160.076 44.3009 160.121 44.4503C160.211 44.6743 158.463 50.2614 158.328 50.1419C158.209 50.0224 156.327 44.7789 156.327 44.5698C156.327 44.4353 156.536 44.2411 156.775 44.1515C157.044 44.0469 157.223 43.8677 157.223 43.7183C157.223 43.4792 157.044 43.4494 155.131 43.4494C153.174 43.4494 153.04 43.4643 153.04 43.7332Z\"\n                fill=\"currentColor\"\n              />\n            </svg>\n          </a> */}\n          <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex items-center justify-center bg-white p-8 text-black transition-colors\"\n            href=\"https://www.coinweb.com/\"\n          >\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"500\"\n              height=\"27\"\n              viewBox=\"0 0 1175 225\"\n              fill=\"none\"\n            >\n              <path\n                className=\"logo__symbol\"\n                d=\"M182.823,156.68l-28.85-16.66l-0.61-0.35h-1.36l-50.55,29.21v50.33c3.59-0.37,7.08-1.48,10.21-3.29\n\t\tl71.14-41.07c3.28-1.91,5.24-5.31,5.24-9.11S186.083,158.57,182.823,156.68L182.823,156.68z\"\n                fill=\"#3a5cfc\"\n              ></path>\n\n              <polygon\n                className=\"logo__symbol\"\n                points=\"6.523,54.91 6.633,54.97 6.523,54.9\"\n                fill=\"#3a5cfc\"\n              ></polygon>\n\n              <path\n                className=\"logo__symbol\"\n                d=\"M96.373,168.88v50.33c-3.6-0.37-7.08-1.48-10.21-3.29l-71.56-41.32c-3.13-1.82-5.85-4.28-7.96-7.2l2.21-1.28\n\t\tl0,0l2.21-1.28l34.08-19.69h0.01l2.54-1.46l2.53-1.46l46.15,26.66L96.373,168.88z\"\n                fill=\"#3a5cfc\"\n              ></path>\n\n              <path\n                className=\"logo__symbol\"\n                d=\"M96.373,3.15V53.5l-46.15,26.64l-2.54-1.46l-2.53-1.46h-0.01l-20.94-12.1l-15.36-8.87l-2.21-1.28\n\t\tc2.11-2.93,4.83-5.38,7.95-7.2l71.58-41.31C89.303,4.64,92.783,3.51,96.373,3.15z\"\n                fill=\"#3a5cfc\"\n              ></path>\n\n              <path\n                className=\"logo__symbol\"\n                d=\"M182.803,47.51l-71.14-41.05c-3.15-1.82-6.63-2.95-10.21-3.31V53.5l46.16,26.65l-46.16,26.64V53.5h-5.08\n\t\tv53.28l-46.15-26.65l-1.27,2.2l-1.27,2.19l-5.08-2.93l0,0L8.523,61.93l-2.2-1.28l0,0l-2.2-1.27c-1.48,3.29-2.26,6.87-2.26,10.47\n\t\tv82.66c0,3.63,0.77,7.2,2.24,10.48l2.22-1.28l2.2-1.27l11.77-6.81l22.31-12.87l0,0l5.08-2.94l1.27,2.19l1.27,2.2l46.15-26.65v53.3\n\t\th5.08v-56.24l51.93-29.97l0.59-0.34l28.82-16.67c3.28-1.88,5.24-5.27,5.25-9.06c0-3.79-1.95-7.19-5.24-9.1V47.51z M47.683,137.82\n\t\tV84.55l46.15,26.64l-46.15,26.64V137.82z\"\n                fill=\"#3a5cfc\"\n              ></path>\n\n              <path\n                className=\"logo__text\"\n                d=\"M308.898,191.054c-19.173,0-35.52-6.76-49.044-20.289c-13.529-13.525-20.29-29.872-20.29-49.044\n\t\t\tc0-19.169,6.761-35.516,20.29-49.044c13.525-13.525,29.872-20.29,49.044-20.29c14.512,0,27.587,4.124,39.235,12.362\n\t\t\tc11.644,8.243,19.886,18.904,24.724,31.979H344.64c-8.423-12.538-20.248-18.812-35.473-18.812\n\t\t\tc-12.362,0-22.666,4.211-30.904,12.63c-8.243,8.423-12.362,18.812-12.362,31.174c0,12.186,4.165,22.531,12.496,31.039\n\t\t\tc8.331,8.511,18.584,12.765,30.77,12.765c15.049,0,26.693-6.089,34.936-18.274h28.755c-4.837,13.08-13.08,23.649-24.724,31.711\n\t\t\tC336.485,187.024,323.41,191.054,308.898,191.054z\"\n                fill=\"#2e3446\"\n              ></path>\n\n              <path\n                className=\"logo__text\"\n                d=\"M406.851,72.677c13.525-13.525,29.872-20.29,49.044-20.29c19.168,0,35.515,6.765,49.044,20.29\n\t\t\tc13.525,13.529,20.289,29.875,20.289,49.044c0,19.172-6.764,35.519-20.289,49.044c-13.529,13.529-29.875,20.289-49.044,20.289\n\t\t\tc-19.173,0-35.52-6.76-49.044-20.289c-13.529-13.525-20.29-29.872-20.29-49.044C386.561,102.552,393.322,86.206,406.851,72.677z\n\t\t\t M456.164,77.917c-12.362,0-22.666,4.211-30.904,12.63c-8.243,8.423-12.362,18.812-12.362,31.174\n\t\t\tc0,12.186,4.165,22.531,12.496,31.039c8.331,8.511,18.584,12.765,30.77,12.765c12.181,0,22.347-4.207,30.501-12.631\n\t\t\tc8.15-8.419,12.228-18.812,12.228-31.173c0-12.362-4.077-22.75-12.228-31.174C478.511,82.128,468.345,77.917,456.164,77.917z\"\n                fill=\"#2e3446\"\n              ></path>\n\n              <path\n                className=\"logo__text\"\n                d=\"M550.758,35.994c-4.661,0-8.512-1.566-11.556-4.703c-3.049-3.133-4.568-6.941-4.568-11.421\n\t\t\tc0-4.476,1.52-8.285,4.568-11.421c3.044-3.132,6.895-4.703,11.556-4.703c4.476,0,8.238,1.57,11.286,4.703\n\t\t\tc3.045,3.137,4.569,6.945,4.569,11.421c0,4.48-1.524,8.289-4.569,11.421C558.996,34.428,555.233,35.994,550.758,35.994z\n\t\t\t M537.59,188.905V54.537h26.336v134.368H537.59z\"\n                fill=\"#2e3446\"\n              ></path>\n\n              <path\n                className=\"logo__text\"\n                d=\"M677.327,188.905v-70.678c0-26.873-10.93-40.31-32.786-40.31c-21.859,0-32.785,13.437-32.785,40.31v70.678\n\t\t\tH585.42v-70.678c0-21.855,5.374-38.294,16.124-49.312c10.749-11.019,25.08-16.527,42.997-16.527\n\t\t\tc17.913,0,32.249,5.509,42.998,16.527c10.749,11.018,16.124,27.457,16.124,49.312v70.678H677.327z\"\n                fill=\"#2e3446\"\n              ></path>\n\n              <path\n                className=\"logo__text\"\n                d=\"M881.027,54.537h26.874l-44.342,134.368h-26.336l-30.099-94.326l-30.098,94.326h-26.336L706.349,54.537\n\t\t\th26.874l30.636,99.701l30.098-99.701h26.337l30.098,99.701L881.027,54.537z\"\n                fill=\"#2e3446\"\n              ></path>\n\n              <path\n                className=\"logo__text\"\n                d=\"M927.387,170.9c-13.349-13.438-20.021-29.737-20.021-48.91c0-19.168,6.584-35.562,19.752-49.179\n\t\t\tc13.168-13.613,29.334-20.424,48.507-20.424c18.273,0,34.217,6.139,47.835,18.408c13.613,12.274,20.78,28.444,21.498,48.507\n\t\t\tc0,4.48-0.269,8.512-0.806,12.093H935.583c1.075,10.036,5.328,18.232,12.765,24.59c7.433,6.361,16.527,9.54,27.277,9.54\n\t\t\tc13.256,0,24.005-4.477,32.248-13.437l31.442,0.269c-5.912,11.647-14.512,21.008-25.799,28.083\n\t\t\tc-11.287,7.079-23.649,10.614-37.086,10.614C957.082,191.054,940.731,184.336,927.387,170.9z M935.583,109.897h81.158\n\t\t\tc-2.511-9.313-7.482-16.972-14.915-22.977c-7.437-6-15.901-9.002-25.396-9.002c-10.212,0-18.945,2.956-26.201,8.868\n\t\t\tS938.09,100.403,935.583,109.897z\"\n                fill=\"#2e3446\"\n              ></path>\n\n              <path\n                className=\"logo__text\"\n                d=\"M1059.738,121.721V0.79h26.604v70.946c10.926-12.899,25.08-19.349,42.46-19.349\n\t\t\tc19.169,0,35.516,6.765,49.044,20.29c13.525,13.529,20.29,29.875,20.29,49.044c0,19.172-6.765,35.519-20.29,49.044\n\t\t\tc-13.528,13.529-29.875,20.289-49.044,20.289c-19.173,0-35.473-6.76-48.91-20.289\n\t\t\tC1066.456,157.24,1059.738,140.893,1059.738,121.721z M1128.803,77.917c-12.005,0-22.171,4.258-30.501,12.765\n\t\t\tc-8.331,8.511-12.496,18.857-12.496,31.039c0,12.361,4.119,22.754,12.361,31.173c8.238,8.424,18.45,12.631,30.636,12.631\n\t\t\tc12.362,0,22.616-4.207,30.771-12.631c8.15-8.419,12.228-18.812,12.228-31.173c0-12.362-4.124-22.75-12.362-31.174\n\t\t\tC1151.196,82.128,1140.984,77.917,1128.803,77.917z\"\n                fill=\"#2e3446\"\n              ></path>\n            </svg>\n          </a>\n          <a\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"group col-span-1 flex items-center justify-center bg-white p-8 text-green-700 transition-colors\"\n            href=\"https://bcg.com\"\n          >\n            <svg\n              version=\"1.0\"\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"1875.000000pt\"\n              height=\"30\"\n              viewBox=\"0 0 1875 392\"\n              preserveAspectRatio=\"xMidYMid meet\"\n            >\n              <g\n                transform=\"translate(0.000000,392.000000) scale(0.100000,-0.100000)\"\n                fill=\"#007253\"\n                stroke=\"none\"\n              >\n                <path\n                  d=\"M4354 3910 c-409 -34 -764 -172 -1089 -426 -80 -62 -259 -240 -326\n-324 l-36 -44 -40 86 c-55 117 -113 199 -202 289 -126 126 -271 213 -454 274\n-200 66 -187 65 -1249 65 l-958 0 0 -1885 0 -1886 1063 4 c1041 3 1064 3 1155\n24 183 42 325 101 457 188 82 54 214 183 277 269 29 39 53 73 55 75 2 3 40\n-31 86 -75 278 -270 607 -443 984 -520 124 -25 136 -25 443 -22 303 4 319 5\n434 32 450 106 840 351 1108 698 34 43 64 78 68 78 3 0 28 -30 55 -66 209\n-285 558 -532 920 -652 244 -81 287 -87 665 -87 l335 0 128 33 c475 123 858\n393 1129 796 173 259 282 558 319 875 11 103 7 469 -7 539 l-6 32 -763 0 -763\n0 -226 -353 c-124 -194 -228 -360 -232 -370 -6 -16 30 -17 575 -17 372 0 581\n-4 581 -10 0 -21 -53 -149 -91 -220 -145 -270 -398 -467 -698 -543 -73 -18\n-113 -22 -261 -21 -155 0 -186 3 -271 26 -255 69 -467 205 -628 404 -299 369\n-361 864 -161 1281 64 136 135 236 238 339 254 254 606 382 955 347 227 -24\n356 -71 602 -223 13 -8 26 -12 28 -10 14 18 407 653 405 655 -21 18 -162 102\n-228 135 -281 141 -563 203 -920 203 -220 0 -366 -17 -546 -63 -532 -137 -983\n-499 -1249 -1002 -110 -209 -172 -399 -255 -787 -86 -399 -117 -504 -191 -655\n-58 -116 -104 -181 -199 -280 -198 -206 -415 -321 -683 -360 -194 -29 -367\n-14 -554 49 -432 145 -746 502 -829 944 -20 108 -20 301 0 406 119 611 693\n1046 1309 993 208 -17 423 -89 569 -189 33 -22 64 -37 68 -32 30 35 409 647\n404 654 -24 39 -296 177 -452 229 -251 83 -578 122 -848 100z m-2485 -809\nc124 -39 217 -123 262 -238 32 -82 32 -215 0 -296 -44 -112 -169 -218 -292\n-246 -26 -6 -239 -11 -531 -11 l-488 0 0 405 0 405 494 0 c457 0 499 -1 555\n-19z m194 -1511 c176 -54 292 -196 304 -369 6 -87 -12 -167 -53 -237 -37 -63\n-126 -141 -201 -177 l-68 -32 -612 -3 -613 -3 0 421 0 420 589 0 c562 0 592\n-1 654 -20z\"\n                />\n                <path\n                  d=\"M11840 3834 c-271 -73 -402 -390 -265 -643 39 -70 121 -146 202 -184\n64 -30 74 -32 184 -32 113 0 116 1 192 38 229 113 316 368 205 595 -92 186\n-310 282 -518 226z m259 -160 c100 -54 154 -146 155 -264 1 -92 -26 -158 -87\n-218 -59 -58 -127 -86 -207 -86 -202 0 -341 210 -273 410 21 63 110 154 173\n177 70 26 173 18 239 -19z\"\n                />\n                <path\n                  d=\"M12720 3835 c-180 -56 -230 -300 -84 -411 24 -18 84 -44 145 -64 172\n-54 199 -73 199 -143 0 -106 -151 -141 -304 -69 -37 18 -72 32 -76 32 -9 0\n-70 -95 -70 -110 0 -5 39 -28 86 -52 79 -39 94 -42 178 -46 116 -5 185 13 244\n64 50 43 69 76 82 144 13 69 -3 142 -43 186 -37 43 -92 71 -222 114 -126 42\n-171 80 -162 137 17 101 129 129 268 67 33 -15 61 -25 63 -23 19 24 66 100 66\n106 0 15 -72 50 -136 67 -75 19 -173 20 -234 1z\"\n                />\n                <path\n                  d=\"M14320 3836 c-77 -20 -142 -59 -200 -116 -278 -278 -82 -750 310\n-750 126 1 230 42 316 126 244 238 132 651 -201 740 -61 17 -165 17 -225 0z\nm237 -154 c65 -33 109 -78 138 -137 58 -118 40 -245 -46 -340 -145 -158 -385\n-122 -480 73 -19 38 -24 63 -24 132 0 73 4 92 27 136 32 61 104 127 161 148\n59 22 169 16 224 -12z\"\n                />\n                <path\n                  d=\"M10750 3411 l0 -421 203 0 c228 0 277 9 340 61 70 58 101 159 77 249\n-8 31 -27 60 -59 92 l-48 48 25 22 c35 33 52 79 52 143 0 90 -39 148 -130 196\n-42 22 -58 24 -252 27 l-208 4 0 -421z m390 272 c52 -36 68 -84 46 -137 -23\n-56 -51 -66 -181 -66 l-115 0 0 110 0 110 113 0 c90 0 118 -3 137 -17z m49\n-367 c16 -15 32 -39 36 -53 9 -36 -12 -89 -46 -118 -29 -24 -36 -25 -160 -25\nl-129 0 0 116 0 116 134 -4 c133 -3 135 -3 165 -32z\"\n                />\n                <path\n                  d=\"M13250 3760 l0 -70 130 0 130 0 0 -355 0 -355 70 0 70 0 0 355 0 355\n135 0 135 0 0 70 0 70 -335 0 -335 0 0 -70z\"\n                />\n                <path\n                  d=\"M15060 3405 l0 -425 65 0 65 0 2 315 3 315 195 -315 195 -315 83 0\n82 0 0 425 0 425 -70 0 -70 0 -2 -302 -3 -303 -185 302 -185 303 -87 0 -88 0\n0 -425z\"\n                />\n                <path\n                  d=\"M18230 2375 c-268 -76 -398 -376 -272 -633 38 -79 117 -156 199 -195\n65 -31 73 -32 188 -32 116 0 123 1 186 32 80 40 150 111 187 191 21 46 27 76\n30 155 l4 97 -78 0 c-44 0 -122 3 -174 6 l-95 6 -37 -64 -38 -63 140 -3 c102\n-2 140 -6 140 -15 -1 -24 -42 -102 -68 -130 -44 -47 -99 -70 -180 -75 -66 -4\n-78 -2 -137 27 -114 57 -172 160 -163 295 10 154 114 265 266 283 51 6 149\n-17 186 -43 32 -22 39 -18 75 42 32 53 32 56 14 69 -71 55 -263 80 -373 50z\"\n                />\n                <path\n                  d=\"M11015 2366 c-164 -54 -278 -188 -307 -359 -29 -175 63 -365 218\n-449 155 -84 367 -73 498 24 17 14 16 17 -18 71 -20 31 -39 57 -42 57 -3 0\n-19 -9 -34 -20 -147 -105 -360 -47 -442 119 -33 66 -33 207 0 268 33 63 74\n106 134 139 45 25 64 29 128 29 77 0 117 -11 182 -52 l37 -22 35 57 36 57 -23\n19 c-12 10 -47 31 -77 45 -48 22 -70 26 -170 28 -75 2 -129 -2 -155 -11z\"\n                />\n                <path\n                  d=\"M11855 2367 c-211 -70 -342 -283 -305 -495 16 -95 54 -168 124 -238\n277 -277 757 -84 756 305 0 177 -97 328 -257 404 -61 28 -79 32 -173 34 -67 2\n-120 -2 -145 -10z m262 -158 c134 -65 197 -226 146 -375 -23 -67 -110 -154\n-177 -177 -148 -51 -297 15 -368 163 -31 65 -31 177 0 245 54 117 175 192 292\n179 25 -3 73 -18 107 -35z\"\n                />\n                <path\n                  d=\"M13674 2356 c-99 -46 -154 -147 -141 -258 13 -103 71 -152 256 -213\n130 -43 172 -72 173 -123 1 -48 -20 -79 -70 -103 -60 -30 -141 -21 -240 26\nl-72 34 -37 -54 c-43 -66 -41 -69 58 -115 226 -104 458 -32 500 155 15 68 1\n142 -35 190 -31 40 -118 86 -227 119 -146 44 -191 92 -154 169 35 74 153 89\n264 32 43 -21 56 -24 63 -13 5 7 21 33 35 57 l26 43 -24 19 c-76 60 -281 79\n-375 35z\"\n                />\n                <path\n                  d=\"M12610 1945 l0 -425 70 0 70 0 0 312 0 312 193 -309 192 -310 83 -3\n82 -3 0 426 0 425 -65 0 -65 0 -2 -310 -3 -309 -190 309 -189 310 -88 0 -88 0\n0 -425z\"\n                />\n                <path\n                  d=\"M14292 2063 l3 -308 26 -55 c35 -73 89 -126 169 -164 57 -27 75 -31\n150 -30 152 1 252 59 312 182 l33 67 3 307 3 308 -70 0 -71 0 0 -272 c0 -160\n-4 -289 -11 -311 -22 -80 -111 -146 -197 -147 -80 0 -145 40 -185 115 -20 38\n-22 57 -25 328 l-4 287 -69 0 -70 0 3 -307z\"\n                />\n                <path\n                  d=\"M15220 1945 l0 -425 280 0 280 0 0 70 0 70 -210 0 -210 0 0 355 0\n355 -70 0 -70 0 0 -425z\"\n                />\n                <path\n                  d=\"M15740 2300 l0 -70 130 0 130 0 0 -355 0 -355 75 0 75 0 0 355 0 355\n130 0 130 0 0 70 0 70 -335 0 -335 0 0 -70z\"\n                />\n                <path d=\"M16600 1945 l0 -425 75 0 75 0 0 425 0 425 -75 0 -75 0 0 -425z\" />\n                <path\n                  d=\"M17030 1945 l0 -425 70 0 70 0 2 310 3 310 191 -308 191 -307 86 -3\n87 -3 0 426 0 425 -70 0 -70 0 0 -312 0 -312 -191 312 -192 312 -88 0 -89 0 0\n-425z\"\n                />\n                <path\n                  d=\"M11037 920 c-59 -15 -135 -58 -184 -103 -106 -98 -147 -199 -141\n-352 3 -87 8 -109 35 -168 42 -89 121 -168 210 -210 65 -31 74 -32 188 -32\n108 0 125 3 176 26 144 68 229 206 229 374 l0 75 -92 0 c-51 0 -129 3 -173 6\nl-81 7 -37 -61 c-20 -33 -37 -63 -37 -66 0 -3 63 -6 141 -6 l140 0 -6 -22\nc-27 -89 -90 -158 -166 -183 -104 -35 -234 -3 -301 74 -139 159 -96 402 88\n492 72 35 178 34 258 -3 32 -16 61 -26 65 -23 4 2 21 27 39 54 l31 50 -27 19\nc-56 39 -115 55 -217 58 -55 2 -117 -1 -138 -6z\"\n                />\n                <path\n                  d=\"M12841 904 c-217 -58 -359 -274 -323 -491 50 -298 370 -460 642 -323\n148 75 240 224 240 390 -2 288 -279 499 -559 424z m212 -140 c105 -36 186\n-146 195 -265 6 -89 -16 -153 -76 -220 -63 -70 -126 -99 -218 -99 -165 0 -288\n128 -289 300 0 62 5 85 28 132 33 66 100 130 160 152 56 19 143 20 200 0z\"\n                />\n                <path\n                  d=\"M11730 480 l0 -420 75 0 75 0 0 150 0 150 73 0 72 0 93 -150 93 -150\n85 0 c81 0 84 1 74 19 -5 11 -49 81 -96 156 -47 75 -84 138 -82 140 2 1 21 12\n43 23 52 26 79 56 106 117 48 108 17 254 -69 325 -64 53 -104 60 -334 60\nl-208 0 0 -420z m417 270 c63 -38 87 -143 46 -199 -36 -48 -77 -61 -200 -61\nl-113 0 0 140 0 140 118 0 c98 0 122 -3 149 -20z\"\n                />\n                <path\n                  d=\"M13572 598 l3 -303 27 -57 c104 -222 425 -267 587 -82 74 83 76 94\n79 437 l3 307 -70 0 -70 0 -3 -287 c-3 -269 -4 -291 -24 -328 -62 -119 -239\n-142 -331 -43 -49 53 -53 78 -53 378 l0 280 -76 0 -75 0 3 -302z\"\n                />\n                <path\n                  d=\"M14510 480 l0 -420 70 0 70 0 0 145 0 145 124 0 c141 0 203 15 260\n62 60 50 89 109 94 197 6 97 -20 163 -85 221 -71 62 -108 70 -335 70 l-198 0\n0 -420z m383 276 c54 -23 80 -59 85 -119 5 -69 -15 -110 -68 -137 -32 -16 -59\n-20 -150 -20 l-110 0 0 145 0 145 105 0 c67 0 117 -5 138 -14z\"\n                />\n              </g>\n            </svg>\n          </a>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/sidebar/app-sidebar.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport * as React from \"react\";\nimport { useEffect, useState } from \"react\";\n\nimport { TeamContextType, initialState, useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport Cookies from \"js-cookie\";\nimport {\n  BrushIcon,\n  CogIcon,\n  ContactIcon,\n  FolderIcon,\n  HouseIcon,\n  Loader,\n  PauseCircleIcon,\n  ServerIcon,\n  Sparkles as SparklesIcon,\n  WorkflowIcon,\n} from \"lucide-react\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { useIsAdmin } from \"@/lib/hooks/use-is-admin\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useDataroomsSimple from \"@/lib/swr/use-datarooms-simple\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { useSlackIntegration } from \"@/lib/swr/use-slack-integration\";\nimport { nFormatter } from \"@/lib/utils\";\n\nimport { NavMain } from \"@/components/sidebar/nav-main\";\nimport { NavUser } from \"@/components/sidebar/nav-user\";\nimport { TeamSwitcher } from \"@/components/sidebar/team-switcher\";\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarHeader,\n  SidebarMenu,\n  SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\n\nimport ProBanner from \"../billing/pro-banner\";\nimport { Progress } from \"../ui/progress\";\nimport { BadgeTooltip } from \"../ui/tooltip\";\nimport SlackBanner from \"./banners/slack-banner\";\n\nexport function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {\n  const router = useRouter();\n  const [showProBanner, setShowProBanner] = useState<boolean | null>(null);\n  const [showSlackBanner, setShowSlackBanner] = useState<boolean | null>(null);\n  const { currentTeam, teams, setCurrentTeam, isLoading }: TeamContextType =\n    useTeam() || initialState;\n  const {\n    plan: userPlan,\n    isAnnualPlan,\n    isPro,\n    isBusiness,\n    isDatarooms,\n    isDataroomsPlus,\n    isDataroomsPremium,\n    isFree,\n    isTrial,\n    isPaused,\n  } = usePlan();\n\n  const { limits } = useLimits();\n  const linksLimit = limits?.links;\n  const documentsLimit = limits?.documents;\n\n  // Check Slack integration status\n  const { integration: slackIntegration } = useSlackIntegration({\n    enabled: !!currentTeam?.id,\n  });\n\n  // Check feature flags\n  const { features } = useFeatureFlags();\n\n  // Check if current user is admin (for gating Security & Billing)\n  const { isAdmin } = useIsAdmin();\n\n  // Fetch datarooms for the current team (simple mode - no filters or extra data)\n  const { datarooms } = useDataroomsSimple();\n\n  useEffect(() => {\n    if (Cookies.get(\"hideProBanner\") !== \"pro-banner\") {\n      setShowProBanner(true);\n    } else {\n      setShowProBanner(false);\n    }\n    if (Cookies.get(\"hideSlackBanner\") !== \"slack-banner\") {\n      setShowSlackBanner(true);\n    } else {\n      setShowSlackBanner(false);\n    }\n  }, []);\n\n  // Prepare datarooms items for sidebar (limit to first 5, sorted by most recent)\n  const dataroomItems =\n    datarooms && datarooms.length > 0\n      ? datarooms.slice(0, 5).map((dataroom) => ({\n          title: dataroom.internalName || dataroom.name,\n          url: `/datarooms/${dataroom.id}/documents`,\n          current:\n            router.pathname.includes(\"/datarooms/[id]\") &&\n            String(router.query.id) === String(dataroom.id),\n        }))\n      : undefined;\n\n  const data = {\n    navMain: [\n      {\n        title: \"Dashboard\",\n        url: \"/dashboard\",\n        icon: HouseIcon,\n        current: router.pathname.includes(\"dashboard\"),\n      },\n      {\n        title: \"All Documents\",\n        url: \"/documents\",\n        icon: FolderIcon,\n        current:\n          router.pathname.includes(\"documents\") &&\n          !router.pathname.includes(\"datarooms\"),\n      },\n      {\n        title: \"All Datarooms\",\n        url: \"/datarooms\",\n        icon: ServerIcon,\n        current: router.pathname === \"/datarooms\",\n        disabled: !isBusiness && !isDatarooms && !isDataroomsPlus && !isTrial,\n        trigger: \"sidebar_datarooms\",\n        plan: PlanEnum.Business,\n        highlightItem: [\"datarooms\"],\n        isActive:\n          router.pathname.includes(\"datarooms\") &&\n          (isBusiness || isDatarooms || isDataroomsPlus || isTrial),\n        items:\n          isBusiness || isDatarooms || isDataroomsPlus || isTrial\n            ? dataroomItems\n            : undefined,\n      },\n      {\n        title: \"Visitors\",\n        url: \"/visitors\",\n        icon: ContactIcon,\n        current: router.pathname.includes(\"visitors\"),\n        disabled: isFree && !isTrial,\n        trigger: \"sidebar_visitors\",\n        plan: PlanEnum.Pro,\n        highlightItem: [\"visitors\"],\n      },\n      {\n        title: \"Workflows\",\n        url: \"/workflows\",\n        icon: WorkflowIcon,\n        current: router.pathname.includes(\"/workflows\"),\n        disabled: !features?.workflows,\n        trigger: \"sidebar_workflows\",\n        plan: PlanEnum.DataRoomsPlus,\n        highlightItem: [\"workflows\"],\n      },\n      {\n        title: \"Branding\",\n        url: \"/branding\",\n        icon: BrushIcon,\n        current:\n          router.pathname.includes(\"branding\") &&\n          !router.pathname.includes(\"datarooms\"),\n      },\n      {\n        title: \"Settings\",\n        url: \"/settings/general\",\n        icon: CogIcon,\n        isActive:\n          router.pathname.includes(\"settings\") &&\n          !router.pathname.includes(\"branding\") &&\n          !router.pathname.includes(\"datarooms\") &&\n          !router.pathname.includes(\"documents\"),\n        items: [\n          {\n            title: \"General\",\n            url: \"/settings/general\",\n            current: router.pathname.includes(\"settings/general\"),\n          },\n          {\n            title: \"Team\",\n            url: \"/settings/people\",\n            current: router.pathname.includes(\"settings/people\"),\n          },\n          {\n            title: \"Domains\",\n            url: \"/settings/domains\",\n            current: router.pathname.includes(\"settings/domains\"),\n          },\n          {\n            title: \"Webhooks\",\n            url: \"/settings/webhooks\",\n            current: router.pathname.includes(\"settings/webhooks\"),\n          },\n          {\n            title: \"Slack\",\n            url: \"/settings/slack\",\n            current: router.pathname.includes(\"settings/slack\"),\n          },\n          ...(isAdmin\n            ? [\n                {\n                  title: \"Security\",\n                  url: \"/settings/security\",\n                  current: router.pathname.includes(\"settings/security\"),\n                },\n                {\n                  title: \"Billing\",\n                  url: \"/settings/billing\",\n                  current: router.pathname.includes(\"settings/billing\"),\n                },\n              ]\n            : []),\n        ],\n      },\n      // {\n      //   title: \"2025 Recap\",\n      //   url: \"/dashboard?openRecap=true\",\n      //   icon: SparklesIcon,\n      //   current: false,\n      // },\n    ],\n  };\n\n  // Filter out items that should be hidden based on feature flags\n  const filteredNavMain = data.navMain.filter((item) => {\n    // Hide workflows if feature flag is not enabled\n    if (item.title === \"Workflows\" && !features?.workflows) {\n      return false;\n    }\n    return true;\n  });\n\n  return (\n    <Sidebar\n      className=\"bg-gray-50 dark:bg-black\"\n      sidebarClassName=\"bg-gray-50 dark:bg-black\"\n      side=\"left\"\n      variant=\"inset\"\n      collapsible=\"icon\"\n      {...props}\n    >\n      <SidebarHeader className=\"gap-y-8\">\n        <p className=\"hidden w-full justify-center text-2xl font-bold tracking-tighter text-black group-data-[collapsible=icon]:inline-flex dark:text-white\">\n          <Link href=\"/dashboard\">P</Link>\n        </p>\n        <p className=\"ml-2 flex items-center text-2xl font-bold tracking-tighter text-black group-data-[collapsible=icon]:hidden dark:text-white\">\n          <Link href=\"/dashboard\">Papermark</Link>\n          {userPlan && !isFree && !isDataroomsPlus && !isDataroomsPremium ? (\n            <span className=\"relative ml-4 inline-flex items-center rounded-full bg-background px-2.5 py-1 text-xs tracking-normal text-foreground ring-1 ring-gray-800\">\n              {userPlan.charAt(0).toUpperCase() + userPlan.slice(1)}\n              {isPaused ? (\n                <BadgeTooltip content=\"Subscription paused\">\n                  <PauseCircleIcon className=\"absolute -right-1.5 -top-1.5 h-5 w-5 rounded-full bg-background text-amber-500\" />\n                </BadgeTooltip>\n              ) : null}\n            </span>\n          ) : null}\n          {isDataroomsPlus && !isDataroomsPremium ? (\n            <span className=\"relative ml-4 inline-flex items-center rounded-full bg-background px-2.5 py-1 text-xs tracking-normal text-foreground ring-1 ring-gray-800\">\n              Datarooms+\n              {isPaused ? (\n                <BadgeTooltip content=\"Subscription paused\">\n                  <PauseCircleIcon className=\"absolute -right-1.5 -top-1.5 h-5 w-5 rounded-full bg-background text-amber-500\" />\n                </BadgeTooltip>\n              ) : null}\n            </span>\n          ) : null}\n          {isDataroomsPremium ? (\n            <span className=\"relative ml-4 inline-flex items-center rounded-full bg-background px-2.5 py-1 text-xs tracking-normal text-foreground ring-1 ring-gray-800\">\n              Premium\n              {isPaused ? (\n                <BadgeTooltip content=\"Subscription paused\">\n                  <PauseCircleIcon className=\"absolute -right-1.5 -top-1.5 h-5 w-5 rounded-full bg-background text-amber-500\" />\n                </BadgeTooltip>\n              ) : null}\n            </span>\n          ) : null}\n          {isTrial ? (\n            <span className=\"ml-2 rounded-sm bg-foreground px-2 py-0.5 text-xs tracking-normal text-background ring-1 ring-gray-800\">\n              Trial\n            </span>\n          ) : null}\n        </p>\n        {isLoading ? (\n          <div className=\"flex items-center gap-2 text-sm\">\n            <Loader className=\"h-5 w-5 animate-spin\" /> Loading teams...\n          </div>\n        ) : (\n          <TeamSwitcher\n            currentTeam={currentTeam}\n            teams={teams}\n            setCurrentTeam={setCurrentTeam}\n          />\n        )}\n      </SidebarHeader>\n      <SidebarContent>\n        <NavMain items={filteredNavMain} />\n      </SidebarContent>\n      <SidebarFooter>\n        <SidebarMenu className=\"group-data-[collapsible=icon]:hidden\">\n          <SidebarMenuItem>\n            <div>\n              {/*\n               * Show Slack banner to all users if they haven't dismissed it and don't have Slack connected\n               */}\n              {!slackIntegration && showSlackBanner ? (\n                <SlackBanner setShowSlackBanner={setShowSlackBanner} />\n              ) : null}\n              {/*\n               * if user is free and showProBanner is true show pro banner\n               */}\n              {isFree && showProBanner ? (\n                <ProBanner setShowProBanner={setShowProBanner} />\n              ) : null}\n\n              <div className=\"mb-2\">\n                {linksLimit ? (\n                  <UsageProgress\n                    title=\"Links\"\n                    unit=\"links\"\n                    usage={limits?.usage?.links}\n                    usageLimit={linksLimit}\n                  />\n                ) : null}\n                {documentsLimit ? (\n                  <UsageProgress\n                    title=\"Documents\"\n                    unit=\"documents\"\n                    usage={limits?.usage?.documents}\n                    usageLimit={documentsLimit}\n                  />\n                ) : null}\n                {linksLimit || documentsLimit ? (\n                  <p className=\"mt-2 px-2 text-xs text-muted-foreground\">\n                    Change plan to increase usage limits\n                  </p>\n                ) : null}\n              </div>\n            </div>\n          </SidebarMenuItem>\n        </SidebarMenu>\n        <NavUser />\n      </SidebarFooter>\n    </Sidebar>\n  );\n}\n\nfunction UsageProgress(data: {\n  title: string;\n  unit: string;\n  usage?: number;\n  usageLimit?: number;\n}) {\n  let { title, unit, usage, usageLimit } = data;\n  let usagePercentage = 0;\n  if (usage !== undefined && usageLimit !== undefined) {\n    usagePercentage = (usage / usageLimit) * 100;\n  }\n\n  return (\n    <div className=\"p-2\">\n      <div className=\"mt-1 flex flex-col space-y-1\">\n        {usage !== undefined && usageLimit !== undefined ? (\n          <p className=\"text-xs text-foreground\">\n            <span>{nFormatter(usage)}</span> / {nFormatter(usageLimit)} {unit}\n          </p>\n        ) : (\n          <div className=\"h-5 w-32 animate-pulse rounded-md bg-muted\" />\n        )}\n        <Progress value={usagePercentage} className=\"h-1 bg-muted\" max={100} />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/sidebar/banners/slack-banner.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { Dispatch, SetStateAction } from \"react\";\n\nimport Cookies from \"js-cookie\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport { SlackIcon } from \"@/components/shared/icons/slack-icon\";\nimport X from \"@/components/shared/icons/x\";\nimport { Button } from \"@/components/ui/button\";\n\nexport default function SlackBanner({\n  setShowSlackBanner,\n}: {\n  setShowSlackBanner: Dispatch<SetStateAction<boolean | null>>;\n}) {\n  const router = useRouter();\n  const analytics = useAnalytics();\n\n  const handleHideBanner = () => {\n    setShowSlackBanner(false);\n    Cookies.set(\"hideSlackBanner\", \"slack-banner\", {\n      expires: 30, // Hide for 30 days\n    });\n  };\n\n  const handleConnectSlack = () => {\n    analytics.capture(\"Slack Connect Button Clicked\", {\n      source: \"slack_banner\",\n      location: \"sidebar\",\n    });\n    router.push(\"/settings/slack\");\n  };\n\n  return (\n    <aside className=\"relative mb-2 flex w-full flex-col justify-center rounded-lg border border-gray-700 bg-background p-4 text-foreground\">\n      <button\n        type=\"button\"\n        onClick={handleHideBanner}\n        className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\"\n      >\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </button>\n      <div className=\"flex items-center space-x-2\">\n        <SlackIcon className=\"h-5 w-5\" />\n        <span className=\"text-sm font-bold\">Connect Slack</span>\n      </div>\n      <p className=\"my-4 text-sm\">Get visit notifications in Slack channel.</p>\n      <div className=\"flex\">\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          className=\"grow\"\n          onClick={handleConnectSlack}\n        >\n          Set up Slack\n        </Button>\n      </div>\n    </aside>\n  );\n}\n"
  },
  {
    "path": "components/sidebar/nav-main.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { ChevronRight, CrownIcon, type LucideIcon } from \"lucide-react\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Shimmer } from \"@/components/ai-elements/shimmer\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n  SidebarGroup,\n  SidebarGroupLabel,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n} from \"@/components/ui/sidebar\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\n\nexport interface NavItem {\n  title: string;\n  url: string;\n  icon: LucideIcon;\n  current?: boolean;\n  isActive?: boolean;\n  disabled?: boolean;\n  plan?: PlanEnum;\n  trigger?: string;\n  highlightItem?: string[];\n  items?: {\n    title: string;\n    url: string;\n    current?: boolean;\n  }[];\n}\n\nexport function NavMain({ items }: { items: NavItem[] }) {\n  const analytics = useAnalytics();\n  const teamInfo = useTeam();\n\n  const handleItemClick = (title: string) => {\n    if (title === \"2025 Recap\") {\n      analytics.capture(\"YIR: Banner Opened\", {\n        source: \"sidebar\",\n        teamId: teamInfo?.currentTeam?.id,\n      });\n    }\n  };\n\n  return (\n    <SidebarGroup>\n      <SidebarMenu className=\"space-y-0.5 text-foreground\">\n        {items.map((item) => (\n          <Collapsible key={item.title} asChild defaultOpen={item.isActive}>\n            <SidebarMenuItem>\n              <SidebarMenuButton\n                asChild\n                tooltip={item.title}\n                className={cn(\n                  item.current &&\n                    item.items?.length &&\n                    \"rounded-md bg-gray-200 font-semibold dark:bg-secondary\",\n                  item.current &&\n                    !item.items?.length &&\n                    \"rounded-md bg-gray-200 font-semibold dark:bg-secondary\",\n                )}\n              >\n                {item.disabled ? (\n                  <UpgradePlanModal\n                    key={item.title}\n                    clickedPlan={item.plan as PlanEnum}\n                    trigger={item.trigger}\n                    highlightItem={item.highlightItem}\n                  >\n                    <div className=\"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm text-muted-foreground outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\">\n                      <item.icon />\n                      <span\n                        className=\"flex flex-row items-center gap-2 group-data-[collapsible=icon]:hidden\"\n                        id={item.plan}\n                      >\n                        {item.title}\n                        <CrownIcon className=\"!size-4\" />\n                      </span>\n                    </div>\n                  </UpgradePlanModal>\n                ) : (\n                  <Link\n                    href={item.url}\n                    className=\"p-2\"\n                    onClick={() => handleItemClick(item.title)}\n                  >\n                    <item.icon\n                      className={cn(\n                        item.title === \"2025 Recap\" &&\n                          \"text-orange-500 dark:text-orange-400\",\n                      )}\n                    />\n                    {item.title === \"2025 Recap\" ? (\n                      <Shimmer\n                        as=\"span\"\n                        className=\"[--background:theme(colors.yellow.300)] [--muted-foreground:theme(colors.orange.500)] dark:[--background:theme(colors.yellow.200)] dark:[--muted-foreground:theme(colors.orange.400)]\"\n                        duration={0.5}\n                        spread={3}\n                        hoverOnly\n                      >\n                        {item.title}\n                      </Shimmer>\n                    ) : (\n                      <span>{item.title}</span>\n                    )}\n                  </Link>\n                )}\n              </SidebarMenuButton>\n              {!item.disabled && item.items?.length ? (\n                <>\n                  <CollapsibleTrigger asChild>\n                    <SidebarMenuAction className=\"data-[state=open]:rotate-90\">\n                      <ChevronRight />\n                      <span className=\"sr-only\">Toggle</span>\n                    </SidebarMenuAction>\n                  </CollapsibleTrigger>\n                  <CollapsibleContent>\n                    <SidebarMenuSub>\n                      {item.items?.map((subItem) => (\n                        <SidebarMenuSubItem\n                          key={subItem.title}\n                          className={cn(\n                            subItem.current &&\n                              \"rounded-md bg-gray-200 font-semibold dark:bg-secondary\",\n                          )}\n                        >\n                          <SidebarMenuSubButton asChild>\n                            <Link href={subItem.url}>\n                              <span>{subItem.title}</span>\n                            </Link>\n                          </SidebarMenuSubButton>\n                        </SidebarMenuSubItem>\n                      ))}\n                    </SidebarMenuSub>\n                  </CollapsibleContent>\n                </>\n              ) : null}\n            </SidebarMenuItem>\n          </Collapsible>\n        ))}\n      </SidebarMenu>\n    </SidebarGroup>\n  );\n}\n"
  },
  {
    "path": "components/sidebar/nav-user.tsx",
    "content": "\"use client\";\n\nimport Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport {\n  ChevronsUpDown,\n  CircleUserRound,\n  FileTextIcon,\n  LifeBuoyIcon,\n  LogOut,\n  MailIcon,\n} from \"lucide-react\";\nimport { signOut, useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  useSidebar,\n} from \"@/components/ui/sidebar\";\n\nimport { ModeToggle } from \"../theme-toggle\";\n\ninterface Article {\n  data: {\n    slug: string;\n    title: string;\n    description?: string;\n  };\n}\n\nexport function NavUser() {\n  const { data: session, status } = useSession();\n  const { isMobile } = useSidebar();\n\n  const [searchOpen, setSearchOpen] = useState(false);\n  const [articles, setArticles] = useState<Article[]>([]);\n  const [loading, setLoading] = useState(false);\n\n  const fetchArticles = async (query?: string) => {\n    setLoading(true);\n    try {\n      const params = new URLSearchParams({\n        locale: \"en\", // or get this from your app's locale\n        ...(query && { q: query }),\n      });\n\n      const res = await fetch(`/api/help?${params}`);\n      const data = await res.json();\n\n      if (data.error) {\n        throw new Error(data.error);\n      }\n\n      setArticles(data.articles || []);\n    } catch (error) {\n      console.error(\"Error fetching articles:\", error);\n      setArticles([]); // Set empty array on error\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <>\n      <SidebarMenu>\n        <SidebarMenuItem>\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <SidebarMenuButton\n                size=\"lg\"\n                className=\"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n              >\n                <Avatar className=\"h-8 w-8 rounded-lg\">\n                  <AvatarImage\n                    src={session?.user?.image || \"\"}\n                    alt={session?.user?.name || \"\"}\n                  />\n                  <AvatarFallback className=\"rounded-lg\">\n                    {session?.user?.name?.charAt(0) ||\n                      session?.user?.email?.charAt(0)}\n                  </AvatarFallback>\n                </Avatar>\n                <div className=\"grid flex-1 text-left text-sm leading-tight\">\n                  <span className=\"truncate font-semibold\">\n                    {session?.user?.name || \"\"}\n                  </span>\n                  <span className=\"truncate text-xs\">\n                    {session?.user?.email || \"\"}\n                  </span>\n                </div>\n                <ChevronsUpDown className=\"ml-auto size-4\" />\n              </SidebarMenuButton>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent\n              className=\"w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg\"\n              side={isMobile ? \"bottom\" : \"right\"}\n              align=\"end\"\n              sideOffset={4}\n            >\n              <DropdownMenuLabel className=\"p-0 font-normal\">\n                <div className=\"flex items-center gap-2 px-1 py-1.5 text-left text-sm\">\n                  <Avatar className=\"h-8 w-8 rounded-lg\">\n                    <AvatarImage\n                      src={session?.user?.image || \"\"}\n                      alt={session?.user?.name || \"\"}\n                    />\n                    <AvatarFallback className=\"rounded-lg\">\n                      {session?.user?.name?.charAt(0) ||\n                        session?.user?.email?.charAt(0)}\n                    </AvatarFallback>\n                  </Avatar>\n                  <div className=\"grid flex-1 text-left text-sm leading-tight\">\n                    <span className=\"truncate font-semibold\">\n                      {session?.user?.name || \"\"}\n                    </span>\n                    <span className=\"truncate text-xs\">\n                      {session?.user?.email || \"\"}\n                    </span>\n                  </div>\n                </div>\n              </DropdownMenuLabel>\n              <DropdownMenuSeparator />\n              <ModeToggle />\n              <DropdownMenuGroup>\n                <Link href=\"/account/general\">\n                  <DropdownMenuItem>\n                    <CircleUserRound />\n                    User Settings\n                  </DropdownMenuItem>\n                </Link>\n              </DropdownMenuGroup>\n              <DropdownMenuSeparator />\n              <DropdownMenuGroup>\n                <DropdownMenuItem\n                  onClick={() => {\n                    setSearchOpen(true);\n                    fetchArticles();\n                  }}\n                >\n                  <LifeBuoyIcon />\n                  Help Center\n                </DropdownMenuItem>\n                <DropdownMenuItem\n                  onClick={() => {\n                    navigator.clipboard.writeText(\"support@papermark.com\");\n                    toast.success(\"support@papermark.com copied to clipboard\");\n                  }}\n                >\n                  <MailIcon />\n                  Contact Support\n                </DropdownMenuItem>\n              </DropdownMenuGroup>\n              <DropdownMenuSeparator />\n              <DropdownMenuItem\n                onClick={() =>\n                  signOut({\n                    callbackUrl: `${window.location.origin}`,\n                  })\n                }\n              >\n                <LogOut />\n                Log out\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </SidebarMenuItem>\n      </SidebarMenu>\n\n      <Dialog open={searchOpen} onOpenChange={setSearchOpen}>\n        <DialogContent className=\"max-w-[550px] gap-0 overflow-hidden border-none p-0 shadow-lg\">\n          <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n            <CommandInput\n              placeholder=\"Search help articles...\"\n              className=\"h-14 border-none px-4 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0\"\n              onValueChange={(search) => fetchArticles(search)}\n            />\n            <CommandList className=\"max-h-[400px] overflow-y-auto\">\n              <CommandEmpty>No articles found</CommandEmpty>\n              <CommandGroup heading=\"All Articles\">\n                {articles.map((article) => (\n                  <CommandItem\n                    key={article.data.slug}\n                    value={article.data.title}\n                    onSelect={() => {\n                      window.open(\n                        `${process.env.NEXT_PUBLIC_MARKETING_URL}/help/article/${article.data.slug}`,\n                        \"_blank\",\n                      );\n                      setSearchOpen(false);\n                    }}\n                  >\n                    <FileTextIcon className=\"mr-2 h-4 w-4 text-[#fb7a00]\" />\n                    <div className=\"flex flex-col\">\n                      <span className=\"text-sm font-medium\">\n                        {article.data.title}\n                      </span>\n                      {article.data.description && (\n                        <span className=\"text-xs text-muted-foreground\">\n                          {article.data.description}\n                        </span>\n                      )}\n                    </div>\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            </CommandList>\n          </Command>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/sidebar/team-switcher.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useLimits } from \"@/ee/limits/swr-handler\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { ChevronsUpDown, UserRoundPlusIcon } from \"lucide-react\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { Team } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  useSidebar,\n} from \"@/components/ui/sidebar\";\n\nimport { AddSeatModal } from \"../billing/add-seat-modal\";\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport { AddTeamMembers } from \"../teams/add-team-member-modal\";\nimport { AddTeamModal } from \"../teams/add-team-modal\";\nimport { Avatar, AvatarFallback } from \"../ui/avatar\";\n\nexport function TeamSwitcher({\n  currentTeam: activeTeam,\n  teams,\n  setCurrentTeam,\n}: {\n  currentTeam: Team | null;\n  teams: Team[];\n  setCurrentTeam: (team: Team) => void;\n}) {\n  const [isTeamMemberInviteModalOpen, setTeamMemberInviteModalOpen] =\n    React.useState<boolean>(false);\n  const [isAddSeatModalOpen, setAddSeatModalOpen] =\n    React.useState<boolean>(false);\n  const { isMobile } = useSidebar();\n  const { canAddUsers, showUpgradePlanModal } = useLimits();\n  const { isTrial, isDataroomsPremium } = usePlan();\n\n  const switchTeam = (team: Team) => {\n    localStorage.setItem(\"currentTeamId\", team.id);\n    setCurrentTeam(team);\n  };\n\n  if (!activeTeam) return null;\n\n  return (\n    <SidebarMenu className=\"flex flex-row items-center gap-1 group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:gap-1.5\">\n      <SidebarMenuItem className=\"w-full\">\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <SidebarMenuButton\n              size=\"lg\"\n              className=\"border data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground\"\n            >\n              <Avatar className=\"size-8 rounded\">\n                <AvatarFallback className=\"rounded\">\n                  {activeTeam?.name?.slice(0, 2).toUpperCase()}\n                </AvatarFallback>\n              </Avatar>\n              <div className=\"grid flex-1 text-left text-sm leading-tight\">\n                <span className=\"truncate\">{activeTeam.name}</span>\n              </div>\n              <ChevronsUpDown className=\"ml-auto\" />\n            </SidebarMenuButton>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent\n            className=\"w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg\"\n            align=\"start\"\n            side={isMobile ? \"bottom\" : \"right\"}\n            sideOffset={4}\n          >\n            <DropdownMenuLabel className=\"text-xs text-muted-foreground\">\n              Teams\n            </DropdownMenuLabel>\n            <DropdownMenuSeparator />\n            {teams.map((team, index) => (\n              <DropdownMenuItem\n                key={index}\n                onClick={() => switchTeam(team)}\n                className={cn(\n                  \"gap-2 p-2\",\n                  team.id === activeTeam.id && \"bg-muted font-medium\",\n                )}\n              >\n                {/* <div className=\"flex size-6 items-center justify-center rounded-sm border\">\n                  <GalleryVerticalEndIcon className=\"size-4 shrink-0\" />\n                </div> */}\n                <Avatar className=\"size-6 shrink-0 rounded text-[12px]\">\n                  <AvatarFallback className=\"rounded\">\n                    {team?.name?.slice(0, 2).toUpperCase()}\n                  </AvatarFallback>\n                </Avatar>\n                {team.name}\n                {/* <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut> */}\n              </DropdownMenuItem>\n            ))}\n            <DropdownMenuSeparator />\n            {isDataroomsPremium ? (\n              <AddTeamModal setCurrentTeam={setCurrentTeam}>\n                <DropdownMenuItem\n                  className=\"gap-2 p-2 cursor-pointer\"\n                  onSelect={(e) => e.preventDefault()}\n                >\n                  <div className=\"flex size-6 items-center justify-center rounded-md border bg-background\">\n                    <UserRoundPlusIcon className=\"size-4\" />\n                  </div>\n                  <div className=\"font-medium text-muted-foreground\">Add new team</div>\n                </DropdownMenuItem>\n              </AddTeamModal>\n            ) : (\n              <UpgradePlanModal\n                clickedPlan={PlanEnum.DataRoomsPremium}\n                trigger=\"add_new_team\"\n                highlightItem={[\"teams\"]}\n              >\n                <DropdownMenuItem\n                  className=\"gap-2 p-2 cursor-pointer\"\n                  onSelect={(e) => e.preventDefault()}\n                >\n                  <div className=\"flex size-6 items-center justify-center rounded-md border bg-background\">\n                    <UserRoundPlusIcon className=\"size-4\" />\n                  </div>\n                  <div className=\"font-medium text-muted-foreground\">Add new team</div>\n                </DropdownMenuItem>\n              </UpgradePlanModal>\n            )}\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </SidebarMenuItem>\n      <SidebarMenuItem>\n        {showUpgradePlanModal ? (\n          <UpgradePlanModal\n            clickedPlan={isTrial ? PlanEnum.Business : PlanEnum.Pro}\n            trigger={\"invite_team_members\"}\n          >\n            <SidebarMenuButton\n              size=\"lg\"\n              className=\"size-12 justify-center border data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden\"\n            >\n              <UserRoundPlusIcon className=\"!size-5\" strokeWidth={1.5} />\n            </SidebarMenuButton>\n          </UpgradePlanModal>\n        ) : canAddUsers ? (\n          <AddTeamMembers\n            open={isTeamMemberInviteModalOpen}\n            setOpen={setTeamMemberInviteModalOpen}\n          >\n            <SidebarMenuButton\n              size=\"lg\"\n              className=\"size-12 justify-center border data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden\"\n            >\n              <UserRoundPlusIcon className=\"!size-5\" strokeWidth={1.5} />\n            </SidebarMenuButton>\n          </AddTeamMembers>\n        ) : (\n          <AddSeatModal open={isAddSeatModalOpen} setOpen={setAddSeatModalOpen}>\n            <SidebarMenuButton\n              size=\"lg\"\n              className=\"size-12 justify-center border data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden\"\n            >\n              <UserRoundPlusIcon className=\"!size-5\" strokeWidth={1.5} />\n            </SidebarMenuButton>\n          </AddSeatModal>\n        )}\n      </SidebarMenuItem>\n    </SidebarMenu>\n  );\n}\n"
  },
  {
    "path": "components/sidebar-folders.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { memo, useMemo } from \"react\";\n\nimport { FileTree } from \"@/components/ui/nextra-filetree\";\n\nimport { FolderWithDocuments, useFolders } from \"@/lib/swr/use-documents\";\nimport { cn } from \"@/lib/utils\";\n\nimport { TSelectedFolder } from \"./documents/move-folder-modal\";\n\n// Helper function to build nested folder structure\nconst buildNestedFolderStructure = (folders: FolderWithDocuments[]) => {\n  const folderMap = new Map();\n\n  // Initialize every folder with an additional childFolders property\n  folders.forEach((folder) => {\n    folderMap.set(folder.id, { ...folder, childFolders: [] });\n  });\n\n  const rootFolders: FolderWithDocuments[] = [];\n\n  folderMap.forEach((folder, id) => {\n    if (folder.parentId) {\n      const parent = folderMap.get(folder.parentId);\n      parent.childFolders.push(folder);\n    } else {\n      rootFolders.push(folder);\n    }\n  });\n\n  return rootFolders;\n};\n\nconst FolderComponent = memo(({ folder }: { folder: FolderWithDocuments }) => {\n  const router = useRouter();\n\n  // Memoize the rendering of the current folder's documents\n  const documents = useMemo(\n    () =>\n      folder.documents.map((doc) => (\n        <FileTree.File\n          key={doc.id}\n          name={doc.name}\n          onToggle={() => router.push(`/documents/${doc.id}`)}\n        />\n      )),\n    [folder.documents, router],\n  );\n\n  // Recursively render child folders if they exist\n  const childFolders = useMemo(\n    () =>\n      folder.childFolders.map((childFolder) => (\n        <FolderComponent key={childFolder.id} folder={childFolder} />\n      )),\n    [folder.childFolders],\n  );\n\n  const isActive =\n    folder.path === \"/\" + (router.query.name as string[])?.join(\"/\");\n  const isChildActive = folder.childFolders.some(\n    (childFolder) =>\n      childFolder.path === \"/\" + (router.query.name as string[])?.join(\"/\"),\n  );\n\n  const handleFolderClick = () => {\n    router.push(\n      `/documents/tree${folder.path}`,\n      `/documents/tree${folder.path}`,\n      {\n        scroll: false,\n      },\n    );\n  };\n\n  return (\n    <FileTree.Folder\n      name={folder.name}\n      key={folder.id}\n      active={isActive}\n      childActive={isChildActive}\n      onToggle={handleFolderClick}\n      className={cn(\"hover:bg-gray-200\", isActive && \"bg-gray-200\")}\n    >\n      {childFolders}\n      {documents}\n    </FileTree.Folder>\n  );\n});\nFolderComponent.displayName = \"FolderComponent\";\n\nconst FolderComponentSelection = memo(\n  ({\n    folder,\n    selectedFolder,\n    setSelectedFolder,\n    disableId,\n  }: {\n    disableId?: string[];\n    folder: FolderWithDocuments;\n    selectedFolder: TSelectedFolder;\n    setSelectedFolder: React.Dispatch<React.SetStateAction<TSelectedFolder>>;\n  }) => {\n    // Recursively render child folders if they exist\n    const childFolders = useMemo(\n      () =>\n        folder.childFolders.map((childFolder) => (\n          <FolderComponentSelection\n            key={childFolder.id}\n            folder={childFolder}\n            selectedFolder={selectedFolder}\n            setSelectedFolder={setSelectedFolder}\n            disableId={disableId}\n          />\n        )),\n      [folder.childFolders, selectedFolder, setSelectedFolder, disableId],\n    );\n\n    const isActive = folder.id === selectedFolder?.id;\n    const isChildActive = folder.childFolders.some(\n      (childFolder) => childFolder.id === selectedFolder?.id,\n    );\n    const isDisabled = disableId?.includes(folder.id);\n    return (\n      <div\n        onClick={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          if (isDisabled) return;\n          setSelectedFolder({\n            id: folder.id,\n            name: folder.name,\n            path: folder.path,\n          });\n        }}\n      >\n        <FileTree.Folder\n          disable={isDisabled}\n          name={folder.name}\n          key={folder.id}\n          active={isActive}\n          childActive={isChildActive}\n          onToggle={() => {\n            if (isDisabled) return;\n            setSelectedFolder({\n              id: folder.id,\n              name: folder.name,\n              path: folder.path,\n            });\n          }}\n        >\n          {childFolders}\n        </FileTree.Folder>\n      </div>\n    );\n  },\n);\nFolderComponentSelection.displayName = \"FolderComponentSelection\";\n\nconst SidebarFolders = ({ folders }: { folders: FolderWithDocuments[] }) => {\n  const nestedFolders = useMemo(() => {\n    if (folders) {\n      return buildNestedFolderStructure(folders);\n    }\n    return [];\n  }, [folders]);\n\n  return (\n    <FileTree>\n      {nestedFolders.map((folder) => (\n        <FolderComponent key={folder.id} folder={folder} />\n      ))}\n    </FileTree>\n  );\n};\n\nexport default function SidebarFolderTree() {\n  const { folders, error } = useFolders();\n\n  if (!folders || error) return null;\n\n  return <SidebarFolders folders={folders} />;\n}\n\nconst SidebarFoldersSelection = ({\n  folders,\n  selectedFolder,\n  setSelectedFolder,\n  disableId,\n}: {\n  disableId?: string[];\n  folders: FolderWithDocuments[];\n  selectedFolder: TSelectedFolder;\n  setSelectedFolder: React.Dispatch<React.SetStateAction<TSelectedFolder>>;\n}) => {\n  const nestedFolders = useMemo(() => {\n    if (folders) {\n      return buildNestedFolderStructure(folders);\n    }\n    return [];\n  }, [folders]);\n\n  // Create a virtual \"Home\" folder\n  const homeFolder: Partial<FolderWithDocuments> = {\n    // @ts-ignore\n    id: null,\n    name: \"Home\",\n    path: \"/\",\n    childFolders: nestedFolders,\n    documents: [],\n  };\n\n  return (\n    <FileTree>\n      {/* {nestedFolders.map((folder) => ( */}\n      <FolderComponentSelection\n        // key={folder.id}\n        // @ts-ignore\n        folder={homeFolder}\n        selectedFolder={selectedFolder}\n        setSelectedFolder={setSelectedFolder}\n        disableId={disableId}\n      />\n      {/* ))} */}\n    </FileTree>\n  );\n};\n\nexport function SidebarFolderTreeSelection({\n  selectedFolder,\n  setSelectedFolder,\n  disableId,\n}: {\n  selectedFolder: TSelectedFolder;\n  disableId?: string[];\n  setSelectedFolder: React.Dispatch<React.SetStateAction<TSelectedFolder>>;\n}) {\n  const { folders, error } = useFolders();\n\n  if (!folders || error) return null;\n\n  return (\n    <SidebarFoldersSelection\n      folders={folders}\n      selectedFolder={selectedFolder}\n      setSelectedFolder={setSelectedFolder}\n      disableId={disableId}\n    />\n  );\n}\n"
  },
  {
    "path": "components/tab-menu.tsx",
    "content": "import Link from \"next/link\";\n\nimport * as React from \"react\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Separator } from \"@/components/ui/separator\";\n\nimport { cn } from \"@/lib/utils\";\n\ntype Props = {\n  navigation: {\n    label: string;\n    href: string;\n    value: string;\n    currentValue: string;\n    count?: number;\n    tag?: string;\n    disabled?: boolean;\n  }[];\n  className?: string;\n};\n\nexport const TabMenu: React.FC<React.PropsWithChildren<Props>> = ({\n  navigation,\n  className,\n}) => {\n  return (\n    <nav\n      className={cn(\"sticky top-0 bg-background dark:bg-gray-950\", className)}\n    >\n      <div className=\"flex w-full items-center overflow-x-auto px-4\">\n        <ul className=\"flex flex-row gap-4\">\n          {navigation.map(\n            ({ label, href, value, currentValue, count, tag, disabled }) => (\n              <TabItem\n                key={label}\n                label={label}\n                href={href}\n                value={value}\n                currentValue={currentValue}\n                count={count}\n                tag={tag}\n                disabled={disabled}\n              />\n            ),\n          )}\n        </ul>\n      </div>\n      <Separator />\n    </nav>\n  );\n};\n\nconst TabItem: React.FC<Props[\"navigation\"][0]> = ({\n  label,\n  href,\n  value,\n  currentValue,\n  count,\n  tag,\n  disabled,\n}) => {\n  const active = value === currentValue;\n\n  return (\n    <li\n      className={cn(\n        \"flex shrink-0 list-none border-b-2 border-transparent p-2\",\n        {\n          \"border-primary\": active,\n          hidden: disabled,\n        },\n      )}\n    >\n      <Link\n        href={href}\n        className={cn(\n          \"-mx-3 flex items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-muted hover:text-primary\",\n          {\n            \"font-medium\": active,\n          },\n        )}\n      >\n        {label}\n        {count !== undefined && (\n          <Badge\n            variant=\"secondary\"\n            className={cn(\"ml-auto\", {\n              \"bg-primary/10 hover:bg-primary/20\": active,\n            })}\n          >\n            {count}\n          </Badge>\n        )}\n        {tag ? (\n          <div className=\"rounded border bg-background px-1 py-0.5 font-mono text-xs\">\n            {tag}\n          </div>\n        ) : null}\n      </Link>\n    </li>\n  );\n};\n"
  },
  {
    "path": "components/tags/add-tag-modal.tsx",
    "content": "import { ChangeEvent, FormEventHandler, useMemo, useRef } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { TagColorProps } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\n\nimport { COLORS_LIST } from \"../links/link-sheet/tags/tag-badge\";\n\nexport function AddTagsModal({\n  open,\n  setMenuOpen,\n  children,\n  tagForm,\n  setTagForm,\n  handleSubmit,\n  tagCount = 0,\n}: {\n  tagCount: number | undefined;\n  open: boolean;\n  setMenuOpen: (open: boolean) => void;\n  children?: React.ReactNode;\n  tagForm: {\n    color: TagColorProps;\n    name: string;\n    description: string | null;\n    loading: boolean;\n    id?: string;\n  };\n  setTagForm: React.Dispatch<\n    React.SetStateAction<{\n      color: TagColorProps;\n      name: string;\n      description: string | null;\n      loading: boolean;\n    }>\n  >;\n  handleSubmit: FormEventHandler<HTMLFormElement> | undefined;\n}) {\n  const { isFree, isTrial } = usePlan();\n  const initialValues = useRef(tagForm);\n\n  useMemo(() => {\n    if (tagForm.id && tagForm.id !== initialValues.current.id) {\n      initialValues.current = tagForm;\n    }\n  }, [tagForm.id]);\n\n  const hasChanged =\n    tagForm.name !== initialValues.current.name ||\n    tagForm.color !== initialValues.current.color ||\n    tagForm.description !== initialValues.current.description;\n\n  const isFormValid =\n    tagForm.name.length >= 3 && !!tagForm.color && (!tagForm.id || hasChanged);\n\n  // If the team is on a free plan and has reached the max limit of 5 tags\n  if (isFree && tagCount >= 5) {\n    if (children) {\n      return (\n        <UpgradePlanModal\n          clickedPlan={isTrial ? PlanEnum.Business : PlanEnum.Pro}\n          trigger={\"create_tag\"}\n        >\n          <Button>Upgrade to Create Tags</Button>\n        </UpgradePlanModal>\n      );\n    }\n  }\n\n  function handleValueChange(\n    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,\n  ): void {\n    setTagForm((prev) => ({\n      ...prev,\n      [e.target.name]: e.target.value,\n    }));\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={setMenuOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>{tagForm.id ? \"Edit Tag\" : \"Create Tag\"}</DialogTitle>\n          <DialogDescription>\n            Organize your links with tags for easy categorization and search.\n          </DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"name\" className=\"opacity-80\">\n            Tag name\n          </Label>\n          <Input\n            name=\"name\"\n            id=\"name\"\n            value={tagForm.name}\n            placeholder=\"documentation\"\n            className=\"mb-3 mt-1 w-full\"\n            onChange={(e) => handleValueChange(e)}\n          />\n\n          <Label htmlFor=\"color\" className=\"opacity-80\">\n            Tag Color\n          </Label>\n          <ToggleGroup\n            id=\"color\"\n            type=\"single\"\n            value={tagForm.color}\n            onValueChange={(value: TagColorProps) => {\n              if (value) {\n                setTagForm((prev) => ({\n                  ...prev,\n                  color: value,\n                }));\n              }\n            }}\n            className=\"my-2 flex-shrink-0 flex-wrap !justify-start gap-3\"\n          >\n            {COLORS_LIST.map((li) => (\n              <ToggleGroupItem\n                key={li.color}\n                value={li.color}\n                aria-label={`Select ${li.color}`}\n                className={cn(\n                  \"h-8 rounded-full border-0 px-3 text-sm transition-all\",\n                  li.css,\n                  tagForm.color === li.color\n                    ? `ring-${li.color}-500 ring-2 text-${li.color}-500`\n                    : \"border-transparent\",\n                )}\n              >\n                {li.color}\n              </ToggleGroupItem>\n            ))}\n          </ToggleGroup>\n\n          <div>\n            <Label htmlFor=\"description\" className=\"opacity-80\">\n              Description\n            </Label>\n            <Textarea\n              value={tagForm.description || \"\"}\n              onChange={(e) => handleValueChange(e)}\n              rows={5}\n              name=\"description\"\n              id=\"description\"\n              placeholder=\"Add a description to understand the purpose of this tag...\"\n              className=\"mt-1 flex-1 bg-muted\"\n              autoComplete=\"off\"\n            />\n          </div>\n          <DialogFooter>\n            <Button\n              type=\"submit\"\n              className=\"mt-3 h-9 w-full\"\n              disabled={!isFormValid || tagForm.loading}\n            >\n              {tagForm.loading\n                ? \"Saving...\"\n                : tagForm.id\n                  ? \"Save Changes\"\n                  : \"Create Tag\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/teams/add-team-member-modal.tsx",
    "content": "import { useState } from \"react\";\nimport { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nexport function AddTeamMembers({\n  open,\n  setOpen,\n  children,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  children?: React.ReactNode;\n}) {\n  const [email, setEmail] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const analytics = useAnalytics();\n  const router = useRouter();\n  const emailSchema = z\n    .string()\n    .trim()\n    .toLowerCase()\n    .min(3, { message: \"Please enter a valid email.\" })\n    .email({ message: \"Please enter a valid email.\" });\n\n  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const validation = emailSchema.safeParse(email);\n    if (!validation.success) {\n      toast.error(validation.error.errors[0].message);\n      return;\n    }\n\n    setLoading(true);\n    const response = await fetch(`/api/teams/${teamId}/invite`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        email: validation.data,\n      }),\n    });\n\n    if (!response.ok) {\n      const error = await response.json();\n      setLoading(false);\n      setOpen(false);\n      toast.error(error);\n      return;\n    }\n\n    analytics.capture(\"Team Member Invitation Sent\", {\n      email: validation.data,\n      teamId: teamId,\n    });\n\n    mutate(`/api/teams/${teamId}/invitations`);\n    mutate(`/api/teams/${teamId}/limits`);\n\n    toast.success(\"An invitation email has been sent!\");\n    setOpen(false);\n    setLoading(false);\n    \n    // Redirect to team members page\n    router.push(\"/settings/people\");\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-[425px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>Add Member</DialogTitle>\n          <DialogDescription>\n            You can easily add team members.\n          </DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <Label htmlFor=\"domain\" className=\"opacity-80\">\n            Email\n          </Label>\n          <Input\n            id=\"email\"\n            placeholder=\"team@member.com\"\n            className=\"mb-8 mt-1 w-full\"\n            onChange={(e) => setEmail(e.target.value)}\n          />\n\n          <DialogFooter>\n            <Button type=\"submit\" className=\"h-9 w-full\">\n              {loading ? \"Sending email...\" : \"Add member\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/teams/add-team-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { Team } from \"@/lib/types\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\ninterface AddTeamModalProps {\n  children: React.ReactNode;\n  setCurrentTeam: (team: Team) => void;\n}\n\nexport function AddTeamModal({ children, setCurrentTeam }: AddTeamModalProps) {\n  const router = useRouter();\n  const [teamName, setTeamName] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const handleCreateTeam = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!teamName) return;\n\n    setLoading(true);\n    const response = await fetch(\"/api/teams\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        team: teamName.trim(),\n      }),\n    });\n\n    if (!response.ok) {\n      const error = await response.json();\n      setLoading(false);\n      toast.error(error);\n      return;\n    }\n    const data = await response.json();\n    mutate(\"/api/teams\");\n    localStorage.setItem(\"currentTeamId\", data.id);\n    setCurrentTeam(data);\n    toast.success(\"Team created successfully!\");\n    router.push(\"/documents\");\n    setLoading(false);\n  };\n\n  return (\n    <Dialog>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent\n        className=\"bg-background text-foreground\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        <DialogHeader>\n          <DialogTitle>Create a Team</DialogTitle>\n          <DialogDescription className=\"mb-1 py-2 text-sm text-muted-foreground\">\n            Start by naming your new team and inviting team members.\n          </DialogDescription>\n        </DialogHeader>\n        <form\n          onSubmit={handleCreateTeam}\n          className=\"mt-4 flex flex-col gap-8\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          <div className=\"flex flex-col gap-3\">\n            <div>\n              <Label htmlFor=\"team\">Team name</Label>\n            </div>\n            <div>\n              <Input\n                id=\"team\"\n                placeholder=\"Enter team's name\"\n                onChange={(e) => setTeamName(e.target.value)}\n              />\n            </div>\n          </div>\n          <DialogFooter className=\"flex justify-center\">\n            <Button\n              type=\"submit\"\n              className=\"w-full lg:w-1/2\"\n              loading={loading}\n              // disabled={uploading || !currentFile}\n            >\n              {loading ? \"Creating...\" : \"Create\"}\n              {/* {uploading ? \"Uploading...\" : \"Upload Document\"} */}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/teams/delete-team-modal.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\n\nimport { Button } from \"../ui/button\";\nimport { Input } from \"../ui/input\";\nimport { Label } from \"../ui/label\";\n\nexport function DeleteTeamModal({ children }: { children: React.ReactNode }) {\n  const router = useRouter();\n  const [teamName, setTeamName] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n  const teamInfo = useTeam();\n\n  const handleDeleteTeam = async (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!teamName) return;\n\n    if (teamName.trim() !== teamInfo?.currentTeam?.name) {\n      toast.error(\"Team name doesn't match\");\n      return;\n    }\n\n    if (teamInfo?.teams?.length === 1) {\n      toast.error(\"You cannot delete your only team\");\n      return;\n    }\n\n    setLoading(true);\n    const response = await fetch(`/api/teams/${teamInfo?.currentTeam?.id}`, {\n      method: \"DELETE\",\n    });\n\n    if (response.status !== 204) {\n      const error = await response.json();\n      toast.error(error);\n      setLoading(false);\n      return;\n    }\n\n    await mutate(\"/api/teams\");\n    setLoading(false);\n    toast.success(\"Team deleted successfully!\");\n    router.push(\"/documents\");\n  };\n\n  return (\n    <Dialog>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent\n        className=\"bg-background text-foreground\"\n        onClick={(e) => e.stopPropagation()}\n      >\n        <DialogHeader>\n          <DialogTitle>Delete Team</DialogTitle>\n          <DialogDescription className=\"mb-1 py-2 text-sm text-muted-foreground\">\n            Warning: This will permanently delete your team, custom domain, and\n            all associated documents and their respective stats.\n          </DialogDescription>\n        </DialogHeader>\n        <form\n          onSubmit={handleDeleteTeam}\n          className=\"mt-4 flex flex-col gap-8\"\n          onClick={(e) => e.stopPropagation()}\n        >\n          <div className=\"flex flex-col gap-3\">\n            <div>\n              <Label htmlFor=\"team\">\n                Enter the team name{\" \"}\n                <strong className=\"font-extrabold italic\">\n                  {teamInfo?.currentTeam?.name}\n                </strong>{\" \"}\n                to continue:\n              </Label>\n            </div>\n            <div>\n              <Input\n                id=\"team\"\n                placeholder=\"Enter team's name\"\n                onChange={(e) => setTeamName(e.target.value)}\n              />\n            </div>\n          </div>\n          <DialogFooter className=\"flex justify-center\">\n            <Button\n              type=\"submit\"\n              variant={\"destructive\"}\n              className=\"w-full lg:w-1/2\"\n              loading={loading}\n              // disabled={uploading || !currentFile}\n            >\n              {loading ? \"Deleting...\" : \"Delete Team\"}\n              {/* {uploading ? \"Uploading...\" : \"Upload Document\"} */}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/teams/select-team.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { TeamContextType, useTeam } from \"@/context/team-context\";\nimport { Check, Loader, PlusIcon } from \"lucide-react\";\nimport { ChevronsUpDown as ChevronUpDownIcon } from \"lucide-react\";\n\nimport { Team } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Avatar, AvatarFallback } from \"@/components/ui/avatar\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nconst SelectTeam = ({ teams, currentTeam, isLoading }: TeamContextType) => {\n  const router = useRouter();\n  const userTeam = useTeam();\n\n  const switchTeam = (team: Team) => {\n    localStorage.setItem(\"currentTeamId\", team.id);\n    userTeam?.setCurrentTeam(team);\n    router.push(\"/dashboard\");\n  };\n\n  return (\n    <>\n      {isLoading ? (\n        <div className=\"flex items-center gap-2 text-sm\">\n          <Loader className=\"h-5 w-5 animate-spin\" /> Loading teams...\n        </div>\n      ) : (\n        <DropdownMenu>\n          <DropdownMenuTrigger asChild>\n            <div className=\"flex w-full cursor-pointer items-center justify-between rounded-md border px-[10px] py-2 opacity-90 duration-200 hover:bg-muted\">\n              <div className=\"flex items-center space-x-2\">\n                <Avatar className=\"h-[25px] w-[25px] text-[10px]\">\n                  <AvatarFallback>\n                    {currentTeam?.name?.slice(0, 2).toUpperCase()}\n                  </AvatarFallback>\n                </Avatar>\n\n                <p className=\"text-sm\">{currentTeam?.name}</p>\n              </div>\n              <ChevronUpDownIcon className=\"h-4 w-4\" />\n            </div>\n          </DropdownMenuTrigger>\n          <DropdownMenuContent className=\"w-[250px] px-0 pb-1.5 pt-2 sm:w-[270px] lg:w-[240px] xl:w-[270px]\">\n            {teams.map((team) => (\n              <div\n                key={team.id}\n                onClick={() => switchTeam(team)}\n                className={cn(\n                  `flex w-full cursor-pointer items-center justify-between truncate px-3 py-2 text-sm font-normal transition-all duration-75 hover:bg-gray-200 hover:dark:bg-gray-800`,\n                  team.id === currentTeam?.id && \"font-medium\",\n                )}\n              >\n                <div className=\"flex items-center space-x-2\">\n                  <Avatar className=\"h-7 w-7 text-xs\">\n                    <AvatarFallback>\n                      {team.name?.slice(0, 2).toUpperCase()}\n                    </AvatarFallback>\n                  </Avatar>\n\n                  <p>{team.name}</p>\n                </div>\n\n                {team.id === currentTeam?.id && (\n                  <Check\n                    className=\"h-4 w-4 text-black dark:text-white\"\n                    aria-hidden=\"true\"\n                  />\n                )}\n              </div>\n            ))}\n\n            <Link\n              href=\"/settings/people\"\n              className=\"mx-auto mb-1 mt-3 flex w-[92%] items-center rounded-sm border px-[10px] py-2 text-sm duration-100 hover:cursor-pointer hover:bg-gray-200 hover:dark:bg-gray-800\"\n            >\n              <PlusIcon className=\"mr-2 h-4 w-4\" />\n              Invite Members\n            </Link>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      )}\n    </>\n  );\n};\n\nexport default SelectTeam;\n"
  },
  {
    "path": "components/theme-provider.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport {\n  ThemeProvider as NextThemesProvider,\n  type ThemeProviderProps,\n} from \"next-themes\";\n\nexport function ThemeProvider({ children, ...props }: ThemeProviderProps) {\n  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;\n}\n"
  },
  {
    "path": "components/theme-toggle.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Monitor, Palette } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\n\nimport Moon from \"@/components/shared/icons/moon\";\nimport Sun from \"@/components/shared/icons/sun\";\nimport {\n  DropdownMenuPortal,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nexport function ModeToggle() {\n  const { setTheme, theme } = useTheme();\n\n  return (\n    <DropdownMenuSub>\n      <DropdownMenuSubTrigger>\n        <Palette /> Change Theme\n      </DropdownMenuSubTrigger>\n      <DropdownMenuPortal>\n        <DropdownMenuSubContent className=\"w-fit\">\n          <DropdownMenuRadioGroup\n            value={theme}\n            onValueChange={setTheme}\n            className=\"space-y-1 *:flex *:items-center\"\n          >\n            <DropdownMenuRadioItem value=\"light\">\n              <Sun className=\"mr-2 h-4 w-4\" />\n              Light\n            </DropdownMenuRadioItem>\n            <DropdownMenuRadioItem value=\"dark\">\n              <Moon className=\"mr-2 h-4 w-4\" />\n              Dark\n            </DropdownMenuRadioItem>\n            <DropdownMenuRadioItem value=\"system\">\n              <Monitor className=\"mr-2 h-4 w-4\" />\n              System\n            </DropdownMenuRadioItem>\n          </DropdownMenuRadioGroup>\n        </DropdownMenuSubContent>\n      </DropdownMenuPortal>\n    </DropdownMenuSub>\n  );\n}\n"
  },
  {
    "path": "components/ui/accordion.tsx",
    "content": "import * as React from \"react\";\n\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\";\nimport { ChevronDown } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Accordion = AccordionPrimitive.Root;\n\nconst AccordionItem = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <AccordionPrimitive.Item\n    ref={ref}\n    className={cn(\"border-b\", className)}\n    {...props}\n  />\n));\nAccordionItem.displayName = \"AccordionItem\";\n\nconst AccordionTrigger = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Header className=\"flex\">\n    <AccordionPrimitive.Trigger\n      ref={ref}\n      className={cn(\n        \"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\n    </AccordionPrimitive.Trigger>\n  </AccordionPrimitive.Header>\n));\nAccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;\n\nconst AccordionContent = React.forwardRef<\n  React.ElementRef<typeof AccordionPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <AccordionPrimitive.Content\n    ref={ref}\n    className=\"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down\"\n    {...props}\n  >\n    <div className={cn(\"pb-4 pt-0\", className)}>{children}</div>\n  </AccordionPrimitive.Content>\n));\n\nAccordionContent.displayName = AccordionPrimitive.Content.displayName;\n\nexport { Accordion, AccordionItem, AccordionTrigger, AccordionContent };"
  },
  {
    "path": "components/ui/alert-dialog.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useState } from \"react\";\n\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Button, buttonVariants } from \"@/components/ui/button\";\n\nconst AlertDialog = AlertDialogPrimitive.Root;\n\nconst AlertDialogTrigger = AlertDialogPrimitive.Trigger;\n\nconst AlertDialogPortal = AlertDialogPrimitive.Portal;\n\nconst AlertDialogOverlay = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Overlay\n    className={cn(\n      \"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\",\n      className,\n    )}\n    {...props}\n    ref={ref}\n  />\n));\nAlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;\n\nconst AlertDialogContent = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & {\n    overlayClassName?: string;\n  }\n>(({ className, overlayClassName, ...props }, ref) => (\n  <AlertDialogPortal>\n    <AlertDialogOverlay className={overlayClassName} />\n    <AlertDialogPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg\",\n        className,\n      )}\n      onCloseAutoFocus={(event) => {\n        event.preventDefault();\n        document.body.style.pointerEvents = \"\";\n      }}\n      {...props}\n    />\n  </AlertDialogPortal>\n));\nAlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;\n\nconst AlertDialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-2 text-center sm:text-left\",\n      className,\n    )}\n    {...props}\n  />\n);\nAlertDialogHeader.displayName = \"AlertDialogHeader\";\n\nconst AlertDialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className,\n    )}\n    {...props}\n  />\n);\nAlertDialogFooter.displayName = \"AlertDialogFooter\";\n\nconst AlertDialogTitle = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Title\n    ref={ref}\n    className={cn(\"text-lg font-semibold\", className)}\n    {...props}\n  />\n));\nAlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;\n\nconst AlertDialogDescription = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nAlertDialogDescription.displayName =\n  AlertDialogPrimitive.Description.displayName;\n\nconst AlertDialogAction = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Action>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Action\n    ref={ref}\n    className={cn(buttonVariants(), className)}\n    {...props}\n  />\n));\nAlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;\n\nconst AlertDialogCancel = React.forwardRef<\n  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,\n  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>\n>(({ className, ...props }, ref) => (\n  <AlertDialogPrimitive.Cancel\n    ref={ref}\n    className={cn(\n      buttonVariants({ variant: \"outline\" }),\n      \"mt-2 sm:mt-0\",\n      className,\n    )}\n    {...props}\n  />\n));\nAlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;\n\ninterface SingleAlertDialogProps {\n  title: string;\n  description: string;\n  action: string;\n  onAction?: () => Promise<void> | void;\n  actionUpdate?: string;\n  disabled?: boolean;\n  loading?: boolean;\n}\n\nconst CommonAlertDialog = ({\n  title,\n  description,\n  action,\n  onAction,\n  actionUpdate,\n  disabled = false,\n  loading = false,\n}: SingleAlertDialogProps) => {\n  const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);\n\n  const handleDisconnect = async () => {\n    if (onAction) await onAction();\n    setShowDisconnectDialog(false);\n  };\n\n  return (\n    <AlertDialog\n      open={showDisconnectDialog}\n      onOpenChange={setShowDisconnectDialog}\n    >\n      <AlertDialogTrigger asChild>\n        <Button variant=\"destructive\" size=\"sm\" disabled={disabled || loading}>\n          {loading ? `${actionUpdate}...` : action}\n        </Button>\n      </AlertDialogTrigger>\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle> {title} </AlertDialogTitle>\n          <AlertDialogDescription>{description}</AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel>Cancel</AlertDialogCancel>\n          <AlertDialogAction\n            onClick={handleDisconnect}\n            className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n          >\n            {action}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n};\n\nexport {\n  CommonAlertDialog,\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n};\n"
  },
  {
    "path": "components/ui/alert.tsx",
    "content": "import * as React from \"react\";\n\nimport { type VariantProps, cva } from \"class-variance-authority\";\nimport { X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border p-4 [&>svg~*:not(button)]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-background text-foreground\",\n        destructive:\n          \"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive\",\n        warning:\n          \"border-warning/50 text-warning dark:border-warning [&>svg]:text-warning\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nconst Alert = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>\n>(({ className, variant, ...props }, ref) => (\n  <div\n    ref={ref}\n    role=\"alert\"\n    className={cn(alertVariants({ variant }), className)}\n    {...props}\n  />\n));\nAlert.displayName = \"Alert\";\n\nconst AlertTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <h5\n    ref={ref}\n    className={cn(\"mb-1 font-medium leading-none tracking-tight\", className)}\n    {...props}\n  />\n));\nAlertTitle.displayName = \"AlertTitle\";\n\nconst AlertDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm [&_p]:leading-relaxed\", className)}\n    {...props}\n  />\n));\nAlertDescription.displayName = \"AlertDescription\";\n\nconst AlertClose = React.forwardRef<\n  HTMLButtonElement,\n  React.ButtonHTMLAttributes<HTMLButtonElement>\n>(({ className, ...props }, ref) => (\n  <button\n    ref={ref}\n    className={cn(\n      \"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  >\n    <X className=\"h-4 w-4\" />\n    <span className=\"sr-only\">Close</span>\n  </button>\n));\nAlertClose.displayName = \"AlertClose\";\n\nexport { Alert, AlertTitle, AlertDescription, AlertClose };\n"
  },
  {
    "path": "components/ui/avatar.tsx",
    "content": "import * as React from \"react\";\n\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Avatar = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full\",\n      className,\n    )}\n    {...props}\n  />\n));\nAvatar.displayName = AvatarPrimitive.Root.displayName;\n\nconst AvatarImage = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Image>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Image\n    ref={ref}\n    className={cn(\"aspect-square h-full w-full\", className)}\n    {...props}\n  />\n));\nAvatarImage.displayName = AvatarPrimitive.Image.displayName;\n\nconst AvatarFallback = React.forwardRef<\n  React.ElementRef<typeof AvatarPrimitive.Fallback>,\n  React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>\n>(({ className, ...props }, ref) => (\n  <AvatarPrimitive.Fallback\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full items-center justify-center rounded-full bg-gray-300 dark:bg-muted\",\n      className,\n    )}\n    {...props}\n  />\n));\nAvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;\n\nexport { Avatar, AvatarImage, AvatarFallback };\n"
  },
  {
    "path": "components/ui/badge.tsx",
    "content": "import * as React from \"react\";\n\nimport { type VariantProps, cva } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst badgeVariants = cva(\n  \"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-transparent bg-primary text-primary-foreground hover:bg-primary/80\",\n        secondary:\n          \"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80\",\n        outline: \"text-foreground\",\n        time: \"border-transparent bg-amber-50 text-amber-700 hover:bg-amber-100 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50\",\n        email:\n          \"border-transparent bg-blue-50 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50\",\n        password:\n          \"border-transparent bg-purple-50 text-purple-700 hover:bg-purple-100 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50\",\n        preview:\n          \"border-transparent bg-green-50 text-green-700 hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50\",\n        download:\n          \"border-transparent bg-orange-50 text-orange-700 hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50\",\n        watermark:\n          \"border-transparent bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50\",\n        notification:\n          \"px-1.5 rounded-sm bg-secondary text-secondary-foreground border-transparent\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nexport interface BadgeProps\n  extends React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof badgeVariants> {}\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return (\n    <div className={cn(badgeVariants({ variant }), className)} {...props} />\n  );\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "components/ui/bar-list.tsx",
    "content": "import { ReactNode } from \"react\";\n\nimport { motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface BarListProps {\n  data: {\n    icon: ReactNode;\n    title: string;\n    subtitle?: string;\n    value: number;\n    secondaryValue?: string;\n  }[];\n  maxValue: number;\n  barBackground?: string;\n  hoverBackground?: string;\n}\n\nexport default function BarList({\n  data,\n  maxValue,\n  barBackground = \"bg-indigo-100 dark:bg-indigo-950/50\",\n  hoverBackground = \"hover:bg-gray-100 dark:hover:bg-gray-800\",\n}: BarListProps) {\n  return (\n    <div className=\"grid\">\n      {data.map((item, idx) => (\n        <LineItem\n          key={idx}\n          {...item}\n          maxValue={maxValue}\n          barBackground={barBackground}\n          hoverBackground={hoverBackground}\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction LineItem({\n  icon,\n  title,\n  subtitle,\n  value,\n  secondaryValue,\n  maxValue,\n  barBackground,\n  hoverBackground,\n}: {\n  icon: ReactNode;\n  title: string;\n  subtitle?: string;\n  value: number;\n  secondaryValue?: string;\n  maxValue: number;\n  barBackground: string;\n  hoverBackground: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"min-w-0 border-l-2 border-transparent px-4 py-1\",\n        hoverBackground,\n      )}\n    >\n      <div className=\"group flex items-center justify-between\">\n        <div className=\"relative z-10 flex h-8 w-full min-w-0 max-w-[calc(100%-2rem)] items-center\">\n          <div className=\"z-10 flex items-center space-x-3 overflow-hidden px-3\">\n            {icon}\n            <div className=\"truncate\">\n              <div className=\"truncate text-sm text-foreground\">{title}</div>\n              {subtitle && (\n                <div className=\"truncate text-xs text-muted-foreground\">\n                  {subtitle}\n                </div>\n              )}\n            </div>\n          </div>\n          <motion.div\n            style={{\n              width: `${(value / maxValue) * 100}%`,\n            }}\n            className={cn(\n              \"absolute h-full origin-left rounded-md\",\n              barBackground,\n            )}\n            transition={{ ease: \"easeOut\", duration: 0.3 }}\n            initial={{ transform: \"scaleX(0)\" }}\n            animate={{ transform: \"scaleX(1)\" }}\n          />\n          {secondaryValue && (\n            <div className=\"absolute right-2 z-20 text-sm text-muted-foreground\">\n              {secondaryValue}\n            </div>\n          )}\n        </div>\n        <div className=\"w-8 text-right text-sm text-foreground\">{value}</div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/ui/breadcrumb.tsx",
    "content": "import * as React from \"react\";\n\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { ChevronRight, MoreHorizontal } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Breadcrumb = React.forwardRef<\n  HTMLElement,\n  React.ComponentPropsWithoutRef<\"nav\"> & {\n    separator?: React.ReactNode;\n  }\n>(({ ...props }, ref) => <nav ref={ref} aria-label=\"breadcrumb\" {...props} />);\nBreadcrumb.displayName = \"Breadcrumb\";\n\nconst BreadcrumbList = React.forwardRef<\n  HTMLOListElement,\n  React.ComponentPropsWithoutRef<\"ol\">\n>(({ className, ...props }, ref) => (\n  <ol\n    ref={ref}\n    className={cn(\n      \"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5\",\n      className,\n    )}\n    {...props}\n  />\n));\nBreadcrumbList.displayName = \"BreadcrumbList\";\n\nconst BreadcrumbItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentPropsWithoutRef<\"li\">\n>(({ className, ...props }, ref) => (\n  <li\n    ref={ref}\n    className={cn(\"inline-flex items-center gap-1.5\", className)}\n    {...props}\n  />\n));\nBreadcrumbItem.displayName = \"BreadcrumbItem\";\n\nconst BreadcrumbLink = React.forwardRef<\n  HTMLAnchorElement,\n  React.ComponentPropsWithoutRef<\"a\"> & {\n    asChild?: boolean;\n  }\n>(({ asChild, className, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"a\";\n\n  return (\n    <Comp\n      ref={ref}\n      className={cn(\"transition-colors hover:text-foreground\", className)}\n      {...props}\n    />\n  );\n});\nBreadcrumbLink.displayName = \"BreadcrumbLink\";\n\nconst BreadcrumbPage = React.forwardRef<\n  HTMLSpanElement,\n  React.ComponentPropsWithoutRef<\"span\">\n>(({ className, ...props }, ref) => (\n  <span\n    ref={ref}\n    role=\"link\"\n    aria-disabled=\"true\"\n    aria-current=\"page\"\n    className={cn(\"font-normal text-foreground\", className)}\n    {...props}\n  />\n));\nBreadcrumbPage.displayName = \"BreadcrumbPage\";\n\nconst BreadcrumbSeparator = ({\n  children,\n  className,\n  ...props\n}: React.ComponentProps<\"li\">) => (\n  <li\n    role=\"presentation\"\n    aria-hidden=\"true\"\n    className={cn(\"[&>svg]:size-3.5\", className)}\n    {...props}\n  >\n    {children ?? <ChevronRight />}\n  </li>\n);\nBreadcrumbSeparator.displayName = \"BreadcrumbSeparator\";\n\nconst BreadcrumbEllipsis = ({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) => (\n  <span\n    role=\"presentation\"\n    aria-hidden=\"true\"\n    className={cn(\"flex h-9 w-9 items-center justify-center\", className)}\n    {...props}\n  >\n    <MoreHorizontal className=\"h-4 w-4\" />\n    <span className=\"sr-only\">More</span>\n  </span>\n);\nBreadcrumbEllipsis.displayName = \"BreadcrumbElipssis\";\n\nexport {\n  Breadcrumb,\n  BreadcrumbList,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n  BreadcrumbEllipsis,\n};\n"
  },
  {
    "path": "components/ui/button-group.tsx",
    "content": "import { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Separator } from \"@/components/ui/separator\"\n\nconst buttonGroupVariants = cva(\n  \"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1\",\n  {\n    variants: {\n      orientation: {\n        horizontal:\n          \"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none\",\n        vertical:\n          \"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none\",\n      },\n    },\n    defaultVariants: {\n      orientation: \"horizontal\",\n    },\n  }\n)\n\nfunction ButtonGroup({\n  className,\n  orientation,\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof buttonGroupVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"button-group\"\n      data-orientation={orientation}\n      className={cn(buttonGroupVariants({ orientation }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction ButtonGroupText({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  asChild?: boolean\n}) {\n  const Comp = asChild ? Slot : \"div\"\n\n  return (\n    <Comp\n      className={cn(\n        \"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction ButtonGroupSeparator({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"button-group-separator\"\n      orientation={orientation}\n      className={cn(\n        \"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  ButtonGroup,\n  ButtonGroupSeparator,\n  ButtonGroupText,\n  buttonGroupVariants,\n}\n"
  },
  {
    "path": "components/ui/button.tsx",
    "content": "import * as React from \"react\";\n\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { type VariantProps, cva } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport LoadingSpinner from \"./loading-spinner\";\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 gap-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-primary text-primary-foreground hover:bg-primary/90 transition-colors\",\n        special: \"text-white\",\n        orange:\n          \"bg-[#fb7a00] text-white hover:bg-[#fb7a00]/90 transition-colors\",\n        destructive:\n          \"bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors\",\n        outline:\n          \"border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground transition-colors\",\n        link: \"text-primary underline-offset-4 hover:underline transition-colors\",\n      },\n      size: {\n        default: \"h-10 px-4 py-2\",\n        sm: \"h-9 rounded-md px-3\",\n        lg: \"h-11 rounded-md px-8\",\n        icon: \"h-10 w-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n  loading?: boolean;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  (\n    { className, variant, size, disabled, loading, asChild = false, ...props },\n    ref,\n  ) => {\n    const Comp = asChild ? Slot : \"button\";\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        disabled={disabled || loading}\n        {...props}\n      >\n        {loading ? <LoadingSpinner className=\"mr-1 h-5 w-5\" /> : null}\n        {props.children}\n      </Comp>\n    );\n  },\n);\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "components/ui/calendar.tsx",
    "content": "import * as React from \"react\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { DayPicker } from \"react-day-picker\";\n\nimport { buttonVariants } from \"@/components/ui/button\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type CalendarProps = React.ComponentProps<typeof DayPicker>;\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  ...props\n}: CalendarProps) {\n  return (\n    <DayPicker\n      showOutsideDays={showOutsideDays}\n      className={cn(\"p-3\", className)}\n      classNames={{\n        months: \"flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0\",\n        month: \"space-y-4\",\n        caption: \"flex justify-center pt-1 relative items-center\",\n        caption_label: \"text-sm font-medium\",\n        nav: \"space-x-1 flex items-center\",\n        nav_button: cn(\n          buttonVariants({ variant: \"outline\" }),\n          \"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100\",\n        ),\n        nav_button_previous: \"absolute left-1\",\n        nav_button_next: \"absolute right-1\",\n        table: \"w-full border-collapse space-y-1\",\n        head_row: \"flex\",\n        head_cell:\n          \"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]\",\n        row: \"flex w-full mt-2\",\n        cell: \"h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20\",\n        day: cn(\n          buttonVariants({ variant: \"ghost\" }),\n          \"h-9 w-9 p-0 font-normal aria-selected:opacity-100\",\n        ),\n        day_range_end: \"day-range-end\",\n        day_selected:\n          \"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground\",\n        day_today: \"bg-accent text-accent-foreground\",\n        day_outside:\n          \"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground\",\n        day_disabled: \"text-muted-foreground opacity-50\",\n        day_range_middle:\n          \"aria-selected:bg-accent aria-selected:text-accent-foreground\",\n        day_hidden: \"invisible\",\n        ...classNames,\n      }}\n      components={{\n        IconLeft: ({ className, ...props }) => (\n          <ChevronLeft className={cn(\"h-4 w-4\", className)} {...props} />\n        ),\n        IconRight: ({ className, ...props }) => (\n          <ChevronRight className={cn(\"h-4 w-4\", className)} {...props} />\n        ),\n      }}\n      {...props}\n    />\n  );\n}\nCalendar.displayName = \"Calendar\";\n\nexport { Calendar };\n"
  },
  {
    "path": "components/ui/card.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      \"rounded-lg border bg-card text-card-foreground shadow-sm\",\n      className,\n    )}\n    {...props}\n  />\n));\nCard.displayName = \"Card\";\n\nconst CardHeader = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex flex-col space-y-1.5 p-6\", className)}\n    {...props}\n  />\n));\nCardHeader.displayName = \"CardHeader\";\n\nconst CardTitle = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLHeadingElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\n      \"text-2xl font-semibold leading-snug tracking-tight\",\n      className,\n    )}\n    {...props}\n  />\n));\nCardTitle.displayName = \"CardTitle\";\n\nconst CardDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nCardDescription.displayName = \"CardDescription\";\n\nconst CardContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn(\"p-6 pt-0\", className)} {...props} />\n));\nCardContent.displayName = \"CardContent\";\n\nconst CardFooter = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    className={cn(\"flex items-center p-6 pt-0\", className)}\n    {...props}\n  />\n));\nCardFooter.displayName = \"CardFooter\";\n\nexport {\n  Card,\n  CardHeader,\n  CardFooter,\n  CardTitle,\n  CardDescription,\n  CardContent,\n};\n"
  },
  {
    "path": "components/ui/carousel.tsx",
    "content": "import * as React from \"react\";\n\nimport useEmblaCarousel, {\n  type UseEmblaCarouselType,\n} from \"embla-carousel-react\";\nimport { ArrowLeft, ArrowRight } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\n\ntype CarouselApi = UseEmblaCarouselType[1];\ntype UseCarouselParameters = Parameters<typeof useEmblaCarousel>;\ntype CarouselOptions = UseCarouselParameters[0];\ntype CarouselPlugin = UseCarouselParameters[1];\n\ntype CarouselProps = {\n  opts?: CarouselOptions;\n  plugins?: CarouselPlugin;\n  orientation?: \"horizontal\" | \"vertical\";\n  setApi?: (api: CarouselApi) => void;\n};\n\ntype CarouselContextProps = {\n  carouselRef: ReturnType<typeof useEmblaCarousel>[0];\n  api: ReturnType<typeof useEmblaCarousel>[1];\n  scrollPrev: () => void;\n  scrollNext: () => void;\n  canScrollPrev: boolean;\n  canScrollNext: boolean;\n} & CarouselProps;\n\nconst CarouselContext = React.createContext<CarouselContextProps | null>(null);\n\nfunction useCarousel() {\n  const context = React.useContext(CarouselContext);\n\n  if (!context) {\n    throw new Error(\"useCarousel must be used within a <Carousel />\");\n  }\n\n  return context;\n}\n\nconst Carousel = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement> & CarouselProps\n>(\n  (\n    {\n      orientation = \"horizontal\",\n      opts,\n      setApi,\n      plugins,\n      className,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const [carouselRef, api] = useEmblaCarousel(\n      {\n        ...opts,\n        axis: orientation === \"horizontal\" ? \"x\" : \"y\",\n      },\n      plugins,\n    );\n    const [canScrollPrev, setCanScrollPrev] = React.useState(false);\n    const [canScrollNext, setCanScrollNext] = React.useState(false);\n\n    const onSelect = React.useCallback((api: CarouselApi) => {\n      if (!api) {\n        return;\n      }\n\n      setCanScrollPrev(api.canScrollPrev());\n      setCanScrollNext(api.canScrollNext());\n    }, []);\n\n    const scrollPrev = React.useCallback(() => {\n      api?.scrollPrev();\n    }, [api]);\n\n    const scrollNext = React.useCallback(() => {\n      api?.scrollNext();\n    }, [api]);\n\n    const handleKeyDown = React.useCallback(\n      (event: React.KeyboardEvent<HTMLDivElement>) => {\n        if (event.key === \"ArrowLeft\") {\n          event.preventDefault();\n          scrollPrev();\n        } else if (event.key === \"ArrowRight\") {\n          event.preventDefault();\n          scrollNext();\n        }\n      },\n      [scrollPrev, scrollNext],\n    );\n\n    React.useEffect(() => {\n      if (!api || !setApi) {\n        return;\n      }\n\n      setApi(api);\n    }, [api, setApi]);\n\n    React.useEffect(() => {\n      if (!api) {\n        return;\n      }\n\n      onSelect(api);\n      api.on(\"reInit\", onSelect);\n      api.on(\"select\", onSelect);\n\n      return () => {\n        api?.off(\"reInit\", onSelect);\n        api?.off(\"select\", onSelect);\n      };\n    }, [api, onSelect]);\n\n    return (\n      <CarouselContext.Provider\n        value={{\n          carouselRef,\n          api: api,\n          opts,\n          orientation:\n            orientation || (opts?.axis === \"y\" ? \"vertical\" : \"horizontal\"),\n          scrollPrev,\n          scrollNext,\n          canScrollPrev,\n          canScrollNext,\n        }}\n      >\n        <div\n          ref={ref}\n          onKeyDownCapture={handleKeyDown}\n          className={cn(\"relative\", className)}\n          role=\"region\"\n          aria-roledescription=\"carousel\"\n          {...props}\n        >\n          {children}\n        </div>\n      </CarouselContext.Provider>\n    );\n  },\n);\nCarousel.displayName = \"Carousel\";\n\nconst CarouselContent = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const { carouselRef, orientation } = useCarousel();\n\n  return (\n    <div ref={carouselRef} className=\"overflow-hidden\">\n      <div\n        ref={ref}\n        className={cn(\n          \"flex\",\n          orientation === \"horizontal\" ? \"-ml-4\" : \"-mt-4 flex-col\",\n          className,\n        )}\n        {...props}\n      />\n    </div>\n  );\n});\nCarouselContent.displayName = \"CarouselContent\";\n\nconst CarouselItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const { orientation } = useCarousel();\n\n  return (\n    <div\n      ref={ref}\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      className={cn(\n        \"min-w-0 shrink-0 grow-0 basis-full\",\n        orientation === \"horizontal\" ? \"pl-4\" : \"pt-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nCarouselItem.displayName = \"CarouselItem\";\n\nconst CarouselPrevious = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<typeof Button>\n>(({ className, variant = \"outline\", size = \"icon\", ...props }, ref) => {\n  const { orientation, scrollPrev, canScrollPrev } = useCarousel();\n\n  return (\n    <Button\n      ref={ref}\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute h-8 w-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"-left-12 top-1/2 -translate-y-1/2\"\n          : \"-top-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className,\n      )}\n      disabled={!canScrollPrev}\n      onClick={scrollPrev}\n      {...props}\n    >\n      <ArrowLeft className=\"h-4 w-4\" />\n      <span className=\"sr-only\">Previous slide</span>\n    </Button>\n  );\n});\nCarouselPrevious.displayName = \"CarouselPrevious\";\n\nconst CarouselNext = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<typeof Button>\n>(({ className, variant = \"outline\", size = \"icon\", ...props }, ref) => {\n  const { orientation, scrollNext, canScrollNext } = useCarousel();\n\n  return (\n    <Button\n      ref={ref}\n      variant={variant}\n      size={size}\n      className={cn(\n        \"absolute h-8 w-8 rounded-full\",\n        orientation === \"horizontal\"\n          ? \"-right-12 top-1/2 -translate-y-1/2\"\n          : \"-bottom-12 left-1/2 -translate-x-1/2 rotate-90\",\n        className,\n      )}\n      disabled={!canScrollNext}\n      onClick={scrollNext}\n      {...props}\n    >\n      <ArrowRight className=\"h-4 w-4\" />\n      <span className=\"sr-only\">Next slide</span>\n    </Button>\n  );\n});\nCarouselNext.displayName = \"CarouselNext\";\n\nexport {\n  type CarouselApi,\n  Carousel,\n  CarouselContent,\n  CarouselItem,\n  CarouselPrevious,\n  CarouselNext,\n};\n"
  },
  {
    "path": "components/ui/checkbox.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport Check from \"@/components/shared/icons/check\";\n\nconst Checkbox = React.forwardRef<\n  React.ElementRef<typeof CheckboxPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n>(({ className, ...props }, ref) => (\n  <CheckboxPrimitive.Root\n    ref={ref}\n    className={cn(\n      \"peer h-5 w-5 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground\",\n      className,\n    )}\n    {...props}\n  >\n    <CheckboxPrimitive.Indicator\n      className={cn(\"flex items-center justify-center text-current\")}\n    >\n      <Check className=\"h-3 w-3\" />\n    </CheckboxPrimitive.Indicator>\n  </CheckboxPrimitive.Root>\n));\nCheckbox.displayName = CheckboxPrimitive.Root.displayName;\n\nexport { Checkbox };\n"
  },
  {
    "path": "components/ui/collapsible.tsx",
    "content": "import * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\";\n\nconst Collapsible = CollapsiblePrimitive.Root;\n\nconst CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;\n\nconst CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;\n\nexport { Collapsible, CollapsibleTrigger, CollapsibleContent };\n"
  },
  {
    "path": "components/ui/command.tsx",
    "content": "import * as React from \"react\";\n\nimport { type DialogProps } from \"@radix-ui/react-dialog\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { LucideIcon, Search } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\n\nconst Command = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive\n    ref={ref}\n    className={cn(\n      \"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\nCommand.displayName = CommandPrimitive.displayName;\n\ninterface CommandDialogProps extends DialogProps {}\n\nconst CommandDialog = ({ children, ...props }: CommandDialogProps) => {\n  return (\n    <Dialog {...props}>\n      <DialogContent className=\"overflow-hidden p-0 shadow-lg\">\n        <Command className=\"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  );\n};\n\nconst CommandInput = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Input>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {\n    wrapperClassName?: string;\n    noIcon?: boolean;\n  }\n>(({ className, wrapperClassName, noIcon, ...props }, ref) => (\n  <div\n    className={cn(\"flex items-center border-b px-3\", wrapperClassName)}\n    cmdk-input-wrapper=\"\"\n  >\n    {!noIcon && <Search className=\"h-4 w-4 shrink-0 opacity-50\" />}\n    <CommandPrimitive.Input\n      ref={ref}\n      className={cn(\n        \"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    />\n  </div>\n));\n\nCommandInput.displayName = CommandPrimitive.Input.displayName;\n\nconst CommandList = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.List\n    ref={ref}\n    className={cn(\"max-h-[300px] overflow-y-auto overflow-x-hidden\", className)}\n    {...props}\n  />\n));\n\nCommandList.displayName = CommandPrimitive.List.displayName;\n\nconst CommandEmpty = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Empty>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>\n>((props, ref) => (\n  <CommandPrimitive.Empty\n    ref={ref}\n    className=\"py-6 text-center text-sm\"\n    {...props}\n  />\n));\n\nCommandEmpty.displayName = CommandPrimitive.Empty.displayName;\n\nconst CommandGroup = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Group>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Group\n    ref={ref}\n    className={cn(\n      \"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\n\nCommandGroup.displayName = CommandPrimitive.Group.displayName;\n\nconst CommandSeparator = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 h-px bg-border\", className)}\n    {...props}\n  />\n));\nCommandSeparator.displayName = CommandPrimitive.Separator.displayName;\n\nconst CommandItem = React.forwardRef<\n  React.ElementRef<typeof CommandPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>\n>(({ className, ...props }, ref) => (\n  <CommandPrimitive.Item\n    ref={ref}\n    className={cn(\n      // NOTE: removed these classNames because it broke the UI \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\"\n      \"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\n\nCommandItem.displayName = CommandPrimitive.Item.displayName;\n\nconst CommandShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\n        \"ml-auto text-xs tracking-widest text-muted-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  );\n};\nCommandShortcut.displayName = \"CommandShortcut\";\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n};\n"
  },
  {
    "path": "components/ui/copy-button.tsx",
    "content": "\"use client\";\n\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { CheckIcon, CopyIcon, LucideIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useCopyToClipboard } from \"@/lib/hooks/use-copy-to-clipboard\";\nimport { cn } from \"@/lib/utils\";\n\nconst copyButtonVariants = cva(\n  \"relative group rounded-full p-1.5 transition-all duration-75\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent hover:bg-neutral-100 active:bg-neutral-200\",\n        neutral: \"bg-transparent hover:bg-neutral-100 active:bg-neutral-200\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nexport function CopyButton({\n  variant = \"default\",\n  value,\n  className,\n  icon,\n  successMessage,\n}: {\n  value: string;\n  className?: string;\n  icon?: LucideIcon;\n  successMessage?: string;\n} & VariantProps<typeof copyButtonVariants>) {\n  const [copied, copyToClipboard] = useCopyToClipboard();\n  const Comp = icon || CopyIcon;\n  return (\n    <button\n      onClick={(e) => {\n        e.stopPropagation();\n        toast.promise(copyToClipboard(value), {\n          success: successMessage || \"Copied to clipboard!\",\n        });\n      }}\n      className={cn(copyButtonVariants({ variant }), className)}\n      type=\"button\"\n    >\n      <span className=\"sr-only\">Copy</span>\n      {copied ? (\n        <CheckIcon className=\"h-3.5 w-3.5\" />\n      ) : (\n        <Comp className=\"h-3.5 w-3.5\" />\n      )}\n    </button>\n  );\n}\n"
  },
  {
    "path": "components/ui/devices.tsx",
    "content": "// Thanks to Steven Tey for the original code: https://github.com/dubinc/dub/blob/652f17677828c5a9d5d354841b0bfba5fe63c7a8/apps/web/ui/shared/icons/devices.tsx\n\nexport function Chrome({ className }: { className: string }) {\n  return (\n    <svg viewBox=\"0 0 100 100\" className={className}>\n      <linearGradient\n        id=\"b\"\n        x1=\"55.41\"\n        x2=\"12.11\"\n        y1=\"96.87\"\n        y2=\"21.87\"\n        gradientUnits=\"userSpaceOnUse\"\n      >\n        <stop offset=\"0\" stopColor=\"#1e8e3e\" />\n        <stop offset=\"1\" stopColor=\"#34a853\" />\n      </linearGradient>\n      <linearGradient\n        id=\"c\"\n        x1=\"42.7\"\n        x2=\"86\"\n        y1=\"100\"\n        y2=\"25.13\"\n        gradientUnits=\"userSpaceOnUse\"\n      >\n        <stop offset=\"0\" stopColor=\"#fcc934\" />\n        <stop offset=\"1\" stopColor=\"#fbbc04\" />\n      </linearGradient>\n      <linearGradient\n        id=\"a\"\n        x1=\"6.7\"\n        x2=\"93.29\"\n        y1=\"31.25\"\n        y2=\"31.25\"\n        gradientUnits=\"userSpaceOnUse\"\n      >\n        <stop offset=\"0\" stopColor=\"#d93025\" />\n        <stop offset=\"1\" stopColor=\"#ea4335\" />\n      </linearGradient>\n      <path fill=\"url(#a)\" d=\"M93.29 25a50 50 90 0 0-86.6 0l3 54z\" />\n      <path fill=\"url(#b)\" d=\"M28.35 62.5 6.7 25A50 50 90 0 0 50 100l49-50z\" />\n      <path fill=\"url(#c)\" d=\"M71.65 62.5 50 100a50 50 90 0 0 43.29-75H50z\" />\n      <path fill=\"#fff\" d=\"M50 75a25 25 90 1 0 0-50 25 25 90 0 0 0 50z\" />\n      <path\n        fill=\"#1a73e8\"\n        d=\"M50 69.8a19.8 19.8 90 1 0 0-39.6 19.8 19.8 90 0 0 0 39.6z\"\n      />{\" \"}\n    </svg>\n  );\n}\n\nexport function Safari({ className }: { className: string }) {\n  return (\n    <svg className={className} width=\"66\" height=\"66\" viewBox=\"0 0 66 66\">\n      <path\n        fill=\"#C6C6C6\"\n        stroke=\"#C6C6C6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"0.5\"\n        d=\"M383.29373 211.97671a31.325188 31.325188 0 0 1-31.32519 31.32519 31.325188 31.325188 0 0 1-31.32518-31.32519 31.325188 31.325188 0 0 1 31.32518-31.32519 31.325188 31.325188 0 0 1 31.32519 31.32519z\"\n        paintOrder=\"markers stroke fill\"\n        transform=\"translate(-318.88562 -180.59501)\"\n      />\n      <path\n        fill=\"#4A9DED\"\n        d=\"M380.83911 211.97671a28.870571 28.870571 0 0 1-28.87057 28.87057 28.870571 28.870571 0 0 1-28.87057-28.87057 28.870571 28.870571 0 0 1 28.87057-28.87057 28.870571 28.870571 0 0 1 28.87057 28.87057z\"\n        paintOrder=\"markers stroke fill\"\n        transform=\"translate(-318.88562 -180.59501)\"\n      />\n      <path\n        fill=\"#ff5150\"\n        d=\"m36.3834003 34.83806178-6.60095092-6.91272438 23.41607429-15.75199774z\"\n        paintOrder=\"markers stroke fill\"\n      />\n      <path\n        fill=\"#f1f1f1\"\n        d=\"m36.38339038 34.83805895-6.60095092-6.91272438-16.81512624 22.66471911z\"\n        paintOrder=\"markers stroke fill\"\n      />\n      <path\n        d=\"m12.96732 50.59006 23.41607-15.75201 16.81513-22.66472z\"\n        opacity=\".243\"\n      />\n    </svg>\n  );\n}\n\nexport function Apple({ className }: { className: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 2048 2048\"\n      width=\"2048px\"\n      height=\"2048px\"\n      className={className}\n    >\n      <path\n        fill=\"#424242\"\n        fillRule=\"nonzero\"\n        d=\"M1318.64 413.756c-14.426,44.2737 -37.767,85.3075 -65.8997,119.436l0 0.0625985c-28.3855,34.324 -66.3012,64.6713 -108.482,84.7926 -38.713,18.4665 -81.1489,28.4114 -123.377,25.1197l-12.9236 -1.00748 -1.70197 -12.8681c-5.48622,-41.4992 0.849213,-83.5099 14.1921,-122.387 15.5268,-45.241 40.6772,-86.5205 67.6642,-117.8l-0.00472441 -0.00472441c27.9272,-32.7142 65.3788,-61.1776 105.487,-81.8009 40.2437,-20.6941 83.465,-33.6343 122.803,-35.237l14.8701 -0.605906 1.62992 14.8559c4.76457,43.4481 -1.02992,86.8489 -14.2571,127.445z\"\n      />\n      <path\n        fill=\"#424242\"\n        fillRule=\"nonzero\"\n        d=\"M1592.05 804.067c-14.2559,8.82048 -152.045,94.0808 -150.337,265.937 1.80236,207.182 177.474,279.003 187.171,282.966l0.0625985 0 0.419292 0.173622 13.7835 5.70709 -4.72087 14.1047c-0.279921,0.836221 0.0377953,-0.0531496 -0.370866,1.25906 -4.48229,14.361 -34.8685,111.708 -103.511,212.014 -31.1481,45.4985 -62.8831,90.9284 -100.352,125.971 -38.7957,36.2823 -83.1024,60.7737 -137.837,61.7906 -51.5894,0.968505 -85.3642,-13.6453 -120.474,-28.8366 -33.4784,-14.4862 -68.2949,-29.5524 -122.779,-29.5524 -57.2339,0 -93.8198,15.5858 -129.06,30.6 -33.1725,14.1319 -65.2548,27.7996 -111.474,29.6433l-0.0625985 0c-53.3693,1.98189 -99.6485,-24.0343 -140.778,-62.5678 -39.3496,-36.8646 -73.8249,-85.1398 -105.241,-130.579 -70.917,-102.399 -132.592,-251.392 -151.647,-402.892 -15.6732,-124.616 -2.57244,-251.206 57.6756,-355.753 33.6331,-58.4953 80.6398,-106.233 135.598,-139.543 54.7075,-33.1571 117.299,-52.0264 182.451,-53.0032l0 -0.0011811c57.0402,-1.03465 110.823,20.3091 157.884,38.9847 33.3059,13.2165 62.98,24.9933 85.226,24.9933 19.6536,0 48.6237,-11.4224 82.3949,-24.737 57.0367,-22.487 126.815,-49.9949 200.599,-42.6579 30.9862,1.34764 95.5265,8.76969 161.501,44.524 42.0284,22.7776 84.6579,56.9741 119.701,108.261l9.23977 13.5248 -13.8024 8.91261c-0.73819,0.477166 -0.0200788,-0.00944883 -1.25906,0.755906z\"\n      />\n    </svg>\n  );\n}\n"
  },
  {
    "path": "components/ui/dialog.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport X from \"@/components/shared/icons/x\";\n\nconst Dialog = DialogPrimitive.Root;\n\nconst DialogTrigger = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>\n>((props, ref) => <DialogPrimitive.Trigger ref={ref} {...props} />);\nDialogTrigger.displayName = DialogPrimitive.Trigger.displayName;\n\nconst DialogPortal = ({\n  children,\n  isPreviewDialog,\n  ...props\n}: DialogPrimitive.DialogPortalProps & { isPreviewDialog?: boolean }) => (\n  <DialogPrimitive.Portal {...props}>\n    <div\n      className={cn(\n        \"fixed inset-0 z-50 flex items-end justify-center sm:items-center\",\n        isPreviewDialog && \"items-center\",\n      )}\n    >\n      {children}\n    </div>\n  </DialogPrimitive.Portal>\n);\nDialogPortal.displayName = DialogPrimitive.Portal.displayName;\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={cn(\n      \"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in\",\n      className,\n    )}\n    {...props}\n  />\n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {\n    isDocumentDialog?: boolean;\n    isPreviewDialog?: boolean;\n  }\n>(\n  (\n    { className, children, isDocumentDialog, isPreviewDialog, ...props },\n    ref,\n  ) => (\n    <DialogPortal isPreviewDialog={isPreviewDialog}>\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        ref={ref}\n        className={cn(\n          \"fixed z-50 grid w-full gap-4 rounded-t-lg border border-gray-800 bg-background p-6 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-xl sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0 md:w-1/2\",\n          className,\n        )}\n        onCloseAutoFocus={(event) => {\n          event.preventDefault();\n          document.body.style.pointerEvents = \"\";\n        }}\n        {...props}\n      >\n        {children}\n        <DialogPrimitive.Close\n          className={cn(\n            \"absolute rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\",\n            isDocumentDialog ? \"right-8 top-20\" : \"right-4 top-4\",\n          )}\n        >\n          <X className=\"h-4 w-4\" />\n          <span className=\"sr-only\">Close</span>\n        </DialogPrimitive.Close>\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  ),\n);\nDialogContent.displayName = DialogPrimitive.Content.displayName;\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-1.5 text-center sm:text-left\",\n      className,\n    )}\n    {...props}\n  />\n);\nDialogHeader.displayName = \"DialogHeader\";\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className,\n    )}\n    {...props}\n  />\n);\nDialogFooter.displayName = \"DialogFooter\";\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-none tracking-tight\",\n      className,\n    )}\n    {...props}\n  />\n));\nDialogTitle.displayName = DialogPrimitive.Title.displayName;\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n  Dialog,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription,\n};\n"
  },
  {
    "path": "components/ui/drawer.tsx",
    "content": "import * as React from \"react\";\n\nimport { Drawer as DrawerPrimitive } from \"vaul\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Drawer = ({\n  shouldScaleBackground = false,\n  ...props\n}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (\n  <DrawerPrimitive.Root\n    shouldScaleBackground={shouldScaleBackground}\n    {...props}\n  />\n);\nDrawer.displayName = \"Drawer\";\n\nconst DrawerTrigger = DrawerPrimitive.Trigger;\n\nconst DrawerPortal = DrawerPrimitive.Portal;\n\nconst DrawerClose = DrawerPrimitive.Close;\n\nconst DrawerOverlay = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DrawerPrimitive.Overlay\n    ref={ref}\n    className={cn(\"fixed inset-0 z-50 bg-black/80\", className)}\n    {...props}\n  />\n));\nDrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;\n\nconst DrawerContent = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DrawerPortal>\n    {/* <DrawerOverlay /> */}\n    <DrawerPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"fixed inset-x-0 bottom-0 z-50 flex h-auto flex-col rounded-t-[10px] border bg-background\",\n        className,\n      )}\n      onEscapeKeyDown={(e) => e.preventDefault()}\n      onPointerDownOutside={(e) => e.preventDefault()}\n      {...props}\n    >\n      {/* <div className=\"mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted\" /> */}\n      {children}\n    </DrawerPrimitive.Content>\n  </DrawerPortal>\n));\nDrawerContent.displayName = \"DrawerContent\";\n\nconst DrawerHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\"grid gap-1.5 p-4 text-center sm:text-left\", className)}\n    {...props}\n  />\n);\nDrawerHeader.displayName = \"DrawerHeader\";\n\nconst DrawerFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)}\n    {...props}\n  />\n);\nDrawerFooter.displayName = \"DrawerFooter\";\n\nconst DrawerTitle = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DrawerPrimitive.Title\n    ref={ref}\n    className={cn(\n      \"text-lg font-semibold leading-none tracking-tight\",\n      className,\n    )}\n    {...props}\n  />\n));\nDrawerTitle.displayName = DrawerPrimitive.Title.displayName;\n\nconst DrawerDescription = React.forwardRef<\n  React.ElementRef<typeof DrawerPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DrawerPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nDrawerDescription.displayName = DrawerPrimitive.Description.displayName;\n\nexport {\n  Drawer,\n  DrawerPortal,\n  DrawerOverlay,\n  DrawerTrigger,\n  DrawerClose,\n  DrawerContent,\n  DrawerHeader,\n  DrawerFooter,\n  DrawerTitle,\n  DrawerDescription,\n};\n"
  },
  {
    "path": "components/ui/dropdown-menu.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport Check from \"@/components/shared/icons/check\";\nimport ChevronRight from \"@/components/shared/icons/chevron-right\";\nimport Circle from \"@/components/shared/icons/circle\";\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean;\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto h-4 w-4\" />\n  </DropdownMenuPrimitive.SubTrigger>\n));\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className,\n    )}\n    {...props}\n  />\n));\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName;\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <DropdownMenuPrimitive.Portal>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n));\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  />\n));\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className,\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName;\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className,\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-2 w-2 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n));\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      \"px-2 py-1.5 text-sm font-semibold\",\n      inset && \"pl-8\",\n      className,\n    )}\n    {...props}\n  />\n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\"ml-auto text-xs tracking-widest opacity-60\", className)}\n      {...props}\n    />\n  );\n};\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\";\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n};\n"
  },
  {
    "path": "components/ui/feature-preview.tsx",
    "content": "import React from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LockIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { UpgradeButton } from \"@/components/ui/upgrade-button\";\n\ninterface FeaturePreviewProps {\n  /**\n   * The title displayed in the preview card\n   */\n  title: string;\n  /**\n   * The description/subtitle displayed in the preview card\n   */\n  description: string;\n  /**\n   * The plan required to access this feature\n   */\n  requiredPlan: PlanEnum;\n  /**\n   * Analytics trigger identifier for tracking upgrade clicks\n   */\n  trigger: string;\n  /**\n   * The mock content to show as a preview (will be behind a gradient overlay)\n   */\n  children: React.ReactNode;\n  /**\n   * Additional CSS classes for the container\n   */\n  className?: string;\n  /**\n   * Custom upgrade button text\n   */\n  upgradeButtonText?: string;\n}\n\n/**\n * A reusable component that shows a preview of premium features with an upgrade overlay\n *\n * @example\n * ```tsx\n * <FeaturePreview\n *   title=\"Advanced Analytics\"\n *   description=\"Get detailed insights into document engagement and user behavior\"\n *   requiredPlan={PlanEnum.DataRooms}\n *   trigger=\"analytics_preview\"\n * >\n *   <YourMockAnalyticsComponent />\n * </FeaturePreview>\n * ```\n */\nexport function FeaturePreview({\n  title,\n  description,\n  requiredPlan,\n  trigger,\n  children,\n  className,\n  upgradeButtonText = \"Unlock\",\n}: FeaturePreviewProps) {\n  return (\n    <div className={cn(\"relative\", className)}>\n      {/* Content with no interaction */}\n      <div className=\"pointer-events-none\">{children}</div>\n\n      {/* Gradient overlay that fades the content into the upgrade section */}\n      <div className=\"absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background/90\" />\n\n      {/* Upgrade prompt positioned at the bottom */}\n      <div className=\"absolute inset-x-0 bottom-0 flex items-end justify-center pb-8\">\n        <Card className=\"max-w-md border-2 border-primary/20 bg-background/95 shadow-lg backdrop-blur-sm\">\n          <CardHeader className=\"text-center\">\n            <div className=\"mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10\">\n              <LockIcon className=\"h-6 w-6 text-primary\" />\n            </div>\n            <CardTitle className=\"text-xl\">{title}</CardTitle>\n            <CardDescription className=\"text-base\">\n              {description}\n            </CardDescription>\n          </CardHeader>\n          <CardContent className=\"text-center\">\n            <UpgradeButton\n              text={upgradeButtonText}\n              clickedPlan={requiredPlan}\n              trigger={trigger}\n              size=\"lg\"\n              className=\"w-full\"\n            />\n          </CardContent>\n        </Card>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/ui/file-upload.tsx",
    "content": "import { DragEvent, ReactNode, useState } from \"react\";\n\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { CloudUpload, LoaderCircle } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { cn } from \"@/lib/utils\";\nimport { resizeImage } from \"@/lib/utils/resize-image\";\n\ntype AcceptedFileFormats = \"any\" | \"images\" | \"csv\";\n\nconst acceptFileTypes: Record<\n  AcceptedFileFormats,\n  { types: string[]; errorMessage?: string }\n> = {\n  any: { types: [] },\n  images: {\n    types: [\"image/png\", \"image/jpg\", \"image/jpeg\"],\n    errorMessage: \"File type not supported (.png or .jpg only)\",\n  },\n  csv: {\n    types: [\"text/csv\", \"text/tab-separated-values\"],\n    errorMessage: \"File type not supported (.csv or .tsv only)\",\n  },\n};\n\nconst imageUploadVariants = cva(\n  \"group relative isolate flex aspect-[1200/630] w-full flex-col items-center justify-center overflow-hidden bg-white transition-all hover:bg-gray-50\",\n  {\n    variants: {\n      variant: {\n        default: \"rounded-md border border-gray-300 shadow-sm\",\n        plain: \"\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\ntype FileUploadReadFileProps =\n  | {\n      /**\n       * Whether to automatically read the file and return the result as `src` to onChange\n       */\n      readFile?: false;\n      onChange?: (data: { file: File }) => void;\n    }\n  | {\n      /**\n       * Whether to automatically read the file and return the result as `src` to onChange\n       */\n      readFile: true;\n      onChange?: (data: { file: File; src: string }) => void;\n    };\n\nexport type FileUploadProps = FileUploadReadFileProps & {\n  accept: AcceptedFileFormats;\n  className?: string;\n  iconClassName?: string;\n  previewClassName?: string;\n  /**\n   * Custom preview component to display instead of the default\n   */\n  customPreview?: ReactNode;\n  /**\n   * Image to display (generally for image uploads)\n   */\n  imageSrc?: string | null;\n\n  /**\n   * Whether to display a loading spinner\n   */\n  loading?: boolean;\n\n  /**\n   * Whether to allow clicking on the area to upload\n   */\n  clickToUpload?: boolean;\n\n  /**\n   * Whether to show instruction overlay when hovered\n   */\n  showHoverOverlay?: boolean;\n\n  /**\n   * Content to display below the upload icon (null to only display the icon)\n   */\n  content?: ReactNode | null;\n\n  /**\n   * Desired resolution to suggest and optionally resize to\n   */\n  targetResolution?: { width: number; height: number; quality: number };\n\n  /**\n   * A maximum file size (in megabytes) to check upon file selection\n   */\n  maxFileSizeMB?: number;\n\n  /**\n   * Accessibility label for screen readers\n   */\n  accessibilityLabel?: string;\n\n  disabled?: boolean;\n} & VariantProps<typeof imageUploadVariants>;\n\nexport function FileUpload({\n  readFile,\n  onChange,\n  variant,\n  className,\n  iconClassName,\n  previewClassName,\n  customPreview,\n  accept = \"any\",\n  imageSrc,\n  loading = false,\n  clickToUpload = true,\n  showHoverOverlay = true,\n  content,\n  maxFileSizeMB = 0,\n  targetResolution,\n  accessibilityLabel = \"File upload\",\n  disabled = false,\n}: FileUploadProps) {\n  const [dragActive, setDragActive] = useState(false);\n  const [fileName, setFileName] = useState<string | null>(null);\n\n  const onFileChange = async (\n    e: React.ChangeEvent<HTMLInputElement> | DragEvent,\n  ) => {\n    const file =\n      \"dataTransfer\" in e\n        ? e.dataTransfer.files && e.dataTransfer.files[0]\n        : e.target.files && e.target.files[0];\n    if (!file) return;\n\n    setFileName(file.name);\n\n    if (maxFileSizeMB > 0 && file.size / 1024 / 1024 > maxFileSizeMB) {\n      toast.error(`File size too big (max ${maxFileSizeMB} MB)`);\n      return;\n    }\n\n    const acceptedTypes = acceptFileTypes[accept].types;\n\n    if (acceptedTypes.length && !acceptedTypes.includes(file.type)) {\n      toast.error(\n        acceptFileTypes[accept].errorMessage ?? \"File type not supported\",\n      );\n      return;\n    }\n\n    let fileToUse = file;\n\n    // Add image resizing logic\n    if (targetResolution && file.type.startsWith(\"image/\")) {\n      try {\n        const resizedFile = await resizeImage(file, targetResolution);\n        const blob = await fetch(resizedFile).then((r) => r.blob());\n        fileToUse = new File([blob], file.name, { type: file.type });\n      } catch (error) {\n        console.error(\"Error resizing image:\", error);\n        // Fallback to original file if resize fails\n      }\n    }\n\n    // File reading logic\n    if (readFile) {\n      const reader = new FileReader();\n      reader.onload = (e) =>\n        onChange?.({ src: e.target?.result as string, file: fileToUse });\n      reader.readAsDataURL(fileToUse);\n      return;\n    }\n\n    onChange?.({ file: fileToUse });\n  };\n\n  return (\n    <label\n      className={cn(\n        imageUploadVariants({ variant }),\n        !disabled\n          ? cn(clickToUpload && \"cursor-pointer\")\n          : \"cursor-not-allowed\",\n        className,\n      )}\n    >\n      {loading && (\n        <div className=\"absolute inset-0 z-[5] flex items-center justify-center rounded-[inherit] bg-white\">\n          <LoaderCircle />\n        </div>\n      )}\n      <div\n        className=\"absolute inset-0 z-[5]\"\n        onDragOver={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setDragActive(true);\n        }}\n        onDragEnter={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setDragActive(true);\n        }}\n        onDragLeave={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          setDragActive(false);\n        }}\n        onDrop={async (e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          onFileChange(e);\n          setDragActive(false);\n        }}\n      />\n      <div\n        className={cn(\n          \"absolute inset-0 z-[3] flex flex-col items-center justify-center rounded-[inherit] border-2 border-transparent bg-white transition-all\",\n          disabled && \"bg-gray-50\",\n          dragActive &&\n            !disabled &&\n            \"cursor-copy border-black bg-gray-50 opacity-100\",\n          imageSrc\n            ? cn(\n                \"opacity-0\",\n                showHoverOverlay && !disabled && \"group-hover:opacity-100\",\n              )\n            : cn(!disabled && \"group-hover:bg-gray-50\"),\n        )}\n      >\n        <CloudUpload\n          className={cn(\n            \"size-7 transition-all duration-75\",\n            !disabled\n              ? cn(\n                  \"text-gray-500 group-hover:scale-110 group-active:scale-95\",\n                  dragActive ? \"scale-110\" : \"scale-100\",\n                )\n              : \"text-gray-400\",\n            iconClassName,\n          )}\n        />\n        {content !== null && (\n          <div\n            className={cn(\n              \"mt-2 text-center text-sm text-gray-500\",\n              disabled && \"text-gray-400\",\n            )}\n          >\n            {content ?? (\n              <>\n                <p>Drag and drop {clickToUpload && \"or click\"} to upload.</p>\n              </>\n            )}\n          </div>\n        )}\n        <span className=\"sr-only\">{accessibilityLabel}</span>\n      </div>\n      {imageSrc &&\n        (customPreview ?? (\n          <img\n            src={imageSrc}\n            alt=\"Preview\"\n            className={cn(\n              \"h-full w-full rounded-[inherit] object-cover\",\n              previewClassName,\n            )}\n          />\n        ))}\n      {clickToUpload && (\n        <div className=\"sr-only mt-1 flex shadow-sm\">\n          <input\n            key={fileName} // Gets us a fresh input every time a file is uploaded\n            type=\"file\"\n            accept={acceptFileTypes[accept].types.join(\",\")}\n            onChange={onFileChange}\n            disabled={disabled}\n          />\n        </div>\n      )}\n    </label>\n  );\n}\n"
  },
  {
    "path": "components/ui/form-hook.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport {\n  Controller,\n  ControllerProps,\n  FieldPath,\n  FieldValues,\n  FormProvider,\n  useFormContext,\n} from \"react-hook-form\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Label } from \"@/components/ui/label\";\n\nconst Form = FormProvider;\n\ntype FormFieldContextValue<\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n> = {\n  name: TName;\n};\n\nconst FormFieldContext = React.createContext<FormFieldContextValue>(\n  {} as FormFieldContextValue,\n);\n\nconst FormField = <\n  TFieldValues extends FieldValues = FieldValues,\n  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,\n>({\n  ...props\n}: ControllerProps<TFieldValues, TName>) => {\n  return (\n    <FormFieldContext.Provider value={{ name: props.name }}>\n      <Controller {...props} />\n    </FormFieldContext.Provider>\n  );\n};\n\nconst useFormField = () => {\n  const fieldContext = React.useContext(FormFieldContext);\n  const itemContext = React.useContext(FormItemContext);\n  const { getFieldState, formState } = useFormContext();\n\n  const fieldState = getFieldState(fieldContext.name, formState);\n\n  if (!fieldContext) {\n    throw new Error(\"useFormField should be used within <FormField>\");\n  }\n\n  const { id } = itemContext;\n\n  return {\n    id,\n    name: fieldContext.name,\n    formItemId: `${id}-form-item`,\n    formDescriptionId: `${id}-form-item-description`,\n    formMessageId: `${id}-form-item-message`,\n    ...fieldState,\n  };\n};\n\ntype FormItemContextValue = {\n  id: string;\n};\n\nconst FormItemContext = React.createContext<FormItemContextValue>(\n  {} as FormItemContextValue,\n);\n\nconst FormItem = React.forwardRef<\n  HTMLDivElement,\n  React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n  const id = React.useId();\n\n  return (\n    <FormItemContext.Provider value={{ id }}>\n      <div ref={ref} className={cn(\"space-y-2\", className)} {...props} />\n    </FormItemContext.Provider>\n  );\n});\nFormItem.displayName = \"FormItem\";\n\nconst FormLabel = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  const { error, formItemId } = useFormField();\n\n  return (\n    <Label\n      ref={ref}\n      className={cn(error && \"text-destructive\", className)}\n      htmlFor={formItemId}\n      {...props}\n    />\n  );\n});\nFormLabel.displayName = \"FormLabel\";\n\nconst FormControl = React.forwardRef<\n  React.ElementRef<typeof Slot>,\n  React.ComponentPropsWithoutRef<typeof Slot>\n>(({ ...props }, ref) => {\n  const { error, formItemId, formDescriptionId, formMessageId } =\n    useFormField();\n\n  return (\n    <Slot\n      ref={ref}\n      id={formItemId}\n      aria-describedby={\n        !error\n          ? `${formDescriptionId}`\n          : `${formDescriptionId} ${formMessageId}`\n      }\n      aria-invalid={!!error}\n      {...props}\n    />\n  );\n});\nFormControl.displayName = \"FormControl\";\n\nconst FormDescription = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n  const { formDescriptionId } = useFormField();\n\n  return (\n    <p\n      ref={ref}\n      id={formDescriptionId}\n      className={cn(\"text-sm text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n});\nFormDescription.displayName = \"FormDescription\";\n\nconst FormMessage = React.forwardRef<\n  HTMLParagraphElement,\n  React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n  const { error, formMessageId } = useFormField();\n  const body = error ? String(error?.message) : children;\n\n  if (!body) {\n    return null;\n  }\n\n  return (\n    <p\n      ref={ref}\n      id={formMessageId}\n      className={cn(\"text-sm font-medium text-destructive\", className)}\n      {...props}\n    >\n      {body}\n    </p>\n  );\n});\nFormMessage.displayName = \"FormMessage\";\n\nexport {\n  useFormField,\n  Form,\n  FormItem,\n  FormLabel,\n  FormControl,\n  FormDescription,\n  FormMessage,\n  FormField,\n};\n"
  },
  {
    "path": "components/ui/form.tsx",
    "content": "import {\n  InputHTMLAttributes,\n  ReactNode,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { toast } from \"sonner\";\n\nimport { cn } from \"@/lib/utils\";\nimport { validateEmail } from \"@/lib/utils/validate-email\";\n\nimport PlanBadge from \"../billing/plan-badge\";\nimport { Button } from \"./button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"./card\";\nimport { Input } from \"./input\";\nimport { Label } from \"./label\";\nimport { Switch } from \"./switch\";\n\nexport function Form({\n  title,\n  description,\n  inputAttrs,\n  helpText,\n  buttonText = \"Save Changes\",\n  disabledTooltip,\n  handleSubmit,\n  validate,\n  defaultValue,\n  plan,\n}: {\n  title: string;\n  description: string;\n  inputAttrs: InputHTMLAttributes<HTMLInputElement>;\n  helpText?: string | ReactNode;\n  buttonText?: string;\n  disabledTooltip?: string | ReactNode;\n  handleSubmit: (data: any) => Promise<any>;\n  validate?: (data: string) => boolean;\n  defaultValue?: string;\n  plan?: string;\n}) {\n  const [saving, setSaving] = useState(false);\n  const [value, setValue] = useState(defaultValue);\n\n  useEffect(() => {\n    if (defaultValue) setValue(defaultValue);\n  }, [defaultValue]);\n\n  const saveDisabled = useMemo(() => {\n    if (saving) return true;\n    if (inputAttrs.type === \"checkbox\") {\n      const currentValue = value === \"true\";\n      const defaultVal = defaultValue === \"true\";\n      return currentValue === defaultVal;\n    }\n    return !value || value === defaultValue;\n  }, [saving, value, defaultValue, inputAttrs.type]);\n\n  const renderInput = () => {\n    if (inputAttrs.type === \"checkbox\") {\n      return (\n        <div className=\"flex items-center space-x-2\">\n          <Switch\n            checked={value === \"true\"}\n            onCheckedChange={(checked) => setValue(String(checked))}\n            disabled={!!disabledTooltip}\n            id={inputAttrs.name}\n          />\n          <Label\n            htmlFor={inputAttrs.name}\n            className=\"text-sm text-muted-foreground\"\n          >\n            {inputAttrs.placeholder}\n          </Label>\n        </div>\n      );\n    }\n\n    return (\n      <Input\n        {...inputAttrs}\n        value={value}\n        type={inputAttrs.type || \"text\"}\n        required\n        disabled={!!disabledTooltip}\n        onChange={(e) => setValue(e.target.value)}\n        onBlur={(e) => setValue(e.target.value.trim())}\n        onKeyDown={(e) =>\n          inputAttrs.type === \"email\" && e.key === \" \" && e.preventDefault()\n        }\n        className={cn(\n          \"w-full max-w-md focus:border-gray-500 focus:outline-none focus:ring-gray-500\",\n          {\n            \"cursor-not-allowed bg-gray-100 text-gray-400\": disabledTooltip,\n          },\n        )}\n        data-1p-ignore\n      />\n    );\n  };\n\n  return (\n    <form\n      onSubmit={async (e) => {\n        e.preventDefault();\n        setSaving(true);\n        if (!validate || validate(value?.toString() || \"\")) {\n          await handleSubmit({\n            [inputAttrs.name as string]: value?.toString(),\n          });\n        } else {\n          toast.error(\"Please enter a valid value\");\n        }\n        setSaving(false);\n      }}\n      className=\"rounded-lg\"\n    >\n      <Card className=\"bg-transparent\">\n        <CardHeader>\n          <CardTitle className=\"flex items-center gap-2\">\n            {title} {plan && <PlanBadge plan={plan} />}\n          </CardTitle>\n          <CardDescription>{description}</CardDescription>\n        </CardHeader>\n        <CardContent>\n          {typeof defaultValue === \"string\" ? (\n            renderInput()\n          ) : (\n            <div className=\"h-[2.35rem] w-full max-w-md animate-pulse rounded-md bg-gray-200\" />\n          )}\n        </CardContent>\n        <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n          {typeof helpText === \"string\" ? (\n            <p\n              className=\"text-sm text-muted-foreground transition-colors\"\n              dangerouslySetInnerHTML={{ __html: helpText || \"\" }}\n            />\n          ) : (\n            helpText\n          )}\n          <div className=\"shrink-0\">\n            <Button loading={saving} disabled={saveDisabled}>\n              {buttonText}\n            </Button>\n          </div>\n        </CardFooter>\n      </Card>\n    </form>\n  );\n}\n"
  },
  {
    "path": "components/ui/gauge.tsx",
    "content": "export const Gauge = ({\n  value,\n  size = \"small\",\n  showValue = true,\n}: {\n  value: number;\n  size: \"xs\" | \"small\" | \"medium\" | \"large\";\n  showValue: boolean;\n}) => {\n  const circumference = 332; //2 * Math.PI * 53; // 2 * pi * radius\n  const valueInCircumference = (value / 100) * circumference;\n  const strokeDasharray = `${circumference} ${circumference}`;\n  const initialOffset = circumference;\n  const strokeDashoffset = initialOffset - valueInCircumference;\n\n  const sizes = {\n    xs: {\n      width: \"24\",\n      height: \"24\",\n      textSize: \"text-[10px]\",\n    },\n    small: {\n      width: \"36\",\n      height: \"36\",\n      textSize: \"text-xs\",\n    },\n    medium: {\n      width: \"72\",\n      height: \"72\",\n      textSize: \"text-lg\",\n    },\n    large: {\n      width: \"144\",\n      height: \"144\",\n      textSize: \"text-3xl\",\n    },\n  };\n\n  return (\n    <div className=\"relative flex flex-col items-center justify-center\">\n      <svg\n        fill=\"none\"\n        shapeRendering=\"crispEdges\"\n        height={sizes[size].height}\n        width={sizes[size].width}\n        viewBox=\"0 0 120 120\"\n        strokeWidth=\"2\"\n        className=\"-rotate-90 transform\"\n      >\n        <circle\n          className=\"text-gray-200 dark:text-gray-800\"\n          strokeWidth=\"12\"\n          stroke=\"currentColor\"\n          fill=\"transparent\"\n          shapeRendering=\"geometricPrecision\"\n          r=\"53\"\n          cx=\"60\"\n          cy=\"60\"\n        />\n        <circle\n          className=\"animate-gauge_fill text-emerald-500\"\n          strokeWidth=\"12\"\n          strokeDasharray={strokeDasharray}\n          strokeDashoffset={initialOffset}\n          shapeRendering=\"geometricPrecision\"\n          strokeLinecap=\"round\"\n          stroke=\"currentColor\"\n          fill=\"transparent\"\n          r=\"53\"\n          cx=\"60\"\n          cy=\"60\"\n          style={{\n            strokeDashoffset: strokeDashoffset,\n            transition: \"stroke-dasharray 1s ease 0s,stroke 1s ease 0s\",\n          }}\n        />\n      </svg>\n      {showValue ? (\n        <div className=\"absolute flex animate-gauge_fadeIn opacity-0\">\n          <p className={`text-foreground ${sizes[size].textSize}`}>{value}</p>\n        </div>\n      ) : null}\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/ui/hover-card.tsx",
    "content": "import * as React from \"react\"\nimport * as HoverCardPrimitive from \"@radix-ui/react-hover-card\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst HoverCard = HoverCardPrimitive.Root\n\nconst HoverCardTrigger = HoverCardPrimitive.Trigger\n\nconst HoverCardContent = React.forwardRef<\n  React.ElementRef<typeof HoverCardPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <HoverCardPrimitive.Content\n    ref={ref}\n    align={align}\n    sideOffset={sideOffset}\n    className={cn(\n      \"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]\",\n      className\n    )}\n    {...props}\n  />\n))\nHoverCardContent.displayName = HoverCardPrimitive.Content.displayName\n\nexport { HoverCard, HoverCardTrigger, HoverCardContent }\n"
  },
  {
    "path": "components/ui/input-group.tsx",
    "content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nfunction InputGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"input-group\"\n      role=\"group\"\n      className={cn(\n        \"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]\",\n        \"h-9 has-[>textarea]:h-auto\",\n\n        // Variants based on alignment.\n        \"has-[>[data-align=inline-start]]:[&>input]:pl-2\",\n        \"has-[>[data-align=inline-end]]:[&>input]:pr-2\",\n        \"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3\",\n        \"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3\",\n\n        // Focus state.\n        \"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1\",\n\n        // Error state.\n        \"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40\",\n\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nconst inputGroupAddonVariants = cva(\n  \"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4\",\n  {\n    variants: {\n      align: {\n        \"inline-start\":\n          \"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]\",\n        \"inline-end\":\n          \"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]\",\n        \"block-start\":\n          \"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5\",\n        \"block-end\":\n          \"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5\",\n      },\n    },\n    defaultVariants: {\n      align: \"inline-start\",\n    },\n  }\n)\n\nfunction InputGroupAddon({\n  className,\n  align = \"inline-start\",\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof inputGroupAddonVariants>) {\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"input-group-addon\"\n      data-align={align}\n      className={cn(inputGroupAddonVariants({ align }), className)}\n      onClick={(e) => {\n        if ((e.target as HTMLElement).closest(\"button\")) {\n          return\n        }\n        e.currentTarget.parentElement?.querySelector(\"input\")?.focus()\n      }}\n      {...props}\n    />\n  )\n}\n\nconst inputGroupButtonVariants = cva(\n  \"flex items-center gap-2 text-sm shadow-none\",\n  {\n    variants: {\n      size: {\n        xs: \"h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5\",\n        sm: \"h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5\",\n        \"icon-xs\":\n          \"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0\",\n        \"icon-sm\": \"size-8 p-0 has-[>svg]:p-0\",\n      },\n    },\n    defaultVariants: {\n      size: \"xs\",\n    },\n  }\n)\n\nfunction InputGroupButton({\n  className,\n  type = \"button\",\n  variant = \"ghost\",\n  size = \"xs\",\n  ...props\n}: Omit<React.ComponentProps<typeof Button>, \"size\"> &\n  VariantProps<typeof inputGroupButtonVariants>) {\n  return (\n    <Button\n      type={type}\n      data-size={size}\n      variant={variant}\n      className={cn(inputGroupButtonVariants({ size }), className)}\n      {...props}\n    />\n  )\n}\n\nfunction InputGroupText({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      className={cn(\n        \"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction InputGroupInput({\n  className,\n  ...props\n}: React.ComponentProps<\"input\">) {\n  return (\n    <Input\n      data-slot=\"input-group-control\"\n      className={cn(\n        \"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction InputGroupTextarea({\n  className,\n  ...props\n}: React.ComponentProps<\"textarea\">) {\n  return (\n    <Textarea\n      data-slot=\"input-group-control\"\n      className={cn(\n        \"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupButton,\n  InputGroupText,\n  InputGroupInput,\n  InputGroupTextarea,\n}\n"
  },
  {
    "path": "components/ui/input-otp.tsx",
    "content": "import * as React from \"react\";\n\nimport { OTPInput, OTPInputContext } from \"input-otp\";\nimport { Dot } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { createAdaptiveSurfacePalette } from \"@/lib/utils/create-adaptive-surface-palette\";\n\n// Create a context to pass accentColor to InputOTPSlot components\nconst AccentColorContext = React.createContext<string | null | undefined>(\n  undefined,\n);\n\nconst InputOTP = React.forwardRef<\n  React.ElementRef<typeof OTPInput>,\n  React.ComponentPropsWithoutRef<typeof OTPInput> & {\n    accentColor?: string | null;\n  }\n>(({ className, containerClassName, accentColor, ...props }, ref) => (\n  <AccentColorContext.Provider value={accentColor}>\n    <OTPInput\n      ref={ref}\n      data-1p-ignore\n      translate=\"no\"\n      containerClassName={cn(\n        \"flex items-center gap-2 has-[:disabled]:opacity-50 notranslate\",\n        containerClassName,\n      )}\n      className={cn(\"disabled:cursor-not-allowed\", className)}\n      {...props}\n    />\n  </AccentColorContext.Provider>\n));\nInputOTP.displayName = \"InputOTP\";\n\nconst InputOTPGroup = React.forwardRef<\n  React.ElementRef<\"div\">,\n  React.ComponentPropsWithoutRef<\"div\">\n>(({ className, ...props }, ref) => (\n  <div ref={ref} className={cn(\"flex items-center\", className)} {...props} />\n));\nInputOTPGroup.displayName = \"InputOTPGroup\";\n\nconst InputOTPSlot = React.forwardRef<\n  React.ElementRef<\"div\">,\n  React.ComponentPropsWithoutRef<\"div\"> & {\n    index: number;\n  }\n>(({ index, className, ...props }, ref) => {\n  const inputOTPContext = React.useContext(OTPInputContext);\n  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];\n  const accentColor = React.useContext(AccentColorContext);\n  const otpPalette = createAdaptiveSurfacePalette(accentColor);\n  const textColor = otpPalette.textColor;\n\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md\",\n        isActive && \"z-10 ring-2 ring-ring\",\n        className,\n      )}\n      style={{\n        color: textColor,\n        borderColor: textColor,\n        caretColor: textColor,\n      }}\n      {...props}\n    >\n      {char}\n      {hasFakeCaret && (\n        <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n          <div\n            className=\"h-4 w-px animate-caret-blink duration-1000\"\n            style={{ backgroundColor: textColor }}\n          />\n        </div>\n      )}\n    </div>\n  );\n});\nInputOTPSlot.displayName = \"InputOTPSlot\";\n\nconst InputOTPSeparator = React.forwardRef<\n  React.ElementRef<\"div\">,\n  React.ComponentPropsWithoutRef<\"div\">\n>(({ ...props }, ref) => (\n  <div ref={ref} role=\"separator\" {...props}>\n    <Dot />\n  </div>\n));\nInputOTPSeparator.displayName = \"InputOTPSeparator\";\n\nexport { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };\n"
  },
  {
    "path": "components/ui/input.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface InputProps\n  extends React.InputHTMLAttributes<HTMLInputElement> {}\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          // \"flex h-10 w-full rounded-md border border-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n          \"flex w-full rounded-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\",\n          // \"flex h-10 w-full rounded-md border-0 ring-1 ring-border bg-background px-3 py-2 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n          className,\n        )}\n        ref={ref}\n        {...props}\n      />\n    );\n  },\n);\nInput.displayName = \"Input\";\n\nexport { Input };\n"
  },
  {
    "path": "components/ui/label.tsx",
    "content": "import * as React from \"react\";\n\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { type VariantProps, cva } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst labelVariants = cva(\n  \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n);\n\nconst Label = React.forwardRef<\n  React.ElementRef<typeof LabelPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &\n    VariantProps<typeof labelVariants>\n>(({ className, ...props }, ref) => (\n  <LabelPrimitive.Root\n    ref={ref}\n    className={cn(labelVariants(), className)}\n    {...props}\n  />\n));\nLabel.displayName = LabelPrimitive.Root.displayName;\n\nexport { Label };\n"
  },
  {
    "path": "components/ui/loading-dots.module.css",
    "content": ".loading {\n  display: inline-flex;\n  align-items: center;\n}\n\n.loading .spacer {\n  margin-right: 2px;\n}\n\n.loading span {\n  animation-name: blink;\n  animation-duration: 1.4s;\n  animation-iteration-count: infinite;\n  animation-fill-mode: both;\n  width: 5px;\n  height: 5px;\n  border-radius: 50%;\n  display: inline-block;\n  margin: 0 1px;\n}\n\n.loading span:nth-of-type(2) {\n  animation-delay: 0.2s;\n}\n\n.loading span:nth-of-type(3) {\n  animation-delay: 0.4s;\n}\n\n@keyframes blink {\n  0% {\n    opacity: 0.2;\n  }\n  20% {\n    opacity: 1;\n  }\n  100% {\n    opacity: 0.2;\n  }\n}\n"
  },
  {
    "path": "components/ui/loading-dots.tsx",
    "content": "import styles from \"./loading-dots.module.css\";\n\nconst LoadingDots = ({ color = \"#000\" }: { color?: string }) => {\n  return (\n    <span className={styles.loading}>\n      <span style={{ backgroundColor: color }} />\n      <span style={{ backgroundColor: color }} />\n      <span style={{ backgroundColor: color }} />\n    </span>\n  );\n};\n\nexport default LoadingDots;\n"
  },
  {
    "path": "components/ui/loading-spinner.module.css",
    "content": ".spinner {\n  position: relative;\n  top: 50%;\n  left: 50%;\n}\n.spinner div {\n  animation: spinner 1.2s linear infinite;\n  background: gray;\n  position: absolute;\n  border-radius: 1rem;\n  width: 30%;\n  height: 8%;\n  left: -10%;\n  top: -4%;\n}\n.spinner div:nth-child(1) {\n  animation-delay: -1.2s;\n  transform: rotate(1deg) translate(120%);\n}\n.spinner div:nth-child(2) {\n  animation-delay: -1.1s;\n  transform: rotate(30deg) translate(120%);\n}\n.spinner div:nth-child(3) {\n  animation-delay: -1s;\n  transform: rotate(60deg) translate(120%);\n}\n.spinner div:nth-child(4) {\n  animation-delay: -0.9s;\n  transform: rotate(90deg) translate(120%);\n}\n.spinner div:nth-child(5) {\n  animation-delay: -0.8s;\n  transform: rotate(120deg) translate(120%);\n}\n.spinner div:nth-child(6) {\n  animation-delay: -0.7s;\n  transform: rotate(150deg) translate(120%);\n}\n.spinner div:nth-child(7) {\n  animation-delay: -0.6s;\n  transform: rotate(180deg) translate(120%);\n}\n.spinner div:nth-child(8) {\n  animation-delay: -0.5s;\n  transform: rotate(210deg) translate(120%);\n}\n.spinner div:nth-child(9) {\n  animation-delay: -0.4s;\n  transform: rotate(240deg) translate(120%);\n}\n.spinner div:nth-child(10) {\n  animation-delay: -0.3s;\n  transform: rotate(270deg) translate(120%);\n}\n.spinner div:nth-child(11) {\n  animation-delay: -0.2s;\n  transform: rotate(300deg) translate(120%);\n}\n.spinner div:nth-child(12) {\n  animation-delay: -0.1s;\n  transform: rotate(330deg) translate(120%);\n}\n\n@keyframes spinner {\n  0% {\n    opacity: 1;\n  }\n  100% {\n    opacity: 0;\n  }\n}\n"
  },
  {
    "path": "components/ui/loading-spinner.tsx",
    "content": "import { cn } from \"@/lib/utils\";\n\nimport styles from \"./loading-spinner.module.css\";\n\nexport default function LoadingSpinner({ className }: { className?: string }) {\n  return (\n    <div className={cn(\"h-5 w-5\", className)}>\n      <div className={cn(styles.spinner, \"h-5 w-5\", className)}>\n        {[...Array(12)].map((_, i) => (\n          <div key={i} />\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/ui/modal.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/navigation\";\n\nimport { Dispatch, SetStateAction } from \"react\";\n\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { Drawer } from \"vaul\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nexport function Modal({\n  children,\n  className,\n  showModal,\n  setShowModal,\n  onClose,\n  desktopOnly,\n  preventDefaultClose,\n  noBackdropBlur = false,\n}: {\n  children: React.ReactNode;\n  className?: string;\n  showModal?: boolean;\n  setShowModal?: Dispatch<SetStateAction<boolean>>;\n  onClose?: () => void;\n  desktopOnly?: boolean;\n  preventDefaultClose?: boolean;\n  noBackdropBlur?: boolean;\n}) {\n  const router = useRouter();\n\n  const closeModal = ({ dragged }: { dragged?: boolean } = {}) => {\n    if (preventDefaultClose && !dragged) {\n      return;\n    }\n    // fire onClose event if provided\n    onClose && onClose();\n\n    // if setShowModal is defined, use it to close modal\n    if (setShowModal) {\n      setShowModal(false);\n      // else, this is intercepting route @modal\n    } else {\n      router.back();\n    }\n  };\n  const { isMobile } = useMediaQuery();\n\n  if (isMobile && !desktopOnly) {\n    return (\n      <Drawer.Root\n        open={setShowModal ? showModal : true}\n        onOpenChange={(open) => {\n          if (!open) {\n            closeModal({ dragged: true });\n          }\n        }}\n      >\n        <Drawer.Overlay className=\"fixed inset-0 z-50 bg-background/80 backdrop-blur\" />\n        <Drawer.Portal>\n          <Drawer.Content\n            className={cn(\n              \"fixed bottom-0 left-0 right-0 z-50 mt-24 rounded-t-[10px] border-t border-gray-200 bg-background dark:border-gray-800 dark:bg-gray-900\",\n              className,\n            )}\n          >\n            <div className=\"sticky top-0 z-20 flex w-full items-center justify-center rounded-t-[10px] bg-inherit\">\n              <div className=\"my-3 h-1 w-12 rounded-full bg-gray-300\" />\n            </div>\n            {children}\n          </Drawer.Content>\n          <Drawer.Overlay />\n        </Drawer.Portal>\n      </Drawer.Root>\n    );\n  }\n\n  return (\n    <Dialog.Root\n      open={setShowModal ? showModal : true}\n      onOpenChange={(open) => {\n        if (!open) {\n          closeModal();\n        }\n      }}\n    >\n      <Dialog.Portal>\n        <Dialog.Overlay\n          // for detecting when there's an active opened modal\n          id=\"modal-backdrop\"\n          className={cn(\n            \"fixed inset-0 z-50 animate-fade-in bg-background/80\",\n            !noBackdropBlur && \"backdrop-blur-md\",\n          )} // backdrop-blur-md\n        />\n        <Dialog.Content\n          onOpenAutoFocus={(e) => e.preventDefault()}\n          onCloseAutoFocus={(e) => e.preventDefault()}\n          className={cn(\n            \"fixed inset-0 z-50 m-auto max-h-fit w-full max-w-md animate-scale-in overflow-hidden border border-gray-200 bg-background p-0 shadow-xl dark:border-gray-800 dark:bg-gray-900 sm:rounded-lg\",\n            className,\n          )}\n        >\n          {children}\n        </Dialog.Content>\n      </Dialog.Portal>\n    </Dialog.Root>\n  );\n}\n"
  },
  {
    "path": "components/ui/multi-select-v2.tsx",
    "content": "import * as React from \"react\";\n\nimport { type VariantProps, cva } from \"class-variance-authority\";\nimport { CheckIcon, PlusIcon, TagIcon, WandSparkles } from \"lucide-react\";\n\nimport { TagColorProps } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\n\nimport TagBadge from \"../links/link-sheet/tags/tag-badge\";\nimport { Icon } from \"../shared/icons\";\nimport LoadingSpinner from \"./loading-spinner\";\n\n/**\n * Variants for the multi-select component to handle different styles.\n * Uses class-variance-authority (cva) to define different styles based on \"variant\" prop.\n */\nconst multiSelectVariants = cva(\n  \"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"border-foreground/10 text-foreground bg-card hover:bg-card/80\",\n        secondary:\n          \"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        destructive:\n          \"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80\",\n        inverted: \"inverted\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\n/**\n * Props for MultiSelect component\n */\ninterface MultiSelectProps<\n  TMeta = { color: string; description: string | null },\n> extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof multiSelectVariants> {\n  /**\n   * An array of option objects to be displayed in the multi-select component.\n   * Each option object has a label, value, and an optional icon.\n   */\n  options: ComboboxOption<TMeta>[];\n  searchPlaceholder?: string;\n  inputClassName?: string;\n  createLabel?: (search: string) => React.ReactNode;\n  onCreate?: (search: string) => Promise<boolean>;\n  optionClassName?: string;\n\n  /**\n   * Callback function triggered when the selected values change.\n   * Receives an array of the new selected values.\n   */\n  onValueChange: (value: string[]) => void;\n\n  /** The default selected values when the component mounts. */\n  defaultValue?: string[];\n\n  /** update dynamic selected values when the value change. */\n  value: string[];\n\n  /**\n   * Placeholder text to be displayed when no values are selected.\n   * Optional, defaults to \"Select options\".\n   */\n  placeholder?: string;\n\n  /**\n   * Animation duration in seconds for the visual effects (e.g., bouncing badges).\n   * Optional, defaults to 0 (no animation).\n   */\n  animation?: number;\n\n  /**\n   * Maximum number of items to display. Extra selected items will be summarized.\n   * Optional, defaults to 3.\n   */\n  maxCount?: number;\n\n  /**\n   * The modality of the popover. When set to true, interaction with outside elements\n   * will be disabled and only popover content will be visible to screen readers.\n   * Optional, defaults to false.\n   */\n  modalPopover?: boolean;\n\n  /**\n   * If true, renders the multi-select component as a child of another component.\n   * Optional, defaults to false.\n   */\n  asChild?: boolean;\n\n  /**\n   * Additional class names to apply custom styles to the multi-select component.\n   * Optional, can be used to add custom styles.\n   */\n  className?: string;\n\n  /**\n   * Additional class names to apply to the popover content.\n   * Optional, can be used to customize popover width and styles.\n   */\n  popoverClassName?: string;\n\n  isPopoverOpen: boolean;\n  setIsPopoverOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  loading?: boolean;\n  triggerIcon?: React.ReactNode;\n}\n\nexport type ComboboxOption<\n  TMeta = { color: string; description: string | null },\n> = {\n  label: string | React.ReactNode;\n  value: string;\n  icon?: Icon | React.ReactNode;\n  meta?: TMeta;\n  separatorAfter?: boolean;\n};\n\nexport const MultiSelect = React.forwardRef<\n  HTMLButtonElement,\n  MultiSelectProps\n>(\n  (\n    {\n      options,\n      onValueChange,\n      variant,\n      defaultValue = [],\n      placeholder = \"Select options\",\n      animation = 0,\n      maxCount = 3,\n      modalPopover = false,\n      asChild = false,\n      className,\n      popoverClassName,\n      value,\n      searchPlaceholder = \"Search...\",\n      inputClassName,\n      optionClassName,\n      onCreate,\n      createLabel,\n      setIsPopoverOpen,\n      isPopoverOpen,\n      loading,\n      triggerIcon,\n      ...props\n    },\n    ref,\n  ) => {\n    // const [selectedValues, setSelectedValues] =\n    //   React.useState<string[]>(defaultValue);\n    const [isCreating, setIsCreating] = React.useState(false);\n    const [isAnimating, setIsAnimating] = React.useState(false);\n    const [search, setSearch] = React.useState(\"\");\n\n    const handleInputKeyDown = (\n      event: React.KeyboardEvent<HTMLInputElement>,\n    ) => {\n      if (event.key === \"Enter\") {\n        setIsPopoverOpen(true);\n      } else if (event.key === \"Backspace\" && !event.currentTarget.value) {\n        event.preventDefault();\n        event.stopPropagation();\n        const newSelectedValues = [...value];\n        newSelectedValues.pop();\n        // setSelectedValues(newSelectedValues);\n        onValueChange(newSelectedValues);\n      }\n    };\n\n    const toggleOption = (option: string) => {\n      const newSelectedValues = value.includes(option)\n        ? value.filter((value) => value !== option)\n        : [...value, option];\n      // setSelectedValues(newSelectedValues);\n      onValueChange(newSelectedValues);\n    };\n\n    const handleClear = () => {\n      // setSelectedValues([]);\n      onValueChange([]);\n    };\n\n    const handleTogglePopover = () => {\n      setIsPopoverOpen((prev) => !prev);\n    };\n\n    // const clearExtraOptions = () => {\n    //   const newSelectedValues = value.slice(0, maxCount);\n    //   // setSelectedValues(newSelectedValues);\n    //   onValueChange(newSelectedValues);\n    // };\n\n    // const toggleAll = () => {\n    //   if (value.length === options.length) {\n    //     handleClear();\n    //   } else {\n    //     const allValues = options.map((option) => option.value);\n    //     // setSelectedValues(allValues);\n    //     onValueChange(allValues);\n    //   }\n    // };\n    // flex w-full rounded-none rounded-l-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\n    return (\n      <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>\n        <PopoverTrigger asChild>\n          <Button\n            ref={ref}\n            {...props}\n            onClick={handleTogglePopover}\n            className={cn(\n              \"flex h-auto w-full items-center justify-between rounded-md border border-input bg-inherit px-3 py-1.5 hover:bg-inherit focus:border-muted-foreground focus:outline-none focus:ring-1 focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent [&_svg]:pointer-events-auto\",\n              className,\n            )}\n          >\n            {triggerIcon || (\n              <TagIcon className=\"!size-4 shrink-0 text-muted-foreground\" />\n            )}\n            {loading ? (\n              <div className=\"mx-auto flex w-full items-center justify-between\">\n                <LoadingSpinner className=\"size-4 shrink-0\" />\n              </div>\n            ) : value.length > 0 ? (\n              <div className=\"flex w-full items-center justify-between\">\n                <div className=\"flex flex-wrap items-center gap-2\">\n                  {value.slice(0, maxCount).map((value) => {\n                    const option = options.find((o) => o.value === value);\n                    return (\n                      <TagBadge\n                        key={value}\n                        name={option?.label as string}\n                        color={option?.meta?.color as TagColorProps}\n                      />\n                    );\n                  })}\n                  {value.length > maxCount && (\n                    <span\n                      className=\"my-auto block whitespace-nowrap rounded-md border px-2 py-0.5 text-sm text-foreground dark:border-gray-500\"\n                      style={{ animationDuration: `${animation}s` }}\n                    >\n                      +{value.length - maxCount} more\n                    </span>\n                  )}\n                </div>\n              </div>\n            ) : (\n              <div className=\"mx-auto flex w-full items-center justify-between\">\n                <span className=\"py-[3px] text-sm font-normal text-muted-foreground\">\n                  {placeholder}\n                </span>\n              </div>\n            )}\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent\n          className={cn(\n            \"w-auto p-0 sm:w-[600px] sm:max-w-[35rem]\",\n            popoverClassName,\n          )}\n          align=\"start\"\n          onEscapeKeyDown={() => setIsPopoverOpen(false)}\n        >\n          <Command>\n            <CommandInput\n              placeholder={searchPlaceholder}\n              value={search}\n              onValueChange={setSearch}\n              noIcon\n              wrapperClassName=\"px-0\"\n              className={cn(\n                \"grow border-0 py-3 outline-none placeholder:text-neutral-400 focus:ring-0 sm:text-sm\",\n                inputClassName,\n              )}\n              onKeyDown={handleInputKeyDown}\n            />\n            <ScrollArea>\n              <CommandList>\n                <CommandGroup>\n                  {loading ? (\n                    <CommandItem className=\"justify-center\">\n                      <LoadingSpinner className=\"size-4 shrink-0\" />\n                    </CommandItem>\n                  ) : options.length > 0 ? (\n                    options.map((option) => {\n                      const isSelected = value.includes(option.value);\n                      const IconComponent = option.icon;\n                      return (\n                        <CommandItem\n                          key={option.value}\n                          onSelect={() => toggleOption(option.value)}\n                          className=\"cursor-pointer gap-2 py-2\"\n                          data-Value={option.value}\n                        >\n                          <div\n                            className={cn(\n                              \"flex h-4 w-4 items-center justify-center rounded-sm border border-primary\",\n                              isSelected\n                                ? \"bg-primary text-primary-foreground\"\n                                : \"opacity-50 [&_svg]:invisible\",\n                            )}\n                          >\n                            <CheckIcon className=\"h-4 w-4\" />\n                          </div>\n                          {IconComponent && isReactNode(IconComponent) ? (\n                            IconComponent\n                          ) : typeof IconComponent === \"function\" ? (\n                            <IconComponent className=\"h-5 w-4\" />\n                          ) : null}\n                          <span>{option.label}</span>\n                        </CommandItem>\n                      );\n                    })\n                  ) : (\n                    <CommandItem className=\"justify-center\">\n                      <span>No tags available</span>\n                    </CommandItem>\n                  )}\n                </CommandGroup>\n                {search.length > 0 && onCreate && (\n                  <CommandGroup>\n                    <CommandItem\n                      className={cn(\n                        \"flex cursor-pointer items-center gap-2 whitespace-nowrap\",\n                        optionClassName,\n                      )}\n                      onSelect={async () => {\n                        setIsCreating(true);\n                        const success = await onCreate?.(search);\n                        if (success) {\n                          setSearch(\"\");\n                          setIsPopoverOpen(false);\n                        }\n                        setIsCreating(false);\n                      }}\n                    >\n                      {isCreating ? (\n                        <LoadingSpinner className=\"size-4 shrink-0\" />\n                      ) : (\n                        <PlusIcon className=\"size-4 shrink-0\" />\n                      )}\n                      <p className=\"grow truncate\">\n                        {createLabel?.(search) || `Create \"${search}\"`}\n                      </p>\n                    </CommandItem>\n                  </CommandGroup>\n                )}\n              </CommandList>\n            </ScrollArea>\n          </Command>\n        </PopoverContent>\n        {animation > 0 && value.length > 0 && (\n          <WandSparkles\n            className={cn(\n              \"my-2 h-3 w-3 cursor-pointer bg-background text-foreground\",\n              isAnimating ? \"\" : \"text-muted-foreground\",\n            )}\n            onClick={() => setIsAnimating(!isAnimating)}\n          />\n        )}\n      </Popover>\n    );\n  },\n);\n\nMultiSelect.displayName = \"MultiSelect\";\n\nconst isReactNode = (element: any): element is React.ReactNode =>\n  React.isValidElement(element);\n"
  },
  {
    "path": "components/ui/nextra-filetree.tsx",
    "content": "\"use client\";\n\n/**\n * This component is based on the nextra's filetree component from @shuding\n * https://github.com/shuding/nextra/blob/main/packages/nextra/src/components/file-tree.tsx\n *\n */\nimport React, {\n  CSSProperties,\n  createContext,\n  memo,\n  useCallback,\n  useContext,\n  useEffect,\n  useState,\n} from \"react\";\nimport type { ReactElement, ReactNode } from \"react\";\n\nimport {\n  ChevronRightIcon,\n  FileIcon,\n  FolderIcon,\n  FolderOpenIcon,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst ctx = createContext(0);\nconst prefersLightTextCtx = createContext(false);\n\nfunction useIndent() {\n  return useContext(ctx);\n}\n\nexport function usePrefersLightText() {\n  return useContext(prefersLightTextCtx);\n}\n\ninterface FolderProps {\n  name: string;\n  label?: ReactElement;\n  open?: boolean;\n  defaultOpen?: boolean;\n  active?: boolean;\n  childActive?: boolean;\n  onToggle?: (open: boolean) => void;\n  className?: string;\n  children: ReactNode;\n  disable?: boolean;\n}\n\ninterface FileProps {\n  name: string;\n  label?: ReactElement;\n  active?: boolean;\n  onToggle?: (active: boolean) => void;\n}\n\nfunction Tree({\n  children,\n  prefersLightText,\n  style,\n}: {\n  children: ReactNode;\n  prefersLightText?: boolean;\n  style?: CSSProperties;\n}): ReactElement {\n  return (\n    <prefersLightTextCtx.Provider value={prefersLightText ?? false}>\n      <div\n        className={cn(\"nextra-filetree !mt-0 w-full select-none text-sm\")}\n        style={style}\n      >\n        <div className=\"block space-y-1 rounded-lg\">{children}</div>\n      </div>\n    </prefersLightTextCtx.Provider>\n  );\n}\n\nfunction Ident(): ReactElement {\n  const length = useIndent();\n  return (\n    <>\n      {Array.from({ length }, (_, i) => (\n        <span className=\"w-5\" key={i} />\n      ))}\n    </>\n  );\n}\n\nconst Folder = memo<FolderProps>(\n  ({\n    label,\n    name,\n    open,\n    children,\n    active,\n    childActive,\n    defaultOpen = false,\n    onToggle,\n    className,\n    disable,\n  }) => {\n    const indent = useIndent();\n    const prefersLightText = usePrefersLightText();\n    const [isOpen, setIsOpen] = useState(defaultOpen || childActive);\n\n    useEffect(() => {\n      if (childActive) {\n        setIsOpen(true);\n      }\n    }, [childActive]);\n\n    const handleFolderClick = useCallback(\n      (e: React.MouseEvent) => {\n        e.stopPropagation();\n        onToggle?.(!isOpen);\n      },\n      [isOpen, onToggle],\n    );\n\n    const handleChevronClick = useCallback(\n      (e: React.MouseEvent) => {\n        e.stopPropagation();\n        setIsOpen(!isOpen);\n      },\n      [isOpen],\n    );\n\n    const isFolderOpen = open === undefined ? isOpen : open;\n    const hasChildren = React.Children.count(children) > 0;\n\n    return (\n      <li\n        className={cn(\n          \"flex w-full list-none flex-col\",\n          hasChildren && \"space-y-1\",\n        )}\n      >\n        <div\n          title={name}\n          className={cn(\n            \"inline-flex w-full cursor-pointer items-center overflow-hidden\",\n            \"rounded-md duration-100\",\n            prefersLightText\n              ? \"text-[var(--viewer-text)] hover:bg-[var(--viewer-control-bg)]\"\n              : \"text-foreground hover:bg-gray-100 hover:dark:bg-muted\",\n            \"px-3 py-1.5 leading-6\",\n            active &&\n              (prefersLightText\n                ? \"bg-[var(--viewer-panel-active)] font-semibold\"\n                : \"bg-gray-100 font-semibold dark:bg-muted\"),\n            disable && \"pointer-events-none cursor-auto opacity-50\",\n            className,\n          )}\n          onClick={handleFolderClick}\n        >\n          <Ident />\n          <div\n            className=\"-m-1 -ml-2 flex h-full items-center justify-center rounded p-2\"\n            onClick={handleChevronClick}\n          >\n            <ChevronRightIcon\n              className={cn(\n                \"chevron h-4 w-4 shrink-0 transition-transform duration-150\",\n                isFolderOpen && \"rotate-90\",\n              )}\n            />\n          </div>\n          {isFolderOpen ? (\n            <FolderOpenIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n          ) : (\n            <FolderIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n          )}\n          <span\n            className=\"ml-2 truncate whitespace-nowrap\"\n            style={{\n              maxWidth: `${Math.max(150, 300 - indent * 30)}px`,\n            }}\n            title={(label ?? name) as string}\n          >\n            {label ?? name}\n          </span>\n        </div>\n        {isFolderOpen && (\n          <ul>\n            <ctx.Provider value={indent + 1}>{children}</ctx.Provider>\n          </ul>\n        )}\n      </li>\n    );\n  },\n);\nFolder.displayName = \"Folder\";\n\nconst File = memo<FileProps>(({ label, name, active, onToggle }) => {\n  const indent = useIndent();\n  const prefersLightText = usePrefersLightText();\n  const toggle = useCallback(() => {\n    onToggle?.(!active);\n  }, [active, onToggle]);\n\n  return (\n    <li\n      className={cn(\n        \"flex list-none\",\n        \"rounded-md duration-100\",\n        prefersLightText\n          ? \"text-[var(--viewer-muted-text)] hover:bg-[var(--viewer-control-bg)]\"\n          : \"text-foreground hover:bg-gray-100 hover:dark:bg-muted\",\n        \"px-3 py-1.5 leading-6\",\n        active &&\n          (prefersLightText\n            ? \"bg-[var(--viewer-panel-active)] text-[var(--viewer-text)] font-semibold\"\n            : \"bg-gray-100 font-semibold dark:bg-muted\"),\n      )}\n    >\n      <span\n        className=\"ml-5 inline-flex w-full cursor-default items-center overflow-hidden\"\n        onClick={toggle}\n      >\n        <Ident />\n        <FileIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n        <span\n          className=\"ml-2 truncate whitespace-nowrap\"\n          style={{\n            maxWidth: `${Math.max(150, 280 - indent * 30)}px`,\n          }}\n          title={(label ?? name) as string}\n        >\n          {label ?? name}\n        </span>\n      </span>\n    </li>\n  );\n});\nFile.displayName = \"File\";\n\nexport const FileTree = Object.assign(Tree, { Folder, File });\n"
  },
  {
    "path": "components/ui/pagination.tsx",
    "content": "import * as React from \"react\";\n\nimport { ChevronLeft, ChevronRight, MoreHorizontal } from \"lucide-react\";\n\nimport { ButtonProps, buttonVariants } from \"@/components/ui/button\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Pagination = ({ className, ...props }: React.ComponentProps<\"nav\">) => (\n  <nav\n    role=\"navigation\"\n    aria-label=\"pagination\"\n    className={cn(\"mx-auto flex w-full justify-center\", className)}\n    {...props}\n  />\n);\nPagination.displayName = \"Pagination\";\n\nconst PaginationContent = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    className={cn(\"flex flex-row items-center gap-1\", className)}\n    {...props}\n  />\n));\nPaginationContent.displayName = \"PaginationContent\";\n\nconst PaginationItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<\"li\">\n>(({ className, ...props }, ref) => (\n  <li ref={ref} className={cn(\"\", className)} {...props} />\n));\nPaginationItem.displayName = \"PaginationItem\";\n\ntype PaginationLinkProps = {\n  isActive?: boolean;\n  disabled?: boolean;\n} & Pick<ButtonProps, \"size\"> &\n  React.ComponentProps<\"a\">;\n\nconst PaginationLink = ({\n  className,\n  isActive,\n  size = \"icon\",\n  ...props\n}: PaginationLinkProps) => (\n  <a\n    aria-current={isActive ? \"page\" : undefined}\n    className={cn(\n      buttonVariants({\n        variant: isActive ? \"outline\" : \"ghost\",\n        size,\n      }),\n      className,\n    )}\n    {...props}\n  />\n);\nPaginationLink.displayName = \"PaginationLink\";\n\nconst PaginationPrevious = ({\n  className,\n  disabled,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) => (\n  <PaginationLink\n    aria-label=\"Go to previous page\"\n    size=\"default\"\n    className={cn(\n      \"gap-1 pl-2.5\",\n      disabled ? \"pointer-events-none opacity-50\" : \"\",\n      className,\n    )}\n    {...props}\n  >\n    <ChevronLeft className=\"h-4 w-4\" />\n    <span>Previous</span>\n  </PaginationLink>\n);\nPaginationPrevious.displayName = \"PaginationPrevious\";\n\nconst PaginationNext = ({\n  className,\n  disabled,\n  ...props\n}: React.ComponentProps<typeof PaginationLink>) => (\n  <PaginationLink\n    aria-label=\"Go to next page\"\n    size=\"default\"\n    className={cn(\n      \"gap-1 pr-2.5\",\n      disabled ? \"pointer-events-none opacity-50\" : \"\",\n      className,\n    )}\n    {...props}\n  >\n    <span>Next</span>\n    <ChevronRight className=\"h-4 w-4\" />\n  </PaginationLink>\n);\nPaginationNext.displayName = \"PaginationNext\";\n\nconst PaginationEllipsis = ({\n  className,\n  ...props\n}: React.ComponentProps<\"span\">) => (\n  <span\n    aria-hidden\n    className={cn(\"flex h-9 w-9 items-center justify-center\", className)}\n    {...props}\n  >\n    <MoreHorizontal className=\"h-4 w-4\" />\n    <span className=\"sr-only\">More pages</span>\n  </span>\n);\nPaginationEllipsis.displayName = \"PaginationEllipsis\";\n\nexport {\n  Pagination,\n  PaginationContent,\n  PaginationEllipsis,\n  PaginationItem,\n  PaginationLink,\n  PaginationNext,\n  PaginationPrevious,\n};\n"
  },
  {
    "path": "components/ui/phone-input.tsx",
    "content": "import * as React from \"react\";\n\nimport { E164Number } from \"libphonenumber-js\";\nimport { ChevronDownIcon, PhoneIcon } from \"lucide-react\";\nimport * as RPNInput from \"react-phone-number-input\";\nimport flags from \"react-phone-number-input/flags\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Input, InputProps } from \"@/components/ui/input\";\n\ntype PhoneInputProps = Omit<\n  React.InputHTMLAttributes<HTMLInputElement>,\n  \"onChange\" | \"value\"\n> &\n  Omit<RPNInput.Props<typeof RPNInput.default>, \"onChange\"> & {\n    onChange?: (value: RPNInput.Value) => void;\n  };\n\nconst PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =\n  React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(\n    ({ className, onChange, ...props }, ref) => {\n      return (\n        <RPNInput.default\n          ref={ref}\n          className={cn(\"shadow-xs flex rounded-md\", className)}\n          international\n          flagComponent={FlagComponent}\n          countrySelectComponent={CountrySelect}\n          inputComponent={InputComponent}\n          /**\n           * Handles the onChange event.\n           *\n           * react-phone-number-input might trigger the onChange event as undefined\n           * when a valid phone number is not entered. To prevent this,\n           * the value is coerced to an empty string.\n           *\n           * @param {E164Number | undefined} value - The entered value\n           */\n          onChange={(value) => onChange?.((value || \"\") as E164Number)}\n          {...props}\n        />\n      );\n    },\n  );\nPhoneInput.displayName = \"PhoneInput\";\n\nconst InputComponent = React.forwardRef<HTMLInputElement, InputProps>(\n  ({ className, ...props }, ref) => (\n    <Input\n      data-slot=\"phone-input\"\n      className={cn(\n        \"-ms-px rounded-s-none border-0 bg-transparent py-1.5 shadow-sm ring-1 ring-inset ring-gray-600 focus:ring-2 focus:ring-inset focus:ring-gray-300 focus-visible:z-10\",\n        className,\n      )}\n      style={\n        {\n          backgroundColor: \"var(--phone-input-bg)\",\n          color: \"var(--phone-input-color)\",\n        } as React.CSSProperties\n      }\n      {...props}\n      ref={ref}\n    />\n  ),\n);\nInputComponent.displayName = \"InputComponent\";\n\ntype CountrySelectProps = {\n  disabled?: boolean;\n  value: RPNInput.Country;\n  onChange: (value: RPNInput.Country) => void;\n  options: { label: string; value: RPNInput.Country | undefined }[];\n};\n\nconst CountrySelect = ({\n  disabled,\n  value,\n  onChange,\n  options,\n}: CountrySelectProps) => {\n  const handleSelect = (event: React.ChangeEvent<HTMLSelectElement>) => {\n    onChange(event.target.value as RPNInput.Country);\n  };\n\n  return (\n    <div\n      className=\"has-aria-invalid:border-destructive/60 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-disabled:pointer-events-none has-disabled:opacity-50 relative inline-flex h-9 items-center self-stretch rounded-s-md border-0 bg-transparent py-2 pe-2 ps-3 text-muted-foreground shadow-sm outline-none ring-1 ring-inset ring-gray-600 transition-[color,box-shadow] focus-within:z-10 focus-within:ring-2 focus-within:ring-inset focus-within:ring-gray-300 hover:text-foreground\"\n      style={\n        {\n          backgroundColor: \"var(--phone-input-bg)\",\n          color: \"var(--phone-input-color)\",\n        } as React.CSSProperties\n      }\n    >\n      <div className=\"inline-flex items-center gap-1\" aria-hidden=\"true\">\n        <FlagComponent country={value} countryName={value} aria-hidden=\"true\" />\n        <span className=\"text-muted-foreground/80\">\n          <ChevronDownIcon size={16} aria-hidden=\"true\" />\n        </span>\n      </div>\n      <select\n        disabled={disabled}\n        value={value}\n        onChange={handleSelect}\n        className=\"absolute inset-0 text-sm opacity-0\"\n        aria-label=\"Select country\"\n      >\n        <option key=\"default\" value=\"\">\n          Select a country\n        </option>\n        {options\n          .filter((x) => x.value)\n          .map((option, i) => (\n            <option key={option.value ?? `empty-${i}`} value={option.value}>\n              {option.label}{\" \"}\n              {option.value &&\n                `+${RPNInput.getCountryCallingCode(option.value)}`}\n            </option>\n          ))}\n      </select>\n    </div>\n  );\n};\n\nconst FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {\n  const Flag = flags[country];\n\n  return (\n    <span className=\"w-5 overflow-hidden rounded-sm\">\n      {Flag ? (\n        <Flag title={countryName} />\n      ) : (\n        <PhoneIcon size={16} aria-hidden=\"true\" />\n      )}\n    </span>\n  );\n};\nFlagComponent.displayName = \"FlagComponent\";\n\nexport { PhoneInput };\n"
  },
  {
    "path": "components/ui/popover.tsx",
    "content": "import * as React from \"react\";\n\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Popover = PopoverPrimitive.Root;\n\nconst PopoverTrigger = PopoverPrimitive.Trigger;\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n));\nPopoverContent.displayName = PopoverPrimitive.Content.displayName;\n\nexport { Popover, PopoverTrigger, PopoverContent };\n"
  },
  {
    "path": "components/ui/portal.tsx",
    "content": "import * as React from \"react\";\n\nimport * as PortalPrimitive from \"@radix-ui/react-portal\";\n\nconst Portal = ({\n  containerId,\n  className,\n  children,\n}: {\n  containerId?: string | null;\n  children: React.ReactElement;\n  className?: string;\n}) => {\n  const [mounted, setMounted] = React.useState(false);\n  React.useEffect(() => setMounted(true), []);\n\n  return (\n    <PortalPrimitive.Root\n      className={className}\n      container={\n        containerId && mounted\n          ? document.getElementById(containerId)\n          : undefined\n      }\n    >\n      {children}\n    </PortalPrimitive.Root>\n  );\n};\nPortal.displayName = PortalPrimitive.Root.displayName;\n\nexport { Portal };\n"
  },
  {
    "path": "components/ui/progress.tsx",
    "content": "import * as React from \"react\";\n\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\";\nimport { HelpCircleIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Progress = React.forwardRef<\n  React.ElementRef<typeof ProgressPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {\n    text?: string;\n    error?: boolean;\n  }\n>(({ className, value, text, error, ...props }, ref) => {\n  const textColor = `linear-gradient(to right, \n  text-background ${value || 0}%, \n  text-foreground ${value || 0}%\n)`;\n\n  return (\n    <ProgressPrimitive.Root\n      ref={ref}\n      className={cn(\n        \"relative h-4 w-full overflow-hidden rounded-full bg-secondary\",\n        className,\n      )}\n      {...props}\n    >\n      <ProgressPrimitive.Indicator\n        className=\"h-full w-full flex-1 bg-primary transition-all\"\n        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n      />\n      {text && !error ? (\n        <div className=\"absolute inset-0 flex items-center justify-center py-2\">\n          <div className=\"absolute inset-0 flex items-center justify-center overflow-hidden\">\n            <span className=\"text-xs text-foreground\">{text}</span>\n          </div>\n          <div\n            className=\"absolute inset-0 flex items-center justify-center\"\n            style={{ clipPath: `inset(0 ${100 - (value || 0)}% 0 0)` }}\n          >\n            <span className=\"text-xs text-background\">{text}</span>\n          </div>\n        </div>\n      ) : null}\n      {text && error ? (\n        <div className=\"absolute inset-0 flex items-center justify-center py-2\">\n          <div className=\"absolute inset-0 flex items-center justify-center gap-x-2 overflow-hidden bg-destructive text-destructive-foreground\">\n            <span className=\"text-xs\">{text}</span>\n            <a href=\"mailto:support@papermark.com\" title=\"Contact Support\">\n              <HelpCircleIcon className=\"size-4\" />\n            </a>\n          </div>\n        </div>\n      ) : null}\n    </ProgressPrimitive.Root>\n  );\n});\nProgress.displayName = ProgressPrimitive.Root.displayName;\n\nexport { Progress };\n"
  },
  {
    "path": "components/ui/radio-group.tsx",
    "content": "import * as React from \"react\"\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\"\nimport { Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst RadioGroup = React.forwardRef<\n  React.ElementRef<typeof RadioGroupPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>\n>(({ className, ...props }, ref) => {\n  return (\n    <RadioGroupPrimitive.Root\n      className={cn(\"grid gap-2\", className)}\n      {...props}\n      ref={ref}\n    />\n  )\n})\nRadioGroup.displayName = RadioGroupPrimitive.Root.displayName\n\nconst RadioGroupItem = React.forwardRef<\n  React.ElementRef<typeof RadioGroupPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>\n>(({ className, ...props }, ref) => {\n  return (\n    <RadioGroupPrimitive.Item\n      ref={ref}\n      className={cn(\n        \"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <RadioGroupPrimitive.Indicator className=\"flex items-center justify-center\">\n        <Circle className=\"h-2.5 w-2.5 fill-current text-current\" />\n      </RadioGroupPrimitive.Indicator>\n    </RadioGroupPrimitive.Item>\n  )\n})\nRadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName\n\nexport { RadioGroup, RadioGroupItem }\n"
  },
  {
    "path": "components/ui/resizable.tsx",
    "content": "\"use client\";\n\nimport { GripVertical } from \"lucide-react\";\nimport * as ResizablePrimitive from \"react-resizable-panels\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst ResizablePanelGroup = ({\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (\n  <ResizablePrimitive.PanelGroup\n    className={cn(\n      \"flex h-full w-full data-[panel-group-direction=vertical]:flex-col\",\n      className,\n    )}\n    {...props}\n  />\n);\n\nconst ResizablePanel = ResizablePrimitive.Panel;\n\nconst ResizableHandle = ({\n  withHandle,\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {\n  withHandle?: boolean;\n}) => (\n  <ResizablePrimitive.PanelResizeHandle\n    className={cn(\n      \"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90\",\n      className,\n    )}\n    {...props}\n  >\n    {withHandle && (\n      <div className=\"z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border\">\n        <GripVertical className=\"h-2.5 w-2.5\" />\n      </div>\n    )}\n  </ResizablePrimitive.PanelResizeHandle>\n);\n\nexport { ResizablePanelGroup, ResizablePanel, ResizableHandle };\n"
  },
  {
    "path": "components/ui/responsive-button.tsx",
    "content": "import React, { cloneElement } from \"react\";\n\nimport { useBreakpoint } from \"@/lib/hooks/use-breakpoint\";\n\nimport { Button, ButtonProps } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\ninterface ResponsiveButtonProps extends ButtonProps {\n  icon: React.ReactElement;\n  text: string;\n  breakpoint?: number;\n}\n\nexport const ResponsiveButton = React.forwardRef<\n  HTMLButtonElement,\n  ResponsiveButtonProps\n>(({ icon, text, breakpoint = 1024, ...props }, ref) => {\n  const isSmaller = useBreakpoint(breakpoint);\n\n  if (isSmaller) {\n    return (\n      <TooltipProvider>\n        <Tooltip>\n          <TooltipTrigger asChild>\n            <Button ref={ref} {...props}>\n              {icon}\n              <span className=\"sr-only\">{text}</span>\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>\n            <p>{text}</p>\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    );\n  }\n\n  const iconWithAriaHidden = cloneElement(icon, {\n    \"aria-hidden\": \"true\",\n  });\n\n  return (\n    <Button ref={ref} {...props}>\n      {iconWithAriaHidden}\n      {text}\n    </Button>\n  );\n});\n\nResponsiveButton.displayName = \"ResponsiveButton\";\n"
  },
  {
    "path": "components/ui/rich-text-editor.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useMemo, useState } from \"react\";\n\nimport Image from \"@tiptap/extension-image\";\nimport Placeholder from \"@tiptap/extension-placeholder\";\nimport Youtube from \"@tiptap/extension-youtube\";\nimport { EditorContent, useEditor } from \"@tiptap/react\";\nimport StarterKit from \"@tiptap/starter-kit\";\nimport {\n  Bold,\n  Heading1,\n  Heading2,\n  ImageIcon,\n  Italic,\n  List,\n  ListOrdered,\n  Quote,\n  Redo,\n  Undo,\n  Youtube as YoutubeIcon,\n} from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\n/**\n * Validates if a given URL is a valid YouTube URL.\n * Supports:\n * - youtube.com/watch?v=VIDEO_ID\n * - youtu.be/VIDEO_ID\n * - youtube.com/embed/VIDEO_ID\n * - www.youtube.com variants\n * - youtube-nocookie.com variants\n */\nfunction isValidYouTubeUrl(url: string): boolean {\n  if (!url || typeof url !== \"string\") return false;\n\n  try {\n    const parsedUrl = new URL(url.trim());\n    const hostname = parsedUrl.hostname.toLowerCase();\n\n    // Check for valid YouTube hostnames\n    const validHostnames = [\n      \"youtube.com\",\n      \"www.youtube.com\",\n      \"youtu.be\",\n      \"www.youtu.be\",\n      \"youtube-nocookie.com\",\n      \"www.youtube-nocookie.com\",\n    ];\n\n    if (!validHostnames.includes(hostname)) return false;\n\n    // For youtu.be short URLs, the video ID is in the pathname\n    if (hostname === \"youtu.be\" || hostname === \"www.youtu.be\") {\n      const videoId = parsedUrl.pathname.slice(1); // Remove leading slash\n      return videoId.length > 0 && /^[\\w-]+$/.test(videoId);\n    }\n\n    // For youtube.com/watch URLs, check for 'v' parameter\n    if (parsedUrl.pathname === \"/watch\") {\n      const videoId = parsedUrl.searchParams.get(\"v\");\n      return videoId !== null && videoId.length > 0 && /^[\\w-]+$/.test(videoId);\n    }\n\n    // For youtube.com/embed/VIDEO_ID URLs\n    if (parsedUrl.pathname.startsWith(\"/embed/\")) {\n      const videoId = parsedUrl.pathname.replace(\"/embed/\", \"\").split(\"/\")[0];\n      return videoId.length > 0 && /^[\\w-]+$/.test(videoId);\n    }\n\n    // For youtube.com/v/VIDEO_ID URLs (legacy)\n    if (parsedUrl.pathname.startsWith(\"/v/\")) {\n      const videoId = parsedUrl.pathname.replace(\"/v/\", \"\").split(\"/\")[0];\n      return videoId.length > 0 && /^[\\w-]+$/.test(videoId);\n    }\n\n    return false;\n  } catch {\n    // URL parsing failed\n    return false;\n  }\n}\n\ninterface RichTextEditorProps {\n  content?: any;\n  onChange?: (content: any) => void;\n  placeholder?: string;\n  onImageUpload?: (file: File) => Promise<string>;\n}\n\nexport function RichTextEditor({\n  content,\n  onChange,\n  placeholder = \"Start typing...\",\n  onImageUpload,\n}: RichTextEditorProps) {\n  const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false);\n  const [youtubeUrl, setYoutubeUrl] = useState(\"\");\n  const [youtubeUrlError, setYoutubeUrlError] = useState<string | null>(null);\n\n  // Memoize URL validation to avoid recalculating on every render\n  const isYoutubeUrlValid = useMemo(\n    () => isValidYouTubeUrl(youtubeUrl),\n    [youtubeUrl],\n  );\n\n  const editor = useEditor({\n    extensions: [\n      StarterKit,\n      Image.configure({\n        inline: true,\n        allowBase64: true,\n        HTMLAttributes: {\n          class: \"rounded-lg max-w-full h-auto\",\n        },\n      }),\n      Youtube.configure({\n        controls: true,\n        nocookie: true,\n        HTMLAttributes: {\n          class: \"rounded-lg w-full aspect-video\",\n        },\n      }),\n      Placeholder.configure({\n        placeholder,\n      }),\n    ],\n    content,\n    immediatelyRender: false, // Fix SSR hydration issues\n    onUpdate: ({ editor }) => {\n      onChange?.(editor.getJSON());\n    },\n    editorProps: {\n      handleDrop: (view, event, slice, moved) => {\n        if (\n          !moved &&\n          event.dataTransfer &&\n          event.dataTransfer.files &&\n          event.dataTransfer.files[0]\n        ) {\n          const file = event.dataTransfer.files[0];\n\n          // Check if it's an image file\n          if (file.type.startsWith(\"image/\") && onImageUpload) {\n            event.preventDefault();\n\n            // Upload the image\n            onImageUpload(file)\n              .then((url) => {\n                const { state } = view;\n                const { selection } = state;\n                const position = selection.empty\n                  ? selection.from\n                  : selection.to;\n\n                const node = state.schema.nodes.image.create({ src: url });\n                const transaction = state.tr.insert(position, node);\n                view.dispatch(transaction);\n              })\n              .catch((error) => {\n                console.error(\"Failed to upload dropped image:\", error);\n              });\n\n            return true;\n          }\n        }\n        return false;\n      },\n      handlePaste: (view, event, slice) => {\n        const items = event.clipboardData?.items;\n        if (items && onImageUpload) {\n          for (let i = 0; i < items.length; i++) {\n            const item = items[i];\n            if (item.type.startsWith(\"image/\")) {\n              event.preventDefault();\n\n              const file = item.getAsFile();\n              if (file) {\n                onImageUpload(file)\n                  .then((url) => {\n                    const { state } = view;\n                    const { selection } = state;\n                    const position = selection.empty\n                      ? selection.from\n                      : selection.to;\n\n                    const node = state.schema.nodes.image.create({ src: url });\n                    const transaction = state.tr.insert(position, node);\n                    view.dispatch(transaction);\n                  })\n                  .catch((error) => {\n                    console.error(\"Failed to upload pasted image:\", error);\n                  });\n              }\n\n              return true;\n            }\n          }\n        }\n        return false;\n      },\n    },\n  });\n\n  const addImage = useCallback(\n    async (e?: React.MouseEvent) => {\n      if (e) {\n        e.preventDefault();\n        e.stopPropagation();\n      }\n\n      if (!editor || !onImageUpload) return;\n\n      const input = document.createElement(\"input\");\n      input.type = \"file\";\n      input.accept = \"image/*\";\n      input.onchange = async (e) => {\n        const file = (e.target as HTMLInputElement).files?.[0];\n        if (file) {\n          try {\n            const url = await onImageUpload(file);\n            editor.chain().focus().setImage({ src: url }).run();\n          } catch (error) {\n            console.error(\"Failed to upload image:\", error);\n          }\n        }\n      };\n      input.click();\n    },\n    [editor, onImageUpload],\n  );\n\n  const addYoutubeVideo = useCallback(() => {\n    if (!editor || !youtubeUrl) return;\n\n    const trimmed = youtubeUrl.trim();\n\n    // Re-validate URL before inserting\n    if (!isValidYouTubeUrl(trimmed)) {\n      setYoutubeUrlError(\n        \"Please enter a valid YouTube URL (e.g., youtube.com/watch?v=..., youtu.be/...)\",\n      );\n      return;\n    }\n\n    editor.commands.setYoutubeVideo({\n      src: trimmed,\n    });\n\n    setYoutubeUrl(\"\");\n    setYoutubeUrlError(null);\n    setYoutubeDialogOpen(false);\n  }, [editor, youtubeUrl]);\n\n  // Clear error and URL when dialog closes\n  const handleYoutubeDialogChange = useCallback((open: boolean) => {\n    setYoutubeDialogOpen(open);\n    if (!open) {\n      setYoutubeUrl(\"\");\n      setYoutubeUrlError(null);\n    }\n  }, []);\n\n  if (!editor) {\n    return null;\n  }\n\n  return (\n    <div className=\"rounded-md border border-input\">\n      {/* Toolbar */}\n      <div className=\"flex flex-wrap gap-1 border-b border-input p-2\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() =>\n            editor.chain().focus().toggleHeading({ level: 1 }).run()\n          }\n          className={editor.isActive(\"heading\", { level: 1 }) ? \"bg-muted\" : \"\"}\n        >\n          <Heading1 className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() =>\n            editor.chain().focus().toggleHeading({ level: 2 }).run()\n          }\n          className={editor.isActive(\"heading\", { level: 2 }) ? \"bg-muted\" : \"\"}\n        >\n          <Heading2 className=\"h-4 w-4\" />\n        </Button>\n        <div className=\"mx-1 h-6 w-px bg-border\" />\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => editor.chain().focus().toggleBold().run()}\n          className={editor.isActive(\"bold\") ? \"bg-muted\" : \"\"}\n        >\n          <Bold className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => editor.chain().focus().toggleItalic().run()}\n          className={editor.isActive(\"italic\") ? \"bg-muted\" : \"\"}\n        >\n          <Italic className=\"h-4 w-4\" />\n        </Button>\n        <div className=\"mx-1 h-6 w-px bg-border\" />\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => editor.chain().focus().toggleBulletList().run()}\n          className={editor.isActive(\"bulletList\") ? \"bg-muted\" : \"\"}\n        >\n          <List className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => editor.chain().focus().toggleOrderedList().run()}\n          className={editor.isActive(\"orderedList\") ? \"bg-muted\" : \"\"}\n        >\n          <ListOrdered className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => editor.chain().focus().toggleBlockquote().run()}\n          className={editor.isActive(\"blockquote\") ? \"bg-muted\" : \"\"}\n        >\n          <Quote className=\"h-4 w-4\" />\n        </Button>\n        {onImageUpload && (\n          <>\n            <div className=\"mx-1 h-6 w-px bg-border\" />\n            <Button variant=\"ghost\" size=\"sm\" onClick={addImage} type=\"button\">\n              <ImageIcon className=\"h-4 w-4\" />\n            </Button>\n          </>\n        )}\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => setYoutubeDialogOpen(true)}\n        >\n          <YoutubeIcon className=\"h-4 w-4\" />\n        </Button>\n        <div className=\"mx-1 h-6 w-px bg-border\" />\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => editor.chain().focus().undo().run()}\n          disabled={!editor.can().undo()}\n        >\n          <Undo className=\"h-4 w-4\" />\n        </Button>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          type=\"button\"\n          onClick={() => editor.chain().focus().redo().run()}\n          disabled={!editor.can().redo()}\n        >\n          <Redo className=\"h-4 w-4\" />\n        </Button>\n      </div>\n\n      {/* Editor */}\n      <div className=\"min-h-[200px] p-3\">\n        <EditorContent\n          editor={editor}\n          className=\"prose prose-sm max-w-none focus:outline-none [&_.ProseMirror]:min-h-[150px] [&_.ProseMirror]:focus:outline-none\"\n        />\n      </div>\n\n      {/* YouTube Dialog */}\n      <Dialog open={youtubeDialogOpen} onOpenChange={handleYoutubeDialogChange}>\n        <DialogContent className=\"sm:max-w-md\">\n          <DialogHeader>\n            <DialogTitle>Add YouTube Video</DialogTitle>\n          </DialogHeader>\n          <div className=\"space-y-4 py-4\">\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"youtube-url\">YouTube URL</Label>\n              <Input\n                id=\"youtube-url\"\n                placeholder=\"https://www.youtube.com/watch?v=...\"\n                value={youtubeUrl}\n                onChange={(e) => {\n                  setYoutubeUrl(e.target.value);\n                  // Clear error when user starts typing\n                  if (youtubeUrlError) setYoutubeUrlError(null);\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\") {\n                    e.preventDefault();\n                    addYoutubeVideo();\n                  }\n                }}\n                className={youtubeUrlError ? \"border-destructive\" : \"\"}\n              />\n              {youtubeUrlError ? (\n                <p className=\"text-xs text-destructive\">{youtubeUrlError}</p>\n              ) : (\n                <p className=\"text-xs text-muted-foreground\">\n                  Paste a YouTube video URL (e.g., youtube.com/watch?v=...,\n                  youtu.be/...)\n                </p>\n              )}\n            </div>\n          </div>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => handleYoutubeDialogChange(false)}\n            >\n              Cancel\n            </Button>\n            <Button onClick={addYoutubeVideo} disabled={!isYoutubeUrlValid}>\n              Add Video\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/ui/scroll-area.tsx",
    "content": "import * as React from \"react\";\n\nimport * as ScrollAreaPrimitive from \"@radix-ui/react-scroll-area\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst ScrollArea = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {\n    showScrollbar?: boolean;\n  }\n>(({ className, children, showScrollbar, ...props }, ref) => (\n  <ScrollAreaPrimitive.Root\n    ref={ref}\n    className={cn(\"relative overflow-hidden\", className)}\n    {...props}\n  >\n    <ScrollAreaPrimitive.Viewport className=\"h-full w-full rounded-[inherit]\">\n      {children}\n    </ScrollAreaPrimitive.Viewport>\n\n    <ScrollAreaPrimitive.Corner />\n    <ScrollBar className={showScrollbar ? \"\" : \"hidden\"} />\n  </ScrollAreaPrimitive.Root>\n));\nScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;\n\nconst ScrollBar = React.forwardRef<\n  React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,\n  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>\n>(({ className, orientation = \"vertical\", ...props }, ref) => (\n  <ScrollAreaPrimitive.ScrollAreaScrollbar\n    ref={ref}\n    orientation={orientation}\n    className={cn(\n      \"flex touch-none select-none transition-colors\",\n      orientation === \"vertical\" &&\n        \"h-full w-2.5 border-l border-l-transparent p-[1px]\",\n      orientation === \"horizontal\" &&\n        \"h-2.5 flex-col border-t border-t-transparent p-[1px]\",\n      className,\n    )}\n    {...props}\n  >\n    <ScrollAreaPrimitive.ScrollAreaThumb className=\"relative flex-1 rounded-full bg-border\" />\n  </ScrollAreaPrimitive.ScrollAreaScrollbar>\n));\nScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;\n\nexport { ScrollArea, ScrollBar };\n"
  },
  {
    "path": "components/ui/select.tsx",
    "content": "import * as React from \"react\";\n\nimport * as SelectPrimitive from \"@radix-ui/react-select\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport Check from \"../shared/icons/check\";\nimport ChevronDown from \"../shared/icons/chevron-down\";\nimport ChevronUp from \"../shared/icons/chevron-up\";\n\nconst Select = SelectPrimitive.Root;\n\nconst SelectGroup = SelectPrimitive.Group;\n\nconst SelectValue = SelectPrimitive.Value;\n\nconst SelectTrigger = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1\",\n      className,\n    )}\n    {...props}\n  >\n    {children}\n    <SelectPrimitive.Icon asChild>\n      <ChevronDown className=\"h-4 w-4 opacity-50\" />\n    </SelectPrimitive.Icon>\n  </SelectPrimitive.Trigger>\n));\nSelectTrigger.displayName = SelectPrimitive.Trigger.displayName;\n\nconst SelectScrollUpButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollUpButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className,\n    )}\n    {...props}\n  >\n    <ChevronUp className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollUpButton>\n));\nSelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;\n\nconst SelectScrollDownButton = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.ScrollDownButton\n    ref={ref}\n    className={cn(\n      \"flex cursor-default items-center justify-center py-1\",\n      className,\n    )}\n    {...props}\n  >\n    <ChevronDown className=\"h-4 w-4\" />\n  </SelectPrimitive.ScrollDownButton>\n));\nSelectScrollDownButton.displayName =\n  SelectPrimitive.ScrollDownButton.displayName;\n\nconst SelectContent = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>\n>(({ className, children, position = \"popper\", ...props }, ref) => (\n  <SelectPrimitive.Portal>\n    <SelectPrimitive.Content\n      ref={ref}\n      className={cn(\n        \"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        position === \"popper\" &&\n          \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n        className,\n      )}\n      position={position}\n      {...props}\n    >\n      <SelectScrollUpButton />\n      <SelectPrimitive.Viewport\n        className={cn(\n          \"p-1\",\n          position === \"popper\" &&\n            \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]\",\n        )}\n      >\n        {children}\n      </SelectPrimitive.Viewport>\n      <SelectScrollDownButton />\n    </SelectPrimitive.Content>\n  </SelectPrimitive.Portal>\n));\nSelectContent.displayName = SelectPrimitive.Content.displayName;\n\nconst SelectLabel = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Label\n    ref={ref}\n    className={cn(\"px-2 py-1.5 text-sm font-semibold\", className)}\n    {...props}\n  />\n));\nSelectLabel.displayName = SelectPrimitive.Label.displayName;\n\nconst SelectItem = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>\n>(({ className, children, ...props }, ref) => (\n  <SelectPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className,\n    )}\n    {...props}\n  >\n    <span className=\"absolute right-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <SelectPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </SelectPrimitive.ItemIndicator>\n    </span>\n    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n  </SelectPrimitive.Item>\n));\nSelectItem.displayName = SelectPrimitive.Item.displayName;\n\nconst SelectSeparator = React.forwardRef<\n  React.ElementRef<typeof SelectPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <SelectPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n));\nSelectSeparator.displayName = SelectPrimitive.Separator.displayName;\n\nexport {\n  Select,\n  SelectGroup,\n  SelectValue,\n  SelectTrigger,\n  SelectContent,\n  SelectLabel,\n  SelectItem,\n  SelectSeparator,\n  SelectScrollUpButton,\n  SelectScrollDownButton,\n};\n"
  },
  {
    "path": "components/ui/separator.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Separator = React.forwardRef<\n  React.ElementRef<typeof SeparatorPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(\n  (\n    { className, orientation = \"horizontal\", decorative = true, ...props },\n    ref,\n  ) => (\n    <SeparatorPrimitive.Root\n      ref={ref}\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"shrink-0 bg-border\",\n        orientation === \"horizontal\" ? \"h-[1px] w-full\" : \"h-full w-[1px]\",\n        className,\n      )}\n      {...props}\n    />\n  ),\n);\nSeparator.displayName = SeparatorPrimitive.Root.displayName;\n\nexport { Separator };\n"
  },
  {
    "path": "components/ui/sheet.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\";\nimport { type VariantProps, cva } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport X from \"@/components/shared/icons/x\";\n\nconst Sheet = SheetPrimitive.Root;\n\nconst SheetTrigger = SheetPrimitive.Trigger;\n\nconst SheetClose = SheetPrimitive.Close;\n\nconst SheetPortal = ({ ...props }: SheetPrimitive.DialogPortalProps) => (\n  <SheetPrimitive.Portal {...props} />\n);\nSheetPortal.displayName = SheetPrimitive.Portal.displayName;\n\nconst SheetOverlay = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Overlay\n    className={cn(\n      \"fixed inset-0 z-50 bg-[rgba(182,192,205,0.7)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 dark:bg-[rgba(30,30,30,0.7)]\",\n      className,\n    )}\n    {...props}\n    ref={ref}\n  />\n));\nSheetOverlay.displayName = SheetPrimitive.Overlay.displayName;\n\nconst sheetVariants = cva(\n  \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500\",\n  {\n    variants: {\n      side: {\n        top: \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n        bottom:\n          \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n        left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-lg\",\n        right:\n          \"inset-y-0 right-0 h-full w-3/4 border-l border-white/10 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-lg\",\n      },\n    },\n    defaultVariants: {\n      side: \"right\",\n    },\n  },\n);\n\ninterface SheetContentProps\n  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,\n    VariantProps<typeof sheetVariants> {}\n\nconst SheetContent = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Content>,\n  SheetContentProps\n>(({ side = \"right\", className, children, ...props }, ref) => (\n  <SheetPortal>\n    <SheetOverlay />\n    <SheetPrimitive.Content\n      ref={ref}\n      className={cn(sheetVariants({ side }), className)}\n      {...props}\n    >\n      {children}\n      <SheetPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n        <X className=\"h-4 w-4\" />\n        <span className=\"sr-only\">Close</span>\n      </SheetPrimitive.Close>\n    </SheetPrimitive.Content>\n  </SheetPortal>\n));\nSheetContent.displayName = SheetPrimitive.Content.displayName;\n\nconst SheetHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col space-y-2 text-center sm:text-left\",\n      className,\n    )}\n    {...props}\n  />\n);\nSheetHeader.displayName = \"SheetHeader\";\n\nconst SheetFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={cn(\n      \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n      className,\n    )}\n    {...props}\n  />\n);\nSheetFooter.displayName = \"SheetFooter\";\n\nconst SheetTitle = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Title\n    ref={ref}\n    className={cn(\"text-lg font-semibold text-foreground\", className)}\n    {...props}\n  />\n));\nSheetTitle.displayName = SheetPrimitive.Title.displayName;\n\nconst SheetDescription = React.forwardRef<\n  React.ElementRef<typeof SheetPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <SheetPrimitive.Description\n    ref={ref}\n    className={cn(\"text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nSheetDescription.displayName = SheetPrimitive.Description.displayName;\n\nexport {\n  Sheet,\n  SheetTrigger,\n  SheetClose,\n  SheetContent,\n  SheetHeader,\n  SheetFooter,\n  SheetTitle,\n  SheetDescription,\n  SheetPortal,\n  SheetOverlay,\n};\n"
  },
  {
    "path": "components/ui/sidebar.tsx",
    "content": "import * as React from \"react\";\n\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { VariantProps, cva } from \"class-variance-authority\";\n\nimport { useIsMobile } from \"@/lib/hooks/use-mobile\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Sheet, SheetContent } from \"@/components/ui/sheet\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\n// Custom PanelLeft icon with filled state support\nconst PanelLeftIcon = ({ filled = false }: { filled?: boolean }) => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    width=\"24\"\n    height=\"24\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth=\"2\"\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n  >\n    {filled ? (\n      <>\n        {/* Filled left panel background */}\n        <path\n          d=\"M10 3 H5 a2 2 0 0 0 -2 2 V19 a2 2 0 0 0 2 2 H10 Z\"\n          fill=\"currentColor\"\n          stroke=\"none\"\n        />\n        {/* Menu item lines (centered in sidebar panel) */}\n        <path d=\"M5.25 8h2.5\" className=\"stroke-background\" strokeWidth=\"1.5\" />\n        <path\n          d=\"M5.25 12h2.5\"\n          className=\"stroke-background\"\n          strokeWidth=\"1.5\"\n        />\n        <path\n          d=\"M5.25 16h2.5\"\n          className=\"stroke-background\"\n          strokeWidth=\"1.5\"\n        />\n      </>\n    ) : (\n      <>\n        {/* Menu item lines (centered in sidebar panel) */}\n        <path d=\"M5.25 8h2.5\" strokeWidth=\"1.5\" />\n        <path d=\"M5.25 12h2.5\" strokeWidth=\"1.5\" />\n        <path d=\"M5.25 16h2.5\" strokeWidth=\"1.5\" />\n      </>\n    )}\n    <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n    <path d=\"M10 3v18\" />\n  </svg>\n);\n\nexport const SIDEBAR_COOKIE_NAME = \"sidebar:state\";\nconst SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;\nconst SIDEBAR_WIDTH = \"16rem\";\nconst SIDEBAR_WIDTH_MOBILE = \"18rem\";\nconst SIDEBAR_WIDTH_ICON = \"3rem\";\nconst SIDEBAR_KEYBOARD_SHORTCUT = \"[\";\n\ntype SidebarContext = {\n  state: \"expanded\" | \"collapsed\";\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  openMobile: boolean;\n  setOpenMobile: (open: boolean) => void;\n  isMobile: boolean;\n  toggleSidebar: () => void;\n};\n\nconst SidebarContext = React.createContext<SidebarContext | null>(null);\n\nfunction useSidebar() {\n  const context = React.useContext(SidebarContext);\n  if (!context) {\n    throw new Error(\"useSidebar must be used within a SidebarProvider.\");\n  }\n\n  return context;\n}\n\nconst SidebarProvider = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    defaultOpen?: boolean;\n    open?: boolean;\n    onOpenChange?: (open: boolean) => void;\n  }\n>(\n  (\n    {\n      defaultOpen = true,\n      open: openProp,\n      onOpenChange: setOpenProp,\n      className,\n      style,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const isMobile = useIsMobile();\n    const [openMobile, setOpenMobile] = React.useState(false);\n\n    // Read initial state from cookie\n    const initialState = React.useMemo(() => {\n      if (typeof window === \"undefined\") return defaultOpen;\n      const cookie = document.cookie\n        .split(\"; \")\n        .find((row) => row.startsWith(SIDEBAR_COOKIE_NAME));\n      if (cookie) {\n        const value = cookie.split(\"=\")[1];\n        return value === \"true\";\n      }\n      return defaultOpen;\n    }, [defaultOpen]);\n\n    // This is the internal state of the sidebar.\n    // We use openProp and setOpenProp for control from outside the component.\n    const [_open, _setOpen] = React.useState(initialState);\n    const open = openProp ?? _open;\n    const setOpen = React.useCallback(\n      (value: boolean | ((value: boolean) => boolean)) => {\n        const openState = typeof value === \"function\" ? value(open) : value;\n        if (setOpenProp) {\n          setOpenProp(openState);\n        } else {\n          _setOpen(openState);\n        }\n\n        // This sets the cookie to keep the sidebar state.\n        document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;\n      },\n      [setOpenProp, open],\n    );\n\n    // Helper to toggle the sidebar.\n    const toggleSidebar = React.useCallback(() => {\n      return isMobile\n        ? setOpenMobile((open) => !open)\n        : setOpen((open) => !open);\n    }, [isMobile, setOpen, setOpenMobile]);\n\n    // Adds a keyboard shortcut to toggle the sidebar.\n    React.useEffect(() => {\n      const handleKeyDown = (event: KeyboardEvent) => {\n        if (event.key === SIDEBAR_KEYBOARD_SHORTCUT) {\n          event.preventDefault();\n          toggleSidebar();\n        }\n      };\n\n      window.addEventListener(\"keydown\", handleKeyDown);\n      return () => window.removeEventListener(\"keydown\", handleKeyDown);\n    }, [toggleSidebar]);\n\n    // We add a state so that we can do data-state=\"expanded\" or \"collapsed\".\n    // This makes it easier to style the sidebar with Tailwind classes.\n    const state = open ? \"expanded\" : \"collapsed\";\n\n    const contextValue = React.useMemo<SidebarContext>(\n      () => ({\n        state,\n        open,\n        setOpen,\n        isMobile,\n        openMobile,\n        setOpenMobile,\n        toggleSidebar,\n      }),\n      [\n        state,\n        open,\n        setOpen,\n        isMobile,\n        openMobile,\n        setOpenMobile,\n        toggleSidebar,\n      ],\n    );\n\n    return (\n      <SidebarContext.Provider value={contextValue}>\n        <TooltipProvider delayDuration={0}>\n          <div\n            style={\n              {\n                \"--sidebar-width\": SIDEBAR_WIDTH,\n                \"--sidebar-width-icon\": SIDEBAR_WIDTH_ICON,\n                ...style,\n              } as React.CSSProperties\n            }\n            className={cn(\n              \"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar\",\n              className,\n            )}\n            ref={ref}\n            {...props}\n          >\n            {children}\n          </div>\n        </TooltipProvider>\n      </SidebarContext.Provider>\n    );\n  },\n);\nSidebarProvider.displayName = \"SidebarProvider\";\n\nconst Sidebar = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    side?: \"left\" | \"right\";\n    variant?: \"sidebar\" | \"floating\" | \"inset\";\n    collapsible?: \"offcanvas\" | \"icon\" | \"none\";\n    sidebarClassName?: string;\n  }\n>(\n  (\n    {\n      side = \"left\",\n      variant = \"sidebar\",\n      collapsible = \"offcanvas\",\n      className,\n      sidebarClassName,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const { isMobile, state, openMobile, setOpenMobile } = useSidebar();\n\n    if (collapsible === \"none\") {\n      return (\n        <div\n          className={cn(\n            \"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground\",\n            className,\n          )}\n          ref={ref}\n          {...props}\n        >\n          {children}\n        </div>\n      );\n    }\n\n    if (isMobile) {\n      return (\n        <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>\n          <SheetContent\n            data-sidebar=\"sidebar\"\n            data-mobile=\"true\"\n            className=\"w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden\"\n            style={\n              {\n                \"--sidebar-width\": SIDEBAR_WIDTH_MOBILE,\n              } as React.CSSProperties\n            }\n            side={side}\n          >\n            <div className=\"flex h-full w-full flex-col\">{children}</div>\n          </SheetContent>\n        </Sheet>\n      );\n    }\n\n    return (\n      <div\n        ref={ref}\n        className=\"group peer hidden text-sidebar-foreground md:block\"\n        data-state={state}\n        data-collapsible={state === \"collapsed\" ? collapsible : \"\"}\n        data-variant={variant}\n        data-side={side}\n      >\n        {/* This is what handles the sidebar gap on desktop */}\n        <div\n          className={cn(\n            \"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear\",\n            \"group-data-[collapsible=offcanvas]:w-0\",\n            \"group-data-[side=right]:rotate-180\",\n            variant === \"floating\" || variant === \"inset\"\n              ? \"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]\"\n              : \"group-data-[collapsible=icon]:w-[--sidebar-width-icon]\",\n          )}\n        />\n        <div\n          className={cn(\n            \"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex\",\n            side === \"left\"\n              ? \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]\"\n              : \"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]\",\n            // Adjust the padding for floating and inset variants.\n            variant === \"floating\" || variant === \"inset\"\n              ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]\"\n              : \"group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n            className,\n          )}\n          {...props}\n        >\n          <div\n            data-sidebar=\"sidebar\"\n            className={cn(\n              \"flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow\",\n              sidebarClassName,\n            )}\n          >\n            {children}\n          </div>\n        </div>\n      </div>\n    );\n  },\n);\nSidebar.displayName = \"Sidebar\";\n\nconst SidebarTrigger = React.forwardRef<\n  React.ElementRef<typeof Button>,\n  React.ComponentProps<typeof Button>\n>(({ className, onClick, ...props }, ref) => {\n  const { toggleSidebar, open } = useSidebar();\n\n  return (\n    <Button\n      ref={ref}\n      data-sidebar=\"trigger\"\n      data-state={open ? \"open\" : \"closed\"}\n      variant=\"ghost\"\n      size=\"icon\"\n      className={cn(\"h-7 w-7\", className)}\n      onClick={(event) => {\n        onClick?.(event);\n        toggleSidebar();\n      }}\n      {...props}\n    >\n      <PanelLeftIcon filled={open} />\n      <span className=\"sr-only\">Toggle Sidebar</span>\n    </Button>\n  );\n});\nSidebarTrigger.displayName = \"SidebarTrigger\";\n\nconst SidebarRail = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\">\n>(({ className, ...props }, ref) => {\n  const { toggleSidebar } = useSidebar();\n\n  return (\n    <button\n      ref={ref}\n      data-sidebar=\"rail\"\n      aria-label=\"Toggle Sidebar\"\n      tabIndex={-1}\n      onClick={toggleSidebar}\n      title=\"Toggle Sidebar\"\n      className={cn(\n        \"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex\",\n        \"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize\",\n        \"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize\",\n        \"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar\",\n        \"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2\",\n        \"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarRail.displayName = \"SidebarRail\";\n\nconst SidebarInset = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"main\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      className={cn(\n        \"relative flex min-h-svh flex-1 flex-col bg-background\",\n        \"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow\", // md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarInset.displayName = \"SidebarInset\";\n\nconst SidebarInput = React.forwardRef<\n  React.ElementRef<typeof Input>,\n  React.ComponentProps<typeof Input>\n>(({ className, ...props }, ref) => {\n  return (\n    <Input\n      ref={ref}\n      data-sidebar=\"input\"\n      className={cn(\n        \"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarInput.displayName = \"SidebarInput\";\n\nconst SidebarHeader = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"header\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  );\n});\nSidebarHeader.displayName = \"SidebarHeader\";\n\nconst SidebarFooter = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"footer\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  );\n});\nSidebarFooter.displayName = \"SidebarFooter\";\n\nconst SidebarSeparator = React.forwardRef<\n  React.ElementRef<typeof Separator>,\n  React.ComponentProps<typeof Separator>\n>(({ className, ...props }, ref) => {\n  return (\n    <Separator\n      ref={ref}\n      data-sidebar=\"separator\"\n      className={cn(\"mx-2 w-auto bg-sidebar-border\", className)}\n      {...props}\n    />\n  );\n});\nSidebarSeparator.displayName = \"SidebarSeparator\";\n\nconst SidebarContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"content\"\n      className={cn(\n        \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarContent.displayName = \"SidebarContent\";\n\nconst SidebarGroup = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => {\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"group\"\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      {...props}\n    />\n  );\n});\nSidebarGroup.displayName = \"SidebarGroup\";\n\nconst SidebarGroupLabel = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"div\";\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"group-label\"\n      className={cn(\n        \"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarGroupLabel.displayName = \"SidebarGroupLabel\";\n\nconst SidebarGroupAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & { asChild?: boolean }\n>(({ className, asChild = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"group-action\"\n      className={cn(\n        \"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 after:md:hidden\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarGroupAction.displayName = \"SidebarGroupAction\";\n\nconst SidebarGroupContent = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"group-content\"\n    className={cn(\"w-full text-sm\", className)}\n    {...props}\n  />\n));\nSidebarGroupContent.displayName = \"SidebarGroupContent\";\n\nconst SidebarMenu = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu\"\n    className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n    {...props}\n  />\n));\nSidebarMenu.displayName = \"SidebarMenu\";\n\nconst SidebarMenuItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<\"li\">\n>(({ className, ...props }, ref) => (\n  <li\n    ref={ref}\n    data-sidebar=\"menu-item\"\n    className={cn(\"group/menu-item relative\", className)}\n    {...props}\n  />\n));\nSidebarMenuItem.displayName = \"SidebarMenuItem\";\n\nconst sidebarMenuButtonVariants = cva(\n  \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-7 text-xs\",\n        lg: \"h-12 text-sm group-data-[collapsible=icon]:!p-0\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nconst SidebarMenuButton = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & {\n    asChild?: boolean;\n    isActive?: boolean;\n    tooltip?: string | React.ComponentProps<typeof TooltipContent>;\n  } & VariantProps<typeof sidebarMenuButtonVariants>\n>(\n  (\n    {\n      asChild = false,\n      isActive = false,\n      variant = \"default\",\n      size = \"default\",\n      tooltip,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const Comp = asChild ? Slot : \"button\";\n    const { isMobile, state } = useSidebar();\n\n    const button = (\n      <Comp\n        ref={ref}\n        data-sidebar=\"menu-button\"\n        data-size={size}\n        data-active={isActive}\n        className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n        {...props}\n      />\n    );\n\n    if (!tooltip) {\n      return button;\n    }\n\n    if (typeof tooltip === \"string\") {\n      tooltip = {\n        children: tooltip,\n      };\n    }\n\n    return (\n      <Tooltip>\n        <TooltipTrigger asChild>{button}</TooltipTrigger>\n        <TooltipContent\n          side=\"right\"\n          align=\"center\"\n          hidden={state !== \"collapsed\" || isMobile}\n          {...tooltip}\n        />\n      </Tooltip>\n    );\n  },\n);\nSidebarMenuButton.displayName = \"SidebarMenuButton\";\n\nconst SidebarMenuAction = React.forwardRef<\n  HTMLButtonElement,\n  React.ComponentProps<\"button\"> & {\n    asChild?: boolean;\n    showOnHover?: boolean;\n  }\n>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"menu-action\"\n      className={cn(\n        \"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 after:md:hidden\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        showOnHover &&\n          \"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarMenuAction.displayName = \"SidebarMenuAction\";\n\nconst SidebarMenuBadge = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\">\n>(({ className, ...props }, ref) => (\n  <div\n    ref={ref}\n    data-sidebar=\"menu-badge\"\n    className={cn(\n      \"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground\",\n      \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n      \"peer-data-[size=sm]/menu-button:top-1\",\n      \"peer-data-[size=default]/menu-button:top-1.5\",\n      \"peer-data-[size=lg]/menu-button:top-2.5\",\n      \"group-data-[collapsible=icon]:hidden\",\n      className,\n    )}\n    {...props}\n  />\n));\nSidebarMenuBadge.displayName = \"SidebarMenuBadge\";\n\nconst SidebarMenuSkeleton = React.forwardRef<\n  HTMLDivElement,\n  React.ComponentProps<\"div\"> & {\n    showIcon?: boolean;\n  }\n>(({ className, showIcon = false, ...props }, ref) => {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`;\n  }, []);\n\n  return (\n    <div\n      ref={ref}\n      data-sidebar=\"menu-skeleton\"\n      className={cn(\"flex h-8 items-center gap-2 rounded-md px-2\", className)}\n      {...props}\n    >\n      {showIcon && (\n        <Skeleton\n          className=\"size-4 rounded-md\"\n          data-sidebar=\"menu-skeleton-icon\"\n        />\n      )}\n      <Skeleton\n        className=\"h-4 max-w-[--skeleton-width] flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  );\n});\nSidebarMenuSkeleton.displayName = \"SidebarMenuSkeleton\";\n\nconst SidebarMenuSub = React.forwardRef<\n  HTMLUListElement,\n  React.ComponentProps<\"ul\">\n>(({ className, ...props }, ref) => (\n  <ul\n    ref={ref}\n    data-sidebar=\"menu-sub\"\n    className={cn(\n      \"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5\",\n      \"group-data-[collapsible=icon]:hidden\",\n      className,\n    )}\n    {...props}\n  />\n));\nSidebarMenuSub.displayName = \"SidebarMenuSub\";\n\nconst SidebarMenuSubItem = React.forwardRef<\n  HTMLLIElement,\n  React.ComponentProps<\"li\">\n>(({ ...props }, ref) => <li ref={ref} {...props} />);\nSidebarMenuSubItem.displayName = \"SidebarMenuSubItem\";\n\nconst SidebarMenuSubButton = React.forwardRef<\n  HTMLAnchorElement,\n  React.ComponentProps<\"a\"> & {\n    asChild?: boolean;\n    size?: \"sm\" | \"md\";\n    isActive?: boolean;\n  }\n>(({ asChild = false, size = \"md\", isActive, className, ...props }, ref) => {\n  const Comp = asChild ? Slot : \"a\";\n\n  return (\n    <Comp\n      ref={ref}\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        \"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground\",\n        \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n        size === \"sm\" && \"text-xs\",\n        size === \"md\" && \"text-sm\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n});\nSidebarMenuSubButton.displayName = \"SidebarMenuSubButton\";\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarProvider,\n  SidebarRail,\n  SidebarSeparator,\n  SidebarTrigger,\n  useSidebar,\n};\n"
  },
  {
    "path": "components/ui/single-select.tsx",
    "content": "import * as React from \"react\";\n\nimport { Link2Icon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\n\nimport LoadingSpinner from \"./loading-spinner\";\n\n/**\n * Props for SingleSelect component\n */\ninterface SingleSelectProps<TMeta = any>\n  extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  /**\n   * An array of option objects to be displayed in the single-select component.\n   * Each option object has a label, value, and optional metadata.\n   */\n  options: SingleSelectOption<TMeta>[];\n  searchPlaceholder?: string;\n  inputClassName?: string;\n  optionClassName?: string;\n  popoverClassName?: string;\n\n  /**\n   * Callback function triggered when the selected value changes.\n   * Receives the new selected value (or empty string if cleared).\n   */\n  onValueChange: (value: string) => void;\n\n  /** The current selected value. */\n  value: string;\n\n  /**\n   * Placeholder text to be displayed when no value is selected.\n   * Optional, defaults to \"Select option\".\n   */\n  placeholder?: string;\n\n  /**\n   * Additional class names to apply custom styles to the single-select component.\n   * Optional, can be used to add custom styles.\n   */\n  className?: string;\n\n  loading?: boolean;\n  triggerIcon?: React.ReactNode;\n\n  /**\n   * Custom render function for each option in the list.\n   * If not provided, will use default rendering with label only.\n   */\n  renderOption?: (\n    option: SingleSelectOption<TMeta>,\n    isSelected: boolean,\n  ) => React.ReactNode;\n\n  /**\n   * Custom render function for the trigger button content.\n   * If not provided, will use default rendering with label only.\n   */\n  renderTrigger?: (\n    option: SingleSelectOption<TMeta> | null,\n  ) => React.ReactNode;\n\n  /**\n   * Text to show when no options match the search.\n   */\n  emptyText?: string;\n}\n\nexport type SingleSelectOption<TMeta = any> = {\n  label: string;\n  value: string;\n  searchableText?: string; // Additional text to search through\n  meta?: TMeta;\n};\n\nexport const SingleSelect = React.forwardRef<\n  HTMLButtonElement,\n  SingleSelectProps\n>(\n  (\n    {\n      options,\n      onValueChange,\n      className,\n      value,\n      searchPlaceholder = \"Search...\",\n      inputClassName,\n      optionClassName,\n      popoverClassName,\n      placeholder = \"Select option\",\n      loading,\n      triggerIcon,\n      renderOption,\n      renderTrigger,\n      emptyText = \"No results found.\",\n      ...props\n    },\n    ref,\n  ) => {\n    const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);\n    const [search, setSearch] = React.useState(\"\");\n\n    const handleInputKeyDown = (\n      event: React.KeyboardEvent<HTMLInputElement>,\n    ) => {\n      if (event.key === \"Enter\") {\n        setIsPopoverOpen(true);\n      } else if (event.key === \"Escape\") {\n        setIsPopoverOpen(false);\n      }\n    };\n\n    const handleSelect = (selectedValue: string) => {\n      onValueChange(selectedValue);\n      setIsPopoverOpen(false);\n      setSearch(\"\");\n    };\n\n    const handleTogglePopover = () => {\n      setIsPopoverOpen((prev) => !prev);\n    };\n\n    const selectedOption = options.find((opt) => opt.value === value);\n\n    // Filter options based on search\n    const filteredOptions = React.useMemo(() => {\n      if (!search) return options;\n      const searchLower = search.toLowerCase();\n      return options.filter((option) => {\n        const searchText = option.searchableText || option.label;\n        return searchText.toLowerCase().includes(searchLower);\n      });\n    }, [options, search]);\n\n    return (\n      <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen} modal>\n        <PopoverTrigger asChild>\n          <Button\n            ref={ref}\n            {...props}\n            onClick={handleTogglePopover}\n            className={cn(\n              \"flex h-auto w-full items-center justify-start gap-3 rounded-md border border-input bg-inherit px-3 py-2 hover:bg-inherit focus:border-muted-foreground focus:outline-none focus:ring-1 focus:ring-inset focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent [&_svg]:pointer-events-auto\",\n              className,\n            )}\n          >\n            {triggerIcon || (\n              <Link2Icon className=\"!size-4 shrink-0 text-muted-foreground\" />\n            )}\n            {loading ? (\n              <div className=\"flex w-full items-center justify-between\">\n                <LoadingSpinner className=\"size-4 shrink-0\" />\n              </div>\n            ) : selectedOption ? (\n              <div className=\"flex w-full items-center overflow-hidden\">\n                {renderTrigger ? (\n                  renderTrigger(selectedOption)\n                ) : (\n                  <span className=\"truncate text-sm text-foreground\">\n                    {selectedOption.label}\n                  </span>\n                )}\n              </div>\n            ) : (\n              <span className=\"text-sm font-normal text-muted-foreground\">\n                {placeholder}\n              </span>\n            )}\n          </Button>\n        </PopoverTrigger>\n        <PopoverContent\n          className={cn(\"p-0\", popoverClassName)}\n          align=\"start\"\n          style={{ width: \"var(--radix-popover-trigger-width)\" }}\n          onEscapeKeyDown={() => setIsPopoverOpen(false)}\n        >\n          <Command shouldFilter={false}>\n            <CommandInput\n              placeholder={searchPlaceholder}\n              value={search}\n              onValueChange={setSearch}\n              noIcon\n              wrapperClassName=\"px-0\"\n              className={cn(\n                \"grow border-0 py-3 outline-none placeholder:text-neutral-400 focus:ring-0 sm:text-sm\",\n                inputClassName,\n              )}\n              onKeyDown={handleInputKeyDown}\n            />\n            <ScrollArea className=\"max-h-[300px]\">\n              <CommandList>\n                <CommandGroup>\n                  {loading ? (\n                    <CommandItem className=\"justify-center\">\n                      <LoadingSpinner className=\"size-4 shrink-0\" />\n                    </CommandItem>\n                  ) : filteredOptions.length > 0 ? (\n                    filteredOptions.map((option) => {\n                      const isSelected = value === option.value;\n                      return (\n                        <CommandItem\n                          key={option.value}\n                          onSelect={() => handleSelect(option.value)}\n                          className={cn(\n                            \"cursor-pointer gap-2 py-2.5\",\n                            optionClassName,\n                          )}\n                          value={option.value}\n                        >\n                          {renderOption ? (\n                            renderOption(option, isSelected)\n                          ) : (\n                            <span className=\"text-sm\">{option.label}</span>\n                          )}\n                        </CommandItem>\n                      );\n                    })\n                  ) : (\n                    <CommandItem disabled className=\"justify-center\">\n                      <span className=\"text-sm text-muted-foreground\">\n                        {emptyText}\n                      </span>\n                    </CommandItem>\n                  )}\n                </CommandGroup>\n              </CommandList>\n            </ScrollArea>\n          </Command>\n        </PopoverContent>\n      </Popover>\n    );\n  },\n);\n\nSingleSelect.displayName = \"SingleSelect\";\n\n"
  },
  {
    "path": "components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/lib/utils\";\n\nfunction Skeleton({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) {\n  return (\n    <div\n      className={cn(\n        \"animate-pulse rounded-md bg-gray-200 dark:bg-muted\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Skeleton };\n"
  },
  {
    "path": "components/ui/smart-date-time-picker.tsx",
    "content": "import { useCallback, useEffect, useId, useRef } from \"react\";\n\nimport {\n  cn,\n  formatDateTime,\n  getDateTimeLocal,\n  parseDateTime,\n} from \"@/lib/utils\";\n\ninterface SmartDateTimePickerProps {\n  value: Date | null | undefined;\n  onChange: (date: Date | null) => void;\n  onComplete?: (date: Date | null) => void;\n  label?: string;\n  placeholder?: string;\n  className?: string;\n  required?: boolean;\n  autoFocus?: boolean;\n  options?: Intl.DateTimeFormatOptions;\n  formatValue?: (date: Date | null) => string;\n  showCalendarIcon?: boolean;\n}\n\nexport function SmartDateTimePicker({\n  value,\n  onChange,\n  onComplete,\n  label,\n  placeholder = 'E.g. \"tomorrow at 5pm\" or \"in 2 hours\"',\n  className,\n  required,\n  autoFocus = false,\n  options,\n  formatValue,\n  showCalendarIcon = true,\n}: SmartDateTimePickerProps) {\n  const id = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const formatDisplayValue = useCallback(\n    (date: Date | null | undefined) => {\n      if (!date) return \"\";\n      return formatValue ? formatValue(date) : formatDateTime(date);\n    },\n    [formatValue],\n  );\n\n  const updateInputValue = useCallback(\n    (date: Date | null | undefined) => {\n      if (inputRef.current) {\n        inputRef.current.value = formatDisplayValue(date);\n      }\n    },\n    [formatDisplayValue],\n  );\n\n  const handleDateChange = useCallback(\n    (date: Date | null) => {\n      onChange(date);\n      updateInputValue(date);\n    },\n    [onChange, updateInputValue],\n  );\n\n  const handleInputBlur = useCallback(\n    (e: React.FocusEvent<HTMLInputElement>) => {\n      if (e.target.value.length > 0) {\n        const parsedDateTime = parseDateTime(e.target.value);\n        if (parsedDateTime) {\n          handleDateChange(parsedDateTime);\n          onComplete?.(parsedDateTime);\n        }\n      } else {\n        handleDateChange(null);\n        onComplete?.(null);\n      }\n    },\n    [handleDateChange, onComplete],\n  );\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLInputElement>) => {\n      if (e.key === \"Enter\" && inputRef.current) {\n        e.preventDefault();\n        const parsedDateTime = parseDateTime(inputRef.current.value);\n        if (parsedDateTime) {\n          handleDateChange(parsedDateTime);\n          onComplete?.(parsedDateTime);\n        }\n      }\n    },\n    [handleDateChange, onComplete],\n  );\n\n  const handleCalendarChange = useCallback(\n    (e: React.ChangeEvent<HTMLInputElement>) => {\n      const date = new Date(e.target.value);\n      if (!isNaN(date.getTime())) {\n        handleDateChange(date);\n        onComplete?.(date);\n      }\n    },\n    [handleDateChange, onComplete],\n  );\n\n  // Handle autofocus\n  useEffect(() => {\n    if (inputRef.current && autoFocus) {\n      setTimeout(() => {\n        inputRef.current?.focus();\n      }, 10);\n    }\n  }, [autoFocus]);\n\n  useEffect(() => {\n    updateInputValue(value);\n  }, [value, updateInputValue]);\n\n  return (\n    <div className={className}>\n      {label && (\n        <div className=\"flex items-center gap-2\">\n          <label\n            htmlFor={`${id}-datetime`}\n            className=\"block text-sm font-medium text-neutral-700\"\n          >\n            {label}\n          </label>\n        </div>\n      )}\n      <div\n        className={cn(\n          \"mt-2 flex w-full items-center justify-between rounded-md border border-neutral-300\",\n          \"bg-white shadow-sm transition-all focus-within:border-neutral-800\",\n          \"focus-within:outline-none focus-within:ring-1 focus-within:ring-neutral-500\",\n        )}\n      >\n        <input\n          ref={inputRef}\n          id={`${id}-datetime`}\n          type=\"text\"\n          placeholder={placeholder}\n          defaultValue={formatDisplayValue(value)}\n          onBlur={handleInputBlur}\n          onKeyDown={handleKeyDown}\n          onChange={(e) => {\n            inputRef.current!.value = e.target.value;\n          }}\n          className=\"flex-1 border-none bg-transparent text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm\"\n        />\n        {showCalendarIcon && (\n          <input\n            type=\"datetime-local\"\n            id={`${id}-datetime-local`}\n            required={required}\n            value={value ? getDateTimeLocal(value) : \"\"}\n            onChange={handleCalendarChange}\n            className=\"w-[40px] border-none bg-transparent text-neutral-500 focus:outline-none focus:ring-0 sm:text-sm\"\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/ui/sonner.tsx",
    "content": "\"use client\";\n\nimport { useTheme } from \"next-themes\";\nimport { Toaster as Sonner } from \"sonner\";\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>;\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme();\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      toastOptions={{\n        classNames: {\n          toast:\n            \"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg\",\n          description: \"group-[.toast]:text-muted-foreground\",\n          actionButton:\n            \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground\",\n          cancelButton:\n            \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground\",\n          closeButton:\n            \"group-[.toast]:bg-background group-[.toast]:border-border group-[.toast]:text-foreground group-[.toast]:hover:bg-muted \",\n        },\n      }}\n      {...props}\n    />\n  );\n};\n\nexport { Toaster };\n"
  },
  {
    "path": "components/ui/status-badge.tsx",
    "content": "import { type VariantProps, cva } from \"class-variance-authority\";\nimport {\n  CircleCheckIcon as CircleCheck,\n  CircleDashedIcon as CircleHalfDottedClock,\n  InfoIcon as CircleInfo,\n  CircleAlertIcon as CircleWarning,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Icon } from \"../shared/icons\";\n\nconst statusBadgeVariants = cva(\n  \"flex gap-1.5 items-center max-w-fit rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        neutral: \"bg-gray-500/[.15] text-gray-600\",\n        success: \"bg-green-500/[.15] text-green-600\",\n        pending: \"bg-orange-500/[.15] text-orange-600\",\n        error: \"bg-destructive text-destructive-foreground\",\n      },\n    },\n    defaultVariants: {\n      variant: \"neutral\",\n    },\n  },\n);\n\nconst defaultIcons = {\n  neutral: CircleInfo,\n  success: CircleCheck,\n  pending: CircleHalfDottedClock,\n  error: CircleWarning,\n};\n\ninterface BadgeProps\n  extends React.HTMLAttributes<HTMLSpanElement>,\n    VariantProps<typeof statusBadgeVariants> {\n  icon?: Icon;\n}\n\nfunction StatusBadge({\n  className,\n  variant,\n  icon,\n  children,\n  ...props\n}: BadgeProps) {\n  const Icon =\n    icon !== null ? (icon ?? defaultIcons[variant ?? \"neutral\"]) : null;\n  return (\n    <span\n      className={cn(statusBadgeVariants({ variant }), className)}\n      {...props}\n    >\n      {Icon && <Icon className=\"h-3 w-3 shrink-0\" />}\n      {children}\n    </span>\n  );\n}\n\nexport { StatusBadge, statusBadgeVariants };\n"
  },
  {
    "path": "components/ui/switch.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Switch = React.forwardRef<\n  React.ElementRef<typeof SwitchPrimitives.Root>,\n  React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>\n>(({ className, ...props }, ref) => (\n  <SwitchPrimitives.Root\n    className={cn(\n      \"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input\",\n      className,\n    )}\n    {...props}\n    ref={ref}\n  >\n    <SwitchPrimitives.Thumb\n      className={cn(\n        \"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0\",\n      )}\n    />\n  </SwitchPrimitives.Root>\n));\nSwitch.displayName = SwitchPrimitives.Root.displayName;\n\nexport { Switch };\n"
  },
  {
    "path": "components/ui/tab-select.tsx",
    "content": "import { Dispatch, SetStateAction, useId } from \"react\";\n\nimport { LayoutGroup, motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport function TabSelect<T extends string>({\n  options,\n  selected,\n  onSelect,\n  className,\n}: {\n  options: { id: T; label: string }[];\n  selected: string | null;\n  onSelect?: Dispatch<SetStateAction<T>> | ((id: T) => void);\n  className?: string;\n}) {\n  const layoutGroupId = useId();\n\n  return (\n    <div className={cn(\"flex text-sm\", className)}>\n      <LayoutGroup id={layoutGroupId}>\n        {options.map(({ id, label }) => (\n          <div key={id} className=\"relative\">\n            <button\n              type=\"button\"\n              onClick={() => onSelect?.(id)}\n              className={cn(\n                \"p-4 transition-colors duration-75\",\n                id === selected\n                  ? \"text-foreground\"\n                  : \"text-muted-foreground hover:text-foreground\",\n              )}\n              aria-selected={id === selected}\n            >\n              {label}\n            </button>\n            {id === selected && (\n              <motion.div\n                layoutId=\"indicator\"\n                transition={{\n                  duration: 0.1,\n                }}\n                className=\"absolute bottom-0 w-full px-1.5\"\n              >\n                <div className=\"h-0.5 bg-black dark:bg-white\" />\n              </motion.div>\n            )}\n          </div>\n        ))}\n      </LayoutGroup>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/ui/table.tsx",
    "content": "import * as React from \"react\";\n\n\n\nimport { cn } from \"@/lib/utils\";\n\n\n\n\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  React.HTMLAttributes<HTMLTableElement>\n>(({ className, ...props }, ref) => (\n  <table\n    ref={ref}\n    className={cn(\"w-full caption-bottom text-sm\", className)}\n    {...props}\n  />\n));\nTable.displayName = \"Table\";\n\nconst TableHeader = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <thead ref={ref} className={cn(\"[&_tr]:border-b\", className)} {...props} />\n));\nTableHeader.displayName = \"TableHeader\";\n\nconst TableBody = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tbody\n    ref={ref}\n    className={cn(\"[&_tr:last-child]:border-0\", className)}\n    {...props}\n  />\n));\nTableBody.displayName = \"TableBody\";\n\nconst TableFooter = React.forwardRef<\n  HTMLTableSectionElement,\n  React.HTMLAttributes<HTMLTableSectionElement>\n>(({ className, ...props }, ref) => (\n  <tfoot\n    ref={ref}\n    className={cn(\"bg-primary font-medium text-primary-foreground\", className)}\n    {...props}\n  />\n));\nTableFooter.displayName = \"TableFooter\";\n\nconst TableRow = React.forwardRef<\n  HTMLTableRowElement,\n  React.HTMLAttributes<HTMLTableRowElement>\n>(({ className, ...props }, ref) => (\n  <tr\n    ref={ref}\n    className={cn(\n      \"border-b transition-colors hover:bg-gray-100 data-[state=selected]:bg-gray-100 hover:dark:bg-muted/50 data-[state=selected]:dark:bg-muted\",\n      className,\n    )}\n    {...props}\n  />\n));\nTableRow.displayName = \"TableRow\";\n\nconst TableHead = React.forwardRef<\n  HTMLTableCellElement,\n  React.ThHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <th\n    ref={ref}\n    className={cn(\n      \"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0\",\n      className,\n    )}\n    {...props}\n  />\n));\nTableHead.displayName = \"TableHead\";\n\nconst TableCell = React.forwardRef<\n  HTMLTableCellElement,\n  React.TdHTMLAttributes<HTMLTableCellElement>\n>(({ className, ...props }, ref) => (\n  <td\n    ref={ref}\n    className={cn(\"p-4 align-middle [&:has([role=checkbox])]:pr-0\", className)}\n    {...props}\n  />\n));\nTableCell.displayName = \"TableCell\";\n\nconst TableCaption = React.forwardRef<\n  HTMLTableCaptionElement,\n  React.HTMLAttributes<HTMLTableCaptionElement>\n>(({ className, ...props }, ref) => (\n  <caption\n    ref={ref}\n    className={cn(\"mt-4 text-sm text-muted-foreground\", className)}\n    {...props}\n  />\n));\nTableCaption.displayName = \"TableCaption\";\n\nexport {\n  Table,\n  TableHeader,\n  TableBody,\n  TableFooter,\n  TableHead,\n  TableRow,\n  TableCell,\n  TableCaption,\n};"
  },
  {
    "path": "components/ui/tabs.tsx",
    "content": "import * as React from \"react\";\n\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst Tabs = TabsPrimitive.Root;\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={cn(\n      \"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsList.displayName = TabsPrimitive.List.displayName;\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={cn(\n      \"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    className={cn(\n      \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n      className,\n    )}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
  },
  {
    "path": "components/ui/textarea.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface TextareaProps\n  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}\n\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n  ({ className, ...props }, ref) => {\n    return (\n      <textarea\n        className={cn(\n          \"flex min-h-[80px] w-full rounded-md border border-input bg-white text-foreground placeholder-muted-foreground focus:border-muted-foreground focus:outline-none focus:ring-muted-foreground dark:border-gray-500 dark:bg-gray-800 focus:dark:bg-transparent sm:text-sm\",\n          className,\n        )}\n        ref={ref}\n        data-1p-ignore\n        {...props}\n      />\n    );\n  },\n);\nTextarea.displayName = \"Textarea\";\n\nexport { Textarea };\n"
  },
  {
    "path": "components/ui/timestamp-tooltip.tsx",
    "content": "/**\n * Portions of this file are adapted from dub.co (github.com/dubinc/dub).\n * Copyright (c) Dub, Inc. and contributors. Published under AGPLv3 license.\n * Source: https://github.com/dubinc/dub/blob/aaba1095e692e1756a29808181cca5bbf18ac06d/packages/ui/src/timestamp-tooltip.tsx\n */\n\n\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\n\nimport { formatDuration, intervalToDuration } from \"date-fns\";\nimport { toast } from \"sonner\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipPortal,\n  TooltipTrigger,\n} from \"./tooltip\";\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst MONTH_MS = 30 * DAY_MS;\n\nexport type TimestampTooltipProps = {\n  timestamp: Date | string | number | null | undefined;\n  rows?: (\"local\" | \"utc\" | \"unix\")[];\n  interactive?: boolean;\n  className?: string;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  align?: \"start\" | \"center\" | \"end\";\n  title?: string;\n  /** Force full labels (e.g., \"Australia/Melbourne\") instead of short labels (e.g., \"GMT+11\"). Defaults to auto-detect. */\n  fullLabels?: boolean;\n  children?: React.ReactNode;\n};\n\nfunction getLocalTimeZone(): string {\n  if (typeof Intl !== \"undefined\") {\n    try {\n      return Intl.DateTimeFormat().resolvedOptions().timeZone || \"Local\";\n    } catch (e) {}\n  }\n  return \"Local\";\n}\n\nexport function TimestampTooltip({\n  timestamp,\n  rows = [\"local\", \"utc\"],\n  interactive = true,\n  side = \"top\",\n  align = \"center\",\n  className,\n  title,\n  fullLabels,\n  children,\n}: TimestampTooltipProps) {\n  if (!timestamp || new Date(timestamp).toString() === \"Invalid Date\")\n    return <>{children}</>;\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild onClick={(e) => e.stopPropagation()}>\n        {children}\n      </TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent\n          className={cn(\"p-0\", className)}\n          side={side}\n          align={align}\n          sideOffset={8}\n        >\n          <TimestampTooltipContent\n            timestamp={timestamp}\n            rows={rows}\n            interactive={interactive}\n            title={title}\n            fullLabels={fullLabels}\n          />\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  );\n}\n\nfunction TimestampTooltipContent({\n  timestamp,\n  rows = [\"local\", \"utc\"],\n  interactive,\n  title,\n  fullLabels: forceFullLabels,\n}: Pick<\n  TimestampTooltipProps,\n  \"timestamp\" | \"rows\" | \"interactive\" | \"title\" | \"fullLabels\"\n>) {\n  if (!timestamp)\n    throw new Error(\"Falsy timestamp not permitted in TimestampTooltipContent\");\n\n  const date = new Date(timestamp);\n  const commonFormat: Intl.DateTimeFormatOptions = {\n    year: \"numeric\",\n    month: \"short\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    second: \"numeric\",\n    hour12: true,\n  };\n\n  const diff = new Date().getTime() - date.getTime();\n  const relativeDuration = intervalToDuration({\n    start: date,\n    end: new Date(),\n  });\n\n  const relative =\n    formatDuration(relativeDuration, {\n      delimiter: \", \",\n      format: [\n        \"years\",\n        \"months\",\n        \"days\",\n        ...(diff < MONTH_MS\n          ? [\n              \"hours\" as const,\n              ...(diff < DAY_MS\n                ? [\"minutes\" as const, \"seconds\" as const]\n                : []),\n            ]\n          : []),\n      ],\n    }) + \" ago\";\n\n  const items: {\n    label: string;\n    shortLabel?: string;\n    successMessageLabel: string;\n    value: string;\n    valueMono?: boolean;\n  }[] = useMemo(\n    () =>\n      rows.map(\n        (key) =>\n          ({\n            local: {\n              label: getLocalTimeZone(),\n              shortLabel: new Date()\n                .toLocaleTimeString(\"en-US\", { timeZoneName: \"short\" })\n                .split(\" \")[2],\n              successMessageLabel: \"local timestamp\",\n              value: date.toLocaleString(\"en-US\", commonFormat),\n            },\n            utc: {\n              label: \"UTC\",\n              shortLabel: \"UTC\",\n              successMessageLabel: \"UTC timestamp\",\n              value: new Date(date.getTime()).toLocaleString(\"en-US\", {\n                ...commonFormat,\n                timeZone: \"UTC\",\n              }),\n            },\n            unix: {\n              label: \"Timestamp\",\n              successMessageLabel: \"timestamp\",\n              value: Math.floor(date.getTime()).toString(),\n              valueMono: true,\n            },\n          })[key]!,\n      ),\n    [rows, date],\n  );\n\n  const shortLabels =\n    !forceFullLabels && items.every(({ shortLabel }) => shortLabel);\n\n  // Re-render every second to update the relative time\n  const [_, setRenderCount] = useState(0);\n  useEffect(() => {\n    const interval = setInterval(() => setRenderCount((c) => c + 1), 1000);\n    return () => clearInterval(interval);\n  }, []);\n\n  return (\n    <div className=\"flex max-w-[360px] flex-col gap-2 px-2.5 py-2 text-left text-xs\">\n      {title && (\n        <span className=\"font-medium text-muted-foreground\">{title}</span>\n      )}\n      <table>\n        {items.map((row, idx) => (\n          <tr\n            key={idx}\n            className={cn(\n              interactive &&\n                \"relative select-none before:absolute before:-inset-x-1 before:inset-y-0 before:rounded before:bg-muted before:opacity-0 before:content-[''] hover:cursor-copy hover:before:opacity-60 active:before:opacity-100\",\n            )}\n            onClick={\n              interactive\n                ? async () => {\n                    try {\n                      await navigator.clipboard.writeText(row.value);\n                      toast.success(\n                        `Copied ${row.successMessageLabel} to clipboard`,\n                      );\n                    } catch (e) {\n                      toast.error(\n                        `Failed to copy ${row.successMessageLabel} to clipboard`,\n                      );\n                      console.error(\n                        `Failed to copy ${row.successMessageLabel} to clipboard`,\n                        e,\n                      );\n                    }\n                  }\n                : undefined\n            }\n          >\n            <td className=\"relative py-0.5\">\n              <span\n                className={cn(\n                  \"truncate text-muted-foreground\",\n                  shortLabels && \"rounded bg-muted px-1 font-mono\",\n                )}\n                title={shortLabels ? row.label : undefined}\n              >\n                {shortLabels ? row.shortLabel : row.label}\n              </span>\n            </td>\n            <td\n              className={cn(\n                \"relative whitespace-nowrap py-0.5 pl-3 text-foreground\",\n                shortLabels && \"pl-2\",\n                row.valueMono && \"font-mono\",\n              )}\n            >\n              {row.value}\n            </td>\n          </tr>\n        ))}\n        {diff > 0 && (\n          <tr\n            className={cn(\n              interactive &&\n                \"relative select-none before:absolute before:-inset-x-1 before:inset-y-0 before:rounded before:bg-muted before:opacity-0 before:content-[''] hover:cursor-copy hover:before:opacity-60 active:before:opacity-100\",\n            )}\n            onClick={\n              interactive\n                ? async () => {\n                    try {\n                      await navigator.clipboard.writeText(relative);\n                      toast.success(\"Copied relative time to clipboard\");\n                    } catch (e) {\n                      toast.error(\"Failed to copy relative time to clipboard\");\n                      console.error(\"Failed to copy relative time\", e);\n                    }\n                  }\n                : undefined\n            }\n          >\n            <td className=\"relative py-0.5\">\n              <span\n                className={cn(\n                  \"truncate text-muted-foreground\",\n                  shortLabels && \"rounded bg-muted px-1 font-mono\",\n                )}\n              >\n                Relative\n              </span>\n            </td>\n            <td\n              className={cn(\n                \"relative whitespace-nowrap py-0.5 pl-3 text-foreground\",\n                shortLabels && \"pl-2\",\n              )}\n            >\n              {relative}\n            </td>\n          </tr>\n        )}\n      </table>\n    </div>\n  );\n}\n\nexport default TimestampTooltip;\n"
  },
  {
    "path": "components/ui/toggle-group.tsx",
    "content": "import * as React from \"react\"\nimport * as ToggleGroupPrimitive from \"@radix-ui/react-toggle-group\"\nimport { type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { toggleVariants } from \"@/components/ui/toggle\"\n\nconst ToggleGroupContext = React.createContext<\n  VariantProps<typeof toggleVariants>\n>({\n  size: \"default\",\n  variant: \"default\",\n})\n\nconst ToggleGroup = React.forwardRef<\n  React.ElementRef<typeof ToggleGroupPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &\n    VariantProps<typeof toggleVariants>\n>(({ className, variant, size, children, ...props }, ref) => (\n  <ToggleGroupPrimitive.Root\n    ref={ref}\n    className={cn(\"flex items-center justify-center gap-1\", className)}\n    {...props}\n  >\n    <ToggleGroupContext.Provider value={{ variant, size }}>\n      {children}\n    </ToggleGroupContext.Provider>\n  </ToggleGroupPrimitive.Root>\n))\n\nToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName\n\nconst ToggleGroupItem = React.forwardRef<\n  React.ElementRef<typeof ToggleGroupPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &\n    VariantProps<typeof toggleVariants>\n>(({ className, children, variant, size, ...props }, ref) => {\n  const context = React.useContext(ToggleGroupContext)\n\n  return (\n    <ToggleGroupPrimitive.Item\n      ref={ref}\n      className={cn(\n        toggleVariants({\n          variant: context.variant || variant,\n          size: context.size || size,\n        }),\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </ToggleGroupPrimitive.Item>\n  )\n})\n\nToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName\n\nexport { ToggleGroup, ToggleGroupItem }\n"
  },
  {
    "path": "components/ui/toggle.tsx",
    "content": "import * as React from \"react\"\nimport * as TogglePrimitive from \"@radix-ui/react-toggle\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst toggleVariants = cva(\n  \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-transparent\",\n        outline:\n          \"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n      },\n      size: {\n        default: \"h-10 px-3\",\n        sm: \"h-9 px-2.5\",\n        lg: \"h-11 px-5\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nconst Toggle = React.forwardRef<\n  React.ElementRef<typeof TogglePrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &\n    VariantProps<typeof toggleVariants>\n>(({ className, variant, size, ...props }, ref) => (\n  <TogglePrimitive.Root\n    ref={ref}\n    className={cn(toggleVariants({ variant, size, className }))}\n    {...props}\n  />\n))\n\nToggle.displayName = TogglePrimitive.Root.displayName\n\nexport { Toggle, toggleVariants }\n"
  },
  {
    "path": "components/ui/tooltip.tsx",
    "content": "import * as React from \"react\";\n\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst TooltipProvider = TooltipPrimitive.Provider;\n\nconst Tooltip = TooltipPrimitive.Root;\n\nconst TooltipTrigger = TooltipPrimitive.Trigger;\n\nconst TooltipPortal = TooltipPrimitive.Portal;\n\nconst TooltipArrow = TooltipPrimitive.Arrow;\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {\n    arrow?: boolean;\n    arrowClassName?: string;\n  }\n>(\n  (\n    {\n      className,\n      sideOffset = 4,\n      children,\n      arrow = false,\n      arrowClassName,\n      ...props\n    },\n    ref,\n  ) => (\n    <TooltipPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 overflow-hidden rounded-md border bg-popover px-2 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      {arrow ? (\n        <TooltipArrow className={cn(\"fill-popover\", arrowClassName)} />\n      ) : null}\n    </TooltipPrimitive.Content>\n  ),\n);\nTooltipContent.displayName = TooltipPrimitive.Content.displayName;\n\nexport const BadgeTooltip = ({\n  content,\n  children,\n  linkText,\n  link,\n  side = \"top\",\n  align = \"center\",\n  className,\n}: {\n  className?: string;\n  align?: \"start\" | \"center\" | \"end\";\n  link?: string;\n  content: string | React.ReactNode;\n  children: React.ReactNode;\n  linkText?: string;\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n}) => {\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild onClick={(e) => e.stopPropagation()}>\n        {children}\n      </TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent\n          className={cn(\n            \"max-w-72 text-center text-muted-foreground\",\n            className,\n          )}\n          side={side}\n          sideOffset={8}\n          align={align}\n        >\n          {typeof content === \"string\" ? (\n            <p>\n              {content}{\" \"}\n              {link && (\n                <a\n                  href={link}\n                  className=\"underline underline-offset-4 transition-all hover:text-gray-800 hover:dark:text-muted-foreground/80\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  onClick={(e) => e.stopPropagation()}\n                >\n                  {linkText || \"Learn more\"}\n                </a>\n              )}\n            </p>\n          ) : (\n            content\n          )}\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  );\n};\n\nexport const ButtonTooltip = ({\n  content,\n  sideOffset = 0,\n  className,\n  children,\n  link,\n}: {\n  content: string;\n  sideOffset?: number;\n  className?: string;\n  children: React.ReactNode;\n  link?: string;\n}) => {\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{children}</TooltipTrigger>\n      <TooltipPortal>\n        <TooltipContent\n          sideOffset={sideOffset}\n          className={cn(\n            \"max-w-72 bg-[#474e5a] text-center text-white\",\n            className,\n          )}\n        >\n          {link ? (\n            <p>\n              {content}{\" \"}\n              <a\n                href={link}\n                className=\"underline underline-offset-4 transition-all hover:text-gray-800 hover:dark:text-muted-foreground/80\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n              >\n                Learn more\n              </a>\n            </p>\n          ) : (\n            <p>{content}</p>\n          )}\n        </TooltipContent>\n      </TooltipPortal>\n    </Tooltip>\n  );\n};\n\nexport {\n  Tooltip,\n  TooltipTrigger,\n  TooltipPortal,\n  TooltipArrow,\n  TooltipContent,\n  TooltipProvider,\n};\n"
  },
  {
    "path": "components/ui/upgrade-button.tsx",
    "content": "import React, { ButtonHTMLAttributes, useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { CrownIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface UpgradeButtonProps {\n  text: string;\n  clickedPlan: PlanEnum;\n  trigger: string;\n  variant?: \"default\" | \"outline\" | \"ghost\";\n  size?: \"default\" | \"sm\" | \"lg\" | \"icon\";\n  className?: string;\n  highlightItem?: string[];\n  onClick?: () => void;\n  useModal?: boolean;\n  customText?: string;\n  type?: ButtonHTMLAttributes<HTMLButtonElement>[\"type\"];\n  disabled?: boolean;\n}\n\nexport function UpgradeButton({\n  text,\n  clickedPlan,\n  trigger,\n  variant = \"default\",\n  size = \"default\",\n  className,\n  highlightItem,\n  onClick,\n  useModal = true,\n  customText,\n  type = \"button\",\n  disabled,\n}: UpgradeButtonProps) {\n  const [open, setOpen] = useState(false);\n\n  const buttonContent = (\n    <Button\n      disabled={disabled}\n      type={type}\n      variant={variant}\n      size={size}\n      className={cn(\"gap-1.5\", className)}\n      onClick={onClick ? onClick : useModal ? () => setOpen(true) : undefined}\n    >\n      {customText ? (\n        <>\n          <CrownIcon className=\"h-4 w-4\" /> {customText}\n        </>\n      ) : (\n        <>\n          <CrownIcon className=\"h-4 w-4\" />\n          Upgrade to {text}\n        </>\n      )}\n    </Button>\n  );\n\n  if (!useModal || onClick || disabled) {\n    return buttonContent;\n  }\n\n  return (\n    <>\n      {buttonContent}\n      <UpgradePlanModal\n        clickedPlan={clickedPlan}\n        trigger={trigger}\n        open={open}\n        setOpen={setOpen}\n        highlightItem={highlightItem}\n      />\n    </>\n  );\n}\n\nexport function createUpgradeButton(\n  text: string,\n  clickedPlan: PlanEnum,\n  trigger: string,\n  options?: Partial<UpgradeButtonProps>,\n) {\n  return function UpgradeButtonComponent(props?: Partial<UpgradeButtonProps>) {\n    return (\n      <UpgradeButton\n        text={text}\n        clickedPlan={clickedPlan}\n        trigger={trigger}\n        {...options}\n        {...props}\n      />\n    );\n  };\n}\n"
  },
  {
    "path": "components/upload-notification.tsx",
    "content": "import { CheckIcon, XIcon } from \"lucide-react\";\n\nimport {\n  Drawer,\n  DrawerClose,\n  DrawerContent,\n  DrawerHeader,\n  DrawerTitle,\n} from \"@/components/ui/drawer\";\n\nimport { Gauge } from \"./ui/gauge\";\nimport { RejectedFile, UploadState } from \"./upload-zone\";\n\ninterface UploadNotificationDrawerProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  uploads: UploadState[];\n  setUploads: (uploads: UploadState[]) => void;\n  rejectedFiles: RejectedFile[];\n  setRejectedFiles: (rejected: RejectedFile[]) => void;\n  handleCloseDrawer: () => void;\n}\n\nexport function UploadNotificationDrawer({\n  open,\n  onOpenChange,\n  uploads,\n  setUploads,\n  rejectedFiles,\n  setRejectedFiles,\n  handleCloseDrawer,\n}: UploadNotificationDrawerProps) {\n  const uploadCount = uploads.length;\n  const failedCount = rejectedFiles.length;\n\n  const onOpenChangeHandler = (open: boolean) => {\n    onOpenChange(open);\n    if (!open) {\n      setUploads([]);\n      setRejectedFiles([]);\n    }\n  };\n\n  return (\n    <div className=\"h-50 fixed bottom-0 right-20\">\n      <Drawer\n        modal={false}\n        open={open}\n        onOpenChange={onOpenChangeHandler}\n        dismissible={false}\n      >\n        <DrawerContent className=\"inset-x-auto right-6 max-h-[250px] w-1/5 min-w-[350px] max-w-[400px] shadow-md focus-visible:outline-none\">\n          <DrawerHeader className=\"flex h-10 items-center justify-between rounded-t-lg border-b border-transparent bg-gray-100 dark:bg-gray-900\">\n            <div className=\"flex items-center space-x-1\">\n              <DrawerTitle>{uploadCount} uploads</DrawerTitle>\n              {failedCount > 0 ? (\n                <p className=\"text-sm\">\n                  (\n                  <span className=\"text-destructive\">{failedCount} failed</span>\n                  )\n                </p>\n              ) : null}\n            </div>\n            <DrawerClose\n              className=\"rounded-full p-1 hover:bg-gray-200 hover:dark:bg-gray-800\"\n              onClick={handleCloseDrawer}\n            >\n              <XIcon className=\"h-6 w-6\" />\n            </DrawerClose>\n          </DrawerHeader>\n          <div className=\"mx-auto flex w-full flex-1 flex-col overflow-y-auto\">\n            {uploads.map((upload, index) => (\n              <div\n                key={index}\n                className=\"px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50\"\n              >\n                {upload.progress === 100 && upload.documentId ? (\n                  <a\n                    href={`/documents/${upload.documentId}`}\n                    className=\"flex items-center justify-between group\"\n                  >\n                    <span className=\"w-72 truncate text-sm\">{upload.fileName}</span>\n                    <CheckIcon\n                      className=\"h-6 w-6 rounded-full bg-emerald-500 p-1 text-background\"\n                      strokeWidth={3}\n                    />\n                  </a>\n                ) : (\n                  <div className=\"flex items-center justify-between\">\n                    <span className=\"w-72 truncate text-sm text-gray-500 dark:text-gray-400\">{upload.fileName}</span>\n                    <Gauge\n                      value={upload.progress}\n                      size={\"xs\"}\n                      showValue={true}\n                    />\n                  </div>\n                )}\n              </div>\n            ))}\n            {rejectedFiles.map((rejected, index) => (\n              <div\n                key={index}\n                className=\"flex items-center justify-between px-4 py-2.5 text-sm text-red-500 hover:bg-gray-50 dark:hover:bg-gray-800/50\"\n              >\n                <span className=\"w-72 truncate\">{rejected.fileName}</span>\n                <span className=\"text-xs\">{rejected.message}</span>\n              </div>\n            ))}\n          </div>\n        </DrawerContent>\n      </Drawer>\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/upload-zone.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useEffect, useMemo, useRef } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { useSession } from \"next-auth/react\";\nimport { DropEvent, FileRejection, useDropzone } from \"react-dropzone\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport {\n  FREE_PLAN_ACCEPTED_FILE_TYPES,\n  FULL_PLAN_ACCEPTED_FILE_TYPES,\n  SUPPORTED_DOCUMENT_MIME_TYPES,\n} from \"@/lib/constants\";\nimport { DocumentData, createDocument } from \"@/lib/documents/create-document\";\nimport { resumableUpload } from \"@/lib/files/tus-upload\";\nimport {\n  createFolderInBoth,\n  createFolderInMainDocs,\n  determineFolderPaths,\n  isSystemFile,\n} from \"@/lib/folders/create-folder\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { useTeamSettings } from \"@/lib/swr/use-team-settings\";\nimport { CustomUser } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { getSupportedContentType } from \"@/lib/utils/get-content-type\";\nimport {\n  getFileSizeLimit,\n  getFileSizeLimits,\n} from \"@/lib/utils/get-file-size-limits\";\nimport { getPagesCount } from \"@/lib/utils/get-page-number-count\";\n\n// Originally these mime values were directly used in the dropzone hook.\n// There was a solid reason to take them out of the scope, primarily to solve a browser compatibility issue to determine the file type when user dropped a folder.\n// you will figure out how this change helped to fix the compatibility issue once you have went through reading of `getFilesFromDropEvent` and `traverseFolder`\nconst acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrial =\n  FREE_PLAN_ACCEPTED_FILE_TYPES;\nconst allAcceptableDropZoneMimeTypes = FULL_PLAN_ACCEPTED_FILE_TYPES;\n\ninterface FileWithPaths extends File {\n  path?: string;\n  whereToUploadPath?: string;\n  dataroomUploadPath?: string;\n}\n\nexport interface UploadState {\n  fileName: string;\n  progress: number;\n  documentId?: string;\n  uploadId: string;\n}\n\nexport interface RejectedFile {\n  fileName: string;\n  message: string;\n}\n\ninterface UploadZoneProps extends React.PropsWithChildren {\n  onUploadStart: (uploads: UploadState[]) => void;\n  onUploadProgress: (\n    index: number,\n    progress: number,\n    documentId?: string,\n  ) => void;\n  onUploadRejected: (rejected: RejectedFile[]) => void;\n  onUploadSuccess?: (\n    files: {\n      fileName: string;\n      documentId: string;\n      dataroomDocumentId: string;\n    }[],\n  ) => void;\n  setUploads: React.Dispatch<React.SetStateAction<UploadState[]>>;\n  setRejectedFiles: React.Dispatch<React.SetStateAction<RejectedFile[]>>;\n  folderPathName?: string;\n  dataroomId?: string;\n  dataroomName?: string;\n}\n\nexport default function UploadZone({\n  children,\n  onUploadStart,\n  onUploadProgress,\n  onUploadRejected,\n  onUploadSuccess,\n  folderPathName,\n  setUploads,\n  setRejectedFiles,\n  dataroomId,\n  dataroomName,\n}: UploadZoneProps) {\n  const analytics = useAnalytics();\n  const { plan, isFree, isTrial } = usePlan();\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { data: session } = useSession();\n  const { limits, canAddDocuments, isPaused } = useLimits();\n  const hasDocumentLimit = limits?.documents != null && limits.documents > 0;\n  const remainingDocuments = hasDocumentLimit\n    ? limits.documents - (limits?.usage?.documents ?? 0)\n    : Infinity;\n\n  // Fetch team settings with proper revalidation - ensures settings stay fresh across tabs\n  const { settings: teamSettings } = useTeamSettings(teamInfo?.currentTeam?.id);\n  const replicateDataroomFolders =\n    teamSettings?.replicateDataroomFolders ?? true;\n\n  // Track if we've created the dataroom folder in \"All Documents\" for non-replication mode\n  // Using promise-lock pattern to prevent race conditions during concurrent folder creation\n  const dataroomFolderPathRef = useRef<string | null>(null);\n  const dataroomFolderCreationPromiseRef = useRef<Promise<string> | null>(null);\n  const fileLimitTruncatedRef = useRef(false);\n\n  // Reset the cached dataroom folder path when the replication setting changes\n  // This ensures we don't use stale cached paths if the setting is toggled\n  useEffect(() => {\n    dataroomFolderPathRef.current = null;\n    dataroomFolderCreationPromiseRef.current = null;\n  }, [replicateDataroomFolders, dataroomId]);\n\n  const fileSizeLimits = useMemo(\n    () =>\n      getFileSizeLimits({\n        limits,\n        isFree,\n        isTrial,\n      }),\n    [limits, isFree, isTrial],\n  );\n\n  const acceptableDropZoneFileTypes =\n    isFree && !isTrial\n      ? acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrial\n      : allAcceptableDropZoneMimeTypes;\n\n  // Helper function to get or create the dataroom folder in \"All Documents\"\n  // Uses promise-lock pattern to prevent concurrent creation attempts\n  const getOrCreateDataroomFolder = useCallback(async (): Promise<string> => {\n    // If we already have the path cached, return it immediately\n    if (dataroomFolderPathRef.current) {\n      return dataroomFolderPathRef.current;\n    }\n\n    // If there's an ongoing creation, await it\n    if (dataroomFolderCreationPromiseRef.current) {\n      return dataroomFolderCreationPromiseRef.current;\n    }\n\n    // Start a new creation process\n    const creationPromise = (async () => {\n      try {\n        if (!teamInfo?.currentTeam?.id || !dataroomName) {\n          throw new Error(\"Missing team ID or dataroom name\");\n        }\n\n        // First check if the folder already exists\n        const existingFoldersResponse = await fetch(\n          `/api/teams/${teamInfo.currentTeam.id}/folders?root=true`,\n        );\n\n        if (existingFoldersResponse.ok) {\n          const existingFolders = await existingFoldersResponse.json();\n          const existingDataroomFolder = existingFolders.find(\n            (folder: any) => folder.name === dataroomName,\n          );\n\n          if (existingDataroomFolder) {\n            // Folder already exists, use it\n            const folderPath = existingDataroomFolder.path.startsWith(\"/\")\n              ? existingDataroomFolder.path.slice(1)\n              : existingDataroomFolder.path;\n            dataroomFolderPathRef.current = folderPath;\n            return folderPath;\n          }\n        }\n\n        // Folder doesn't exist, create it\n        const dataroomFolderResponse = await createFolderInMainDocs({\n          teamId: teamInfo.currentTeam.id,\n          name: dataroomName,\n          path: undefined, // Create at root level\n        });\n\n        const folderPath = dataroomFolderResponse.path.startsWith(\"/\")\n          ? dataroomFolderResponse.path.slice(1)\n          : dataroomFolderResponse.path;\n\n        dataroomFolderPathRef.current = folderPath;\n\n        analytics.capture(\"Dataroom Folder Created in Main Docs\", {\n          folderName: dataroomName,\n          dataroomId,\n        });\n\n        return folderPath;\n      } catch (error) {\n        console.error(\"Error handling dataroom folder:\", error);\n        // Clear the promise ref on error so subsequent attempts can retry\n        dataroomFolderCreationPromiseRef.current = null;\n        // Use dataroom name as fallback path\n        const fallbackPath = dataroomName || \"\";\n        dataroomFolderPathRef.current = fallbackPath;\n        return fallbackPath;\n      } finally {\n        // Clear the promise ref once creation is complete\n        dataroomFolderCreationPromiseRef.current = null;\n      }\n    })();\n\n    // Store the promise so concurrent callers can await it\n    dataroomFolderCreationPromiseRef.current = creationPromise;\n    return creationPromise;\n  }, [teamInfo, dataroomName, dataroomId, analytics]);\n\n  // this var will help to determine the correct api endpoint to request folder creation (If needed).\n  const endpointTargetType = dataroomId\n    ? `datarooms/${dataroomId}/folders`\n    : \"folders\";\n\n  const onDropRejected = useCallback(\n    (rejectedFiles: FileRejection[]) => {\n      const hasTooManyFiles = rejectedFiles.some(({ errors }) =>\n        errors.some(({ code }) => code === \"too-many-files\"),\n      );\n\n      if (hasTooManyFiles) {\n        const maxFiles = fileSizeLimits.maxFiles ?? 150;\n        toast.error(\n          `You're trying to upload ${rejectedFiles.length} files, but you can only upload up to ${maxFiles} files at once. Please upload in smaller batches.`,\n          { duration: 8000 },\n        );\n        onUploadRejected([\n          {\n            fileName: `${rejectedFiles.length} files selected`,\n            message: `Maximum ${maxFiles} files per upload`,\n          },\n        ]);\n        return;\n      }\n\n      const rejected = rejectedFiles.map(({ file, errors }) => {\n        let message = \"\";\n        if (errors.find(({ code }) => code === \"file-too-large\")) {\n          const fileSizeLimitMB = getFileSizeLimit(file.type, fileSizeLimits);\n          message = `File size too big (max. ${fileSizeLimitMB} MB). Upgrade to a paid plan to increase the limit.`;\n        } else if (errors.find(({ code }) => code === \"file-invalid-type\")) {\n          const isSupported = SUPPORTED_DOCUMENT_MIME_TYPES.includes(file.type);\n          message = `File type not supported ${\n            isFree && !isTrial && isSupported ? `on free plan` : \"\"\n          }`;\n        }\n        return { fileName: file.name, message };\n      });\n      onUploadRejected(rejected);\n    },\n    [onUploadRejected, fileSizeLimits, isFree, isTrial],\n  );\n\n  const onDrop = useCallback(\n    async (acceptedFiles: FileWithPaths[]) => {\n      // Check if team is paused\n      if (isPaused) {\n        toast.error(\n          \"Your subscription is paused. Resume your subscription to upload documents.\",\n          {\n            action: {\n              label: \"Go to Billing\",\n              onClick: () => router.push(\"/settings/billing\"),\n            },\n          },\n        );\n        return;\n      }\n\n      if (hasDocumentLimit && remainingDocuments <= 0) {\n        toast.error(\n          `You've reached your plan's document limit (${limits?.usage?.documents}/${limits?.documents} documents). Upgrade your plan to upload more.`,\n          {\n            action: {\n              label: \"Upgrade\",\n              onClick: () => router.push(\"/settings/billing\"),\n            },\n            duration: 8000,\n          },\n        );\n        return;\n      }\n\n      let filesToUpload = acceptedFiles;\n\n      if (fileLimitTruncatedRef.current) {\n        // Folder traversal was already capped at remainingDocuments –\n        // no extra folders were created, just show the warning.\n        fileLimitTruncatedRef.current = false;\n        toast.warning(\n          `Your upload was limited to ${acceptedFiles.length} file${acceptedFiles.length === 1 ? \"\" : \"s\"} because your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? \"\" : \"s\"} (${limits?.usage?.documents}/${limits?.documents} used).`,\n          {\n            action: {\n              label: \"Upgrade\",\n              onClick: () => router.push(\"/settings/billing\"),\n            },\n            duration: 10000,\n          },\n        );\n      } else if (hasDocumentLimit && acceptedFiles.length > remainingDocuments) {\n        // Safety net for the file-picker path (no folder traversal) or\n        // race conditions where the cap was slightly exceeded.\n        const skippedCount = acceptedFiles.length - remainingDocuments;\n        toast.warning(\n          `You're trying to upload ${acceptedFiles.length} files, but your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? \"\" : \"s\"} (${limits?.usage?.documents}/${limits?.documents} used). ${skippedCount} file${skippedCount === 1 ? \"\" : \"s\"} will be skipped.`,\n          {\n            action: {\n              label: \"Upgrade\",\n              onClick: () => router.push(\"/settings/billing\"),\n            },\n            duration: 10000,\n          },\n        );\n        filesToUpload = acceptedFiles.slice(0, remainingDocuments);\n      }\n\n      // Validate files and separate into valid and invalid\n      const validatedFiles = filesToUpload.reduce<{\n        valid: FileWithPaths[];\n        invalid: { fileName: string; message: string }[];\n      }>(\n        (acc, file) => {\n          const fileSizeLimitMB = getFileSizeLimit(file.type, fileSizeLimits);\n          const fileSizeLimit = fileSizeLimitMB * 1024 * 1024; // Convert to bytes\n\n          if (file.size > fileSizeLimit) {\n            acc.invalid.push({\n              fileName: file.name,\n              message: `File size too big (max. ${fileSizeLimitMB} MB)${\n                isFree && !isTrial\n                  ? \". Upgrade to a paid plan to increase the limit\"\n                  : \"\"\n              }`,\n            });\n          } else {\n            acc.valid.push(file);\n          }\n          return acc;\n        },\n        { valid: [], invalid: [] },\n      );\n\n      // Handle rejected files first\n      if (validatedFiles.invalid.length > 0) {\n        setRejectedFiles((prev) => [...validatedFiles.invalid, ...prev]);\n\n        // If all files were rejected, show a summary toast\n        if (validatedFiles.valid.length === 0) {\n          toast.error(\n            `${validatedFiles.invalid.length} file(s) exceeded size limits`,\n          );\n          return;\n        }\n      }\n\n      // Continue with valid files\n      const newUploads = validatedFiles.valid.map((file) => ({\n        fileName: file.name,\n        progress: 0,\n        uploadId: crypto.randomUUID(),\n      }));\n\n      onUploadStart(newUploads);\n\n      const uploadPromises = validatedFiles.valid.map(async (file, index) => {\n        // Due to `getFilesFromEvent` file.path will always hold a valid value and represents the value of webkitRelativePath.\n        // We no longer need to use webkitRelativePath because everything is been handled in `getFilesFromEvent`\n        const path = file.path || file.name;\n\n        // count the number of pages in the file\n        let numPages = 1;\n        if (file.type === \"application/pdf\") {\n          const buffer = await file.arrayBuffer();\n          numPages = await getPagesCount(buffer);\n\n          if (numPages > fileSizeLimits.maxPages) {\n            setUploads((prev) =>\n              prev.filter((upload) => upload.fileName !== file.name),\n            );\n\n            return setRejectedFiles((prev) => [\n              {\n                fileName: file.name,\n                message: `File has too many pages (max. ${fileSizeLimits.maxPages})`,\n              },\n              ...prev,\n            ]);\n          }\n        }\n\n        const { complete } = await resumableUpload({\n          file, // File\n          onProgress: (bytesUploaded, bytesTotal) => {\n            const progress = Math.min(\n              Math.round((bytesUploaded / bytesTotal) * 100),\n              99,\n            );\n            setUploads((prevUploads) => {\n              const updatedUploads = prevUploads.map((upload) =>\n                upload.uploadId === newUploads[index].uploadId\n                  ? { ...upload, progress }\n                  : upload,\n              );\n              const currentUpload = updatedUploads.find(\n                (upload) => upload.uploadId === newUploads[index].uploadId,\n              );\n\n              onUploadProgress(index, progress, currentUpload?.documentId);\n              return updatedUploads;\n            });\n          },\n          onError: (error) => {\n            setUploads((prev) =>\n              prev.filter(\n                (upload) => upload.uploadId !== newUploads[index].uploadId,\n              ),\n            );\n\n            setRejectedFiles((prev) => [\n              { fileName: file.name, message: \"Error uploading file\" },\n              ...prev,\n            ]);\n          },\n          ownerId: (session?.user as CustomUser).id,\n          teamId: teamInfo?.currentTeam?.id as string,\n          numPages,\n          relativePath: path.substring(0, path.lastIndexOf(\"/\")),\n        });\n\n        const uploadResult = await complete;\n\n        let contentType = uploadResult.fileType;\n        let supportedFileType = getSupportedContentType(contentType) ?? \"\";\n\n        if (\n          uploadResult.fileName.endsWith(\".dwg\") ||\n          uploadResult.fileName.endsWith(\".dxf\")\n        ) {\n          supportedFileType = \"cad\";\n          contentType = `image/vnd.${uploadResult.fileName.split(\".\").pop()}`;\n        }\n\n        if (uploadResult.fileName.endsWith(\".xlsm\")) {\n          supportedFileType = \"sheet\";\n          contentType = \"application/vnd.ms-excel.sheet.macroEnabled.12\";\n        }\n\n        if (\n          uploadResult.fileName.endsWith(\".kml\") ||\n          uploadResult.fileName.endsWith(\".kmz\")\n        ) {\n          supportedFileType = \"map\";\n          contentType = `application/vnd.google-earth.${uploadResult.fileName.endsWith(\".kml\") ? \"kml+xml\" : \"kmz\"}`;\n        }\n\n        const documentData: DocumentData = {\n          key: uploadResult.id,\n          supportedFileType: supportedFileType,\n          name: file.name,\n          storageType: DocumentStorageType.S3_PATH,\n          contentType: contentType,\n          fileSize: file.size,\n        };\n\n        const fileUploadPathName = file?.whereToUploadPath;\n        const dataroomUploadPathName = file?.dataroomUploadPath;\n\n        const response = await createDocument({\n          documentData,\n          teamId: teamInfo?.currentTeam?.id as string,\n          numPages: uploadResult.numPages,\n          folderPathName: fileUploadPathName,\n        });\n\n        // add the new document to the list\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`);\n\n        fileUploadPathName &&\n          mutate(\n            `/api/teams/${teamInfo?.currentTeam?.id}/folders/documents/${fileUploadPathName}`,\n          );\n\n        const document = await response.json();\n        let dataroomResponse;\n        if (dataroomId) {\n          try {\n            dataroomResponse = await fetch(\n              `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents`,\n              {\n                method: \"POST\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify({\n                  documentId: document.id,\n                  folderPathName: dataroomUploadPathName || fileUploadPathName,\n                }),\n              },\n            );\n\n            if (!dataroomResponse?.ok) {\n              const { message } = await dataroomResponse.json();\n              console.error(\n                \"An error occurred while adding document to the dataroom: \",\n                message,\n              );\n              return;\n            }\n\n            mutate(\n              `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/documents`,\n            );\n            (dataroomUploadPathName || fileUploadPathName) &&\n              mutate(\n                `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders/documents/${dataroomUploadPathName || fileUploadPathName}`,\n              );\n          } catch (error) {\n            console.error(\n              \"An error occurred while adding document to the dataroom: \",\n              error,\n            );\n          }\n        }\n\n        // update progress to 100%\n        setUploads((prevUploads) =>\n          prevUploads.map((upload) =>\n            upload.uploadId === newUploads[index].uploadId\n              ? { ...upload, progress: 100, documentId: document.id }\n              : upload,\n          ),\n        );\n\n        analytics.capture(\"Document Added\", {\n          documentId: document.id,\n          name: document.name,\n          numPages: document.numPages,\n          path: router.asPath,\n          type: document.type,\n          contentType: document.contentType,\n          teamId: teamInfo?.currentTeam?.id,\n          bulkupload: true,\n          dataroomId: dataroomId,\n          $set: {\n            teamId: teamInfo?.currentTeam?.id,\n            teamPlan: plan,\n          },\n        });\n        const dataroomDocumentId = dataroomResponse?.ok\n          ? (await dataroomResponse.json()).id\n          : null;\n\n        return { ...document, dataroomDocumentId: dataroomDocumentId };\n      });\n\n      const documents = Promise.all(uploadPromises).finally(() => {\n        /* If it a parentFolder was created prior to the upload, we would need to update that\n           how many documents and folders does this folder contain rather than displaying 0\n            */\n\n        mutate(\n          `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}?root=true`,\n        );\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}`);\n        folderPathName &&\n          mutate(\n            `/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${folderPathName}`,\n          );\n      });\n      const uploadedDocuments = await documents;\n      const dataroomDocuments = uploadedDocuments.map((document) => ({\n        documentId: document.id,\n        dataroomDocumentId: document.dataroomDocumentId,\n        fileName: document.name,\n      }));\n      onUploadSuccess?.(dataroomDocuments);\n    },\n    [\n      onUploadStart,\n      onUploadProgress,\n      endpointTargetType,\n      fileSizeLimits,\n      isFree,\n      isTrial,\n      isPaused,\n      hasDocumentLimit,\n      remainingDocuments,\n    ],\n  );\n\n  const getFilesFromEvent = useCallback(\n    async (event: DropEvent) => {\n      // This callback also run when event.type =`dragenter`. We only need to compute files when the event.type is `drop`.\n      if (\"type\" in event && event.type !== \"drop\" && event.type !== \"change\") {\n        return [];\n      }\n\n      fileLimitTruncatedRef.current = false;\n      const fileLimit =\n        hasDocumentLimit && isFinite(remainingDocuments)\n          ? Math.max(0, remainingDocuments)\n          : Infinity;\n      let collectedFileCount = 0;\n\n      // Early check: skip folder traversal (and folder creation) if document limit is already reached\n      if (fileLimit <= 0) {\n        return [];\n      }\n\n      let filesToBePassedToOnDrop: FileWithPaths[] = [];\n\n      /** *********** START OF `traverseFolder` *********** */\n      const traverseFolder = async (\n        entry: FileSystemEntry,\n        parentPathOfThisEntry?: string,\n        dataroomParentPath?: string,\n      ): Promise<FileWithPaths[]> => {\n        /**\n         * Summary of this function:\n         *  1. if it find a folder then corresponding folder will be created at backend.\n         *  2. Smoothly handles the deeply nested folders.\n         *  3. Upon folder creation it assign the path and whereToUploadPath to each entry. (Those values will be helpful for `onDrop` to  upload document correctly)\n         */\n\n        let files: FileWithPaths[] = [];\n\n        if (isSystemFile(entry.name)) {\n          return files;\n        }\n\n        if (collectedFileCount >= fileLimit) {\n          return files;\n        }\n\n        if (entry.isDirectory) {\n          /**\n           * Let's create the folder.\n           * Fact that reader can skip: For Consistency, child files will only be pushed if folder successfully gets created.\n           */\n          try {\n            // An empty folder name can cause the unexpected url problems.\n            if (entry.name.trim() === \"\") {\n              setRejectedFiles((prev) => [\n                {\n                  fileName: entry.name,\n                  message: \"Folder name cannot be empty\",\n                },\n                ...prev,\n              ]);\n              throw new Error(\"Folder name cannot be empty\");\n            }\n\n            if (!teamInfo?.currentTeam?.id) {\n              /** This case probably may not happen */\n              setRejectedFiles((prev) => [\n                {\n                  fileName: \"Unknown Team\",\n                  message: \"Team Id not found\",\n                },\n                ...prev,\n              ]);\n              throw new Error(\"No team found\");\n            }\n\n            // Create folder in main documents if not in dataroom\n            if (!dataroomId) {\n              // Create folder in main documents only\n              const { path: folderPath } = await createFolderInMainDocs({\n                teamId: teamInfo.currentTeam.id,\n                name: entry.name,\n                path: parentPathOfThisEntry ?? folderPathName,\n              });\n\n              analytics.capture(\"Folder Added\", { folderName: entry.name });\n\n              const dirReader = (\n                entry as FileSystemDirectoryEntry\n              ).createReader();\n              const subEntries = await new Promise<FileSystemEntry[]>(\n                (resolve) => dirReader.readEntries(resolve),\n              );\n\n              const filteredSubEntries = subEntries.filter(\n                (subEntry) => !isSystemFile(subEntry.name),\n              );\n\n              const resolvedFolderPath = folderPath.startsWith(\"/\")\n                ? folderPath.slice(1)\n                : folderPath;\n\n              for (const subEntry of filteredSubEntries) {\n                files.push(\n                  ...(await traverseFolder(\n                    subEntry,\n                    resolvedFolderPath,\n                    undefined,\n                  )),\n                );\n              }\n            } else {\n              const isFirstLevelFolder =\n                (parentPathOfThisEntry ?? folderPathName) === folderPathName;\n\n              const {\n                parentDataroomPath: targetParentDataroomPath,\n                parentMainDocsPath: targetParentMainDocsPath,\n              } = determineFolderPaths({\n                currentDataroomPath: dataroomParentPath ?? folderPathName,\n                currentMainDocsPath: parentPathOfThisEntry,\n                isFirstLevelFolder,\n              });\n\n              // If replication is disabled, ensure the dataroom folder exists in \"All Documents\"\n              // Uses promise-lock pattern to prevent race conditions\n              if (!replicateDataroomFolders && dataroomName) {\n                await getOrCreateDataroomFolder();\n              }\n\n              const { dataroomPath, mainDocsPath } = await createFolderInBoth({\n                teamId: teamInfo.currentTeam.id,\n                dataroomId,\n                name: entry.name,\n                parentMainDocsPath: targetParentMainDocsPath,\n                parentDataroomPath: targetParentDataroomPath,\n                setRejectedFiles,\n                analytics,\n                replicateDataroomFolders,\n              });\n\n              const dirReader = (\n                entry as FileSystemDirectoryEntry\n              ).createReader();\n              const subEntries = await new Promise<FileSystemEntry[]>(\n                (resolve) => dirReader.readEntries(resolve),\n              );\n\n              const filteredSubEntries = subEntries.filter(\n                (subEntry) => !isSystemFile(subEntry.name),\n              );\n\n              // Use the resolved paths for all children\n              // Guard against undefined mainDocsPath when replication is disabled\n              const resolvedMainDocsPath = mainDocsPath\n                ? mainDocsPath.startsWith(\"/\")\n                  ? mainDocsPath.slice(1)\n                  : mainDocsPath\n                : undefined;\n              const resolvedDataroomPath = dataroomPath.startsWith(\"/\")\n                ? dataroomPath.slice(1)\n                : dataroomPath;\n\n              for (const subEntry of filteredSubEntries) {\n                files.push(\n                  ...(await traverseFolder(\n                    subEntry,\n                    resolvedMainDocsPath,\n                    resolvedDataroomPath,\n                  )),\n                );\n              }\n            }\n          } catch (error) {\n            console.error(\n              \"An error occurred while creating the folder: \",\n              error,\n            );\n            setRejectedFiles((prev) => [\n              {\n                fileName: entry.name,\n                message: \"Failed to create the folder\",\n              },\n              ...prev,\n            ]);\n          }\n        } else if (entry.isFile) {\n          if (isSystemFile(entry.name)) {\n            return files;\n          }\n\n          if (collectedFileCount >= fileLimit) {\n            return files;\n          }\n\n          let file = await new Promise<FileWithPaths>((resolve) =>\n            (entry as FileSystemFileEntry).file(resolve),\n          );\n\n          /** In some browsers e.g firefox is not able to detect the file type. (This only happens when user upload folder) */\n          const browserFileTypeCompatibilityIssue = file.type === \"\";\n\n          if (browserFileTypeCompatibilityIssue) {\n            const fileExtension = file.name.split(\".\").pop()?.toLowerCase();\n            let correctMimeType: string | undefined;\n            if (fileExtension) {\n              // Iterate through acceptableDropZoneFileTypes to find the MIME type for the extension\n              for (const [mime, extsUntyped] of Object.entries(\n                acceptableDropZoneFileTypes,\n              )) {\n                const exts = extsUntyped as string[]; // Explicitly type exts\n                if (\n                  exts.some((ext) => ext.toLowerCase() === \".\" + fileExtension)\n                ) {\n                  correctMimeType = mime;\n                  break;\n                }\n              }\n            }\n\n            if (correctMimeType) {\n              // if we can't do like ```file.type = fileType``` because of [Error: Setting getter-only property \"type\"]\n              // The following is the only best way to resolve the problem\n              file = new File([file], file.name, {\n                type: correctMimeType,\n                lastModified: file.lastModified,\n              });\n            }\n          }\n\n          // Reason of removing \"/\" because webkitRelativePath doesn't start with \"/\"\n          file.path = entry.fullPath.startsWith(\"/\")\n            ? entry.fullPath.substring(1)\n            : entry.fullPath;\n\n          // Determine where to upload in \"All Documents\"\n          if (!replicateDataroomFolders && dataroomId && dataroomName) {\n            // If replication is disabled, ensure the dataroom folder exists and use it\n            // This await is safe because getOrCreateDataroomFolder uses a promise-lock\n            const dataroomFolderPath = await getOrCreateDataroomFolder();\n            file.whereToUploadPath = dataroomFolderPath;\n          } else {\n            // If replication is enabled or not in a dataroom, use the normal folder path\n            file.whereToUploadPath = parentPathOfThisEntry ?? folderPathName;\n          }\n\n          file.dataroomUploadPath = dataroomParentPath;\n\n          files.push(file);\n          collectedFileCount++;\n        }\n\n        return files;\n      };\n      /** *********** END OF `traverseFolder` *********** */\n\n      if (\"dataTransfer\" in event && event.dataTransfer) {\n        const items = event.dataTransfer.items;\n\n        const fileResults = await Promise.all(\n          Array.from(items, (item) => {\n            // MDN Note: This function is implemented as webkitGetAsEntry() in non-WebKit browsers including Firefox at this time; it may be renamed to getAsEntry() in the future, so you should code defensively, looking for both.\n            const entry =\n              (typeof item?.webkitGetAsEntry === \"function\" &&\n                item.webkitGetAsEntry()) ??\n              (typeof (item as any)?.getAsEntry === \"function\" &&\n                (item as any).getAsEntry()) ??\n              null;\n            return entry\n              ? traverseFolder(\n                  entry,\n                  folderPathName,\n                  dataroomId ? folderPathName : undefined,\n                )\n              : [];\n          }),\n        );\n        fileResults.forEach((fileResult) =>\n          filesToBePassedToOnDrop.push(...fileResult),\n        );\n      } else if (\n        \"target\" in event &&\n        event.target &&\n        event.target instanceof HTMLInputElement &&\n        event.target.files\n      ) {\n        for (let i = 0; i < event.target.files.length; i++) {\n          const file: FileWithPaths = event.target.files[i];\n          file.path = file.name;\n          file.whereToUploadPath = folderPathName;\n          file.dataroomUploadPath = folderPathName;\n          filesToBePassedToOnDrop.push(event.target.files[i]);\n        }\n      }\n\n      if (isFinite(fileLimit) && collectedFileCount >= fileLimit) {\n        fileLimitTruncatedRef.current = true;\n      }\n\n      return filesToBePassedToOnDrop;\n    },\n    [\n      folderPathName,\n      endpointTargetType,\n      teamInfo,\n      dataroomId,\n      dataroomName,\n      analytics,\n      setRejectedFiles,\n      acceptableDropZoneFileTypes,\n      getOrCreateDataroomFolder,\n      hasDocumentLimit,\n      remainingDocuments,\n    ],\n  );\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    accept: acceptableDropZoneFileTypes,\n    multiple: true,\n    // maxSize: maxSize * 1024 * 1024, // 30 MB\n    maxFiles: fileSizeLimits.maxFiles ?? 150,\n    onDrop,\n    onDropRejected,\n    getFilesFromEvent,\n  });\n\n  return (\n    <div\n      {...getRootProps({ onClick: (evt) => evt.stopPropagation() })}\n      className={cn(\n        \"relative\",\n        dataroomId ? \"min-h-[calc(100vh-350px)]\" : \"min-h-[calc(100vh-270px)]\",\n      )}\n    >\n      <div\n        className={cn(\n          \"absolute inset-0 z-40 -m-1 rounded-lg border-2 border-dashed\",\n          isDragActive\n            ? \"pointer-events-auto border-primary/50 bg-gray-100/75 backdrop-blur-sm dark:bg-gray-800/75\"\n            : \"pointer-events-none border-none\",\n        )}\n      >\n        <input\n          {...getInputProps()}\n          name=\"file\"\n          id=\"upload-multi-files-zone\"\n          className=\"sr-only\"\n        />\n\n        {isDragActive && (\n          <div className=\"sticky top-1/2 z-50 -translate-y-1/2 px-2\">\n            <div className=\"flex justify-center\">\n              <div className=\"inline-flex flex-col rounded-lg bg-background/95 px-6 py-4 text-center ring-1 ring-gray-900/5 dark:bg-gray-900/95 dark:ring-white/10\">\n                <span className=\"font-medium text-foreground\">\n                  Drop your file(s) here\n                </span>\n                <p className=\"mt-1 text-xs leading-5 text-muted-foreground\">\n                  {isFree && !isTrial\n                    ? `Only *.pdf, *.xls, *.xlsx, *.csv, *.tsv, *.ods, *.png, *.jpeg, *.jpg`\n                    : `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.csv, *.tsv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}\n                </p>\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/user-agent-icon.tsx",
    "content": "import {\n  Gamepad2Icon,\n  MonitorIcon,\n  SmartphoneIcon,\n  TabletSmartphoneIcon,\n  TvIcon,\n  WatchIcon,\n} from \"lucide-react\";\n\nimport { BlurImage } from \"@/components/blur-image\";\nimport { Apple, Chrome, Safari } from \"@/components/ui/devices\";\n\nexport default function UAIcon({\n  display,\n  type,\n  className,\n}: {\n  display: string;\n  type: \"devices\" | \"browsers\" | \"os\";\n  className: string;\n}) {\n  if (type === \"devices\") {\n    switch (display) {\n      case \"Desktop\":\n        return <MonitorIcon className={className} />;\n      case \"Mobile\":\n        return <SmartphoneIcon className={className} />;\n      case \"Tablet\":\n        return <TabletSmartphoneIcon className={className} />;\n      case \"Wearable\":\n        return <WatchIcon className={className} />;\n      case \"Console\":\n        return <Gamepad2Icon className={className} />;\n      case \"Smarttv\":\n        return <TvIcon className={className} />;\n      default:\n        return <MonitorIcon className={className} />;\n    }\n  } else if (type === \"browsers\") {\n    if (display === \"Chrome\") {\n      return <Chrome className={className} />;\n    } else if (display === \"Safari\" || display === \"Mobile Safari\") {\n      return <Safari className={className} />;\n    } else {\n      return (\n        <BlurImage\n          src={`https://faisalman.github.io/ua-parser-js/images/browsers/${display.toLowerCase()}.png`}\n          alt={display}\n          width={20}\n          height={20}\n          className={className}\n        />\n      );\n    }\n  } else if (type === \"os\") {\n    if (display === \"Mac OS\" || display === \"iOS\") {\n      return <Apple className=\"-mx-1 h-5 w-5\" />;\n    } else {\n      return (\n        <BlurImage\n          src={`https://faisalman.github.io/ua-parser-js/images/os/${display.toLowerCase()}.png`}\n          alt={display}\n          width={30}\n          height={20}\n          className=\"h-4 w-5\"\n        />\n      );\n    }\n  } else {\n    return (\n      <BlurImage\n        src={`https://faisalman.github.io/ua-parser-js/images/companies/default.png`}\n        alt={display}\n        width={20}\n        height={20}\n        className={className}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "components/view/ScreenProtection.tsx",
    "content": "import { useState } from \"react\";\n\nimport { XOctagonIcon } from \"lucide-react\";\nimport { useHotkeys } from \"react-hotkeys-hook\";\n\nimport { Button } from \"@/components/ui/button\";\n\nexport const ScreenProtector = () => {\n  const [blockScreen, setBlockScreen] = useState<boolean>(false);\n\n  // Comprehensive screenshot prevention for all major platforms\n  const handleScreenshotAttempt = (event: KeyboardEvent) => {\n    setBlockScreen(true);\n    event.preventDefault();\n    event.stopPropagation();\n  };\n\n  // Windows screenshot shortcuts\n  useHotkeys(\n    [\n      \"printscreen\", // PrintScreen key\n      \"alt+printscreen\", // Alt + PrintScreen (active window)\n      \"meta+printscreen\", // Win + PrintScreen (save to file)\n      \"meta+shift+s\", // Win + Shift + S (Snipping Tool)\n      \"meta+g\", // Win + G (Game Bar)\n    ],\n    handleScreenshotAttempt,\n    {\n      preventDefault: true,\n      enableOnFormTags: true,\n      enableOnContentEditable: true,\n    },\n  );\n\n  // macOS screenshot shortcuts\n  useHotkeys(\n    [\n      \"meta+shift\",\n      \"meta+shift+3\", // Cmd + Shift + 3 (full screen)\n      \"meta+shift+4\", // Cmd + Shift + 4 (selection)\n      \"meta+shift+5\", // Cmd + Shift + 5 (screenshot utility)\n      \"meta+shift+4+space\", // Cmd + Shift + 4 + Space (window)\n    ],\n    handleScreenshotAttempt,\n    {\n      preventDefault: true,\n      enableOnFormTags: true,\n      enableOnContentEditable: true,\n    },\n  );\n\n  // Linux screenshot shortcuts\n  useHotkeys(\n    [\n      \"shift+printscreen\", // Shift + PrintScreen (selection in some Linux distros)\n      \"ctrl+alt+printscreen\", // Ctrl + Alt + PrintScreen (some Linux distros)\n    ],\n    handleScreenshotAttempt,\n    {\n      preventDefault: true,\n      enableOnFormTags: true,\n      enableOnContentEditable: true,\n    },\n  );\n\n  // Developer tools that could be used for screenshots\n  useHotkeys(\n    [\n      \"f12\", // F12 (Developer Tools)\n      \"ctrl+shift+i\", // Ctrl + Shift + I (Developer Tools)\n      \"meta+alt+i\", // Cmd + Option + I (macOS Developer Tools)\n      \"ctrl+shift+c\", // Ctrl + Shift + C (Inspect Element)\n      \"meta+alt+c\", // Cmd + Option + C (macOS Inspect Element)\n    ],\n    handleScreenshotAttempt,\n    {\n      preventDefault: true,\n      enableOnFormTags: true,\n      enableOnContentEditable: true,\n    },\n  );\n\n  if (blockScreen) {\n    return (\n      <div className=\"absolute inset-0 z-50 flex w-screen items-center justify-center bg-white\">\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"flex gap-2\">\n            <XOctagonIcon className=\"size-5 text-destructive\" />\n            <p className=\"text-sm text-destructive\">\n              Screenshot is not allowed.\n            </p>\n          </div>\n          <Button size=\"sm\" onClick={() => setBlockScreen(false)}>\n            Back to document\n          </Button>\n        </div>\n      </div>\n    );\n  }\n\n  // will be hidden unless user prints the screen\n  return (\n    <div className=\"absolute inset-0 hidden h-screen w-screen bg-white print:block\"></div>\n  );\n};\n"
  },
  {
    "path": "components/view/access-form/access-form-theme.tsx",
    "content": "import { createContext, useContext } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { createAdaptiveSurfacePalette } from \"@/lib/utils/create-adaptive-surface-palette\";\n\nexport type AccessFormTheme = ReturnType<typeof createAdaptiveSurfacePalette>;\n\nconst defaultTheme = createAdaptiveSurfacePalette(undefined);\n\nconst AccessFormThemeContext = createContext<AccessFormTheme>(defaultTheme);\n\nexport function createAccessFormTheme(accentColor: string | null | undefined) {\n  return createAdaptiveSurfacePalette(accentColor || \"#000000\");\n}\n\nexport function AccessFormThemeProvider({\n  value,\n  children,\n}: {\n  value: AccessFormTheme;\n  children: ReactNode;\n}) {\n  return (\n    <AccessFormThemeContext.Provider value={value}>\n      {children}\n    </AccessFormThemeContext.Provider>\n  );\n}\n\nexport function useAccessFormTheme() {\n  return useContext(AccessFormThemeContext);\n}\n"
  },
  {
    "path": "components/view/access-form/agreement-section.tsx",
    "content": "import { Dispatch, SetStateAction } from \"react\";\n\nimport { Brand, DataroomBrand } from \"@prisma/client\";\n\nimport { Checkbox } from \"@/components/ui/checkbox\";\n\nimport { DEFAULT_ACCESS_FORM_TYPE } from \".\";\nimport { useAccessFormTheme } from \"./access-form-theme\";\n\nexport default function AgreementSection({\n  data,\n  setData,\n  agreementContent,\n  agreementName,\n  agreementContentType,\n  brand,\n  useCustomAccessForm,\n}: {\n  data: DEFAULT_ACCESS_FORM_TYPE;\n  setData: Dispatch<SetStateAction<DEFAULT_ACCESS_FORM_TYPE>>;\n  agreementContent: string;\n  agreementName: string;\n  agreementContentType?: string;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n  useCustomAccessForm?: boolean;\n}) {\n  const theme = useAccessFormTheme();\n  const isChecked = !!data.hasConfirmedAgreement;\n\n  const handleCheckChange = (checked: boolean) => {\n    setData((prevData) => ({ ...prevData, hasConfirmedAgreement: checked }));\n  };\n  const toggleAgreement = () => {\n    handleCheckChange(!isChecked);\n  };\n\n  const isTextContent = agreementContentType === \"TEXT\";\n\n  return (\n    <div className=\"relative flex items-start space-x-2 pt-5\">\n      <Checkbox\n        id=\"agreement\"\n        checked={isChecked}\n        onCheckedChange={handleCheckChange}\n        className=\"border border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--agreement-checked-bg)] data-[state=checked]:bg-[var(--agreement-checked-bg)] data-[state=checked]:text-[var(--agreement-check-color)]\"\n        style={\n          {\n            borderColor: theme.controlBorderStrongColor,\n            color: theme.backgroundColor || undefined,\n            \"--agreement-checked-bg\": theme.textColor,\n            \"--agreement-check-color\": theme.inverseTextColor,\n          } as React.CSSProperties\n        }\n      />\n      <label\n        className=\"text-sm font-normal leading-5 text-white peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n        style={{ color: theme.textColor }}\n      >\n        {isTextContent ? (\n          <span\n            className=\"cursor-pointer whitespace-pre-line\"\n            onClick={toggleAgreement}\n          >\n            {agreementContent}\n          </span>\n        ) : (\n          <>\n            <span className=\"cursor-pointer\" onClick={toggleAgreement}>\n              I have reviewed and agree to the terms of this{\" \"}\n            </span>\n            <a\n              href={`${agreementContent}`}\n              target=\"_blank\"\n              rel=\"noreferrer noopener\"\n              className=\"underline hover:text-gray-200\"\n              onClick={(e) => e.stopPropagation()}\n              style={{ color: theme.textColor }}\n            >\n              {agreementName}\n            </a>\n            <span className=\"cursor-pointer\" onClick={toggleAgreement}>\n              .\n            </span>\n          </>\n        )}\n      </label>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/access-form/custom-fields-section.tsx",
    "content": "import { Brand, CustomField, DataroomBrand } from \"@prisma/client\";\nimport { E164Number } from \"libphonenumber-js\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { PhoneInput } from \"@/components/ui/phone-input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useAccessFormTheme } from \"./access-form-theme\";\n\nexport default function CustomFieldsSection({\n  fields,\n  data,\n  setData,\n  brand,\n}: {\n  fields: Partial<CustomField>[];\n  data: { [key: string]: string };\n  setData: (data: { [key: string]: string }) => void;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n}) {\n  const theme = useAccessFormTheme();\n\n  const handleInputChange = (\n    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,\n    identifier: string,\n  ) => {\n    setData({ ...data, [identifier]: e.target.value });\n  };\n\n  const handlePhoneChange = (value: E164Number | null, identifier: string) => {\n    setData({ ...data, [identifier]: value || \"\" });\n  };\n\n  const handleCheckboxChange = (checked: boolean, identifier: string) => {\n    setData({ ...data, [identifier]: String(checked) });\n  };\n\n  if (!fields?.length) return null;\n\n  return (\n    <div className=\"space-y-5\">\n      {fields.map((field, index) => {\n        if (!field.identifier) return null;\n\n        const value = data[field.identifier] || \"\";\n        const isLongText = field.type === \"LONG_TEXT\";\n        const isPhoneNumber = field.type === \"PHONE_NUMBER\";\n        const isCheckbox = field.type === \"CHECKBOX\";\n\n        return (\n          <div key={field.identifier} className=\"relative space-y-2\">\n            {!isCheckbox && (\n              <label\n                htmlFor={field.identifier}\n                className=\"block text-sm font-medium leading-6 text-white\"\n                style={{ color: theme.textColor }}\n              >\n                {field.label}\n              </label>\n            )}\n            {isCheckbox ? (\n              <div className=\"flex items-center space-x-2\">\n                <Checkbox\n                  id={field.identifier}\n                  checked={value === \"true\"}\n                  onCheckedChange={(checked) =>\n                    handleCheckboxChange(!!checked, field.identifier!)\n                  }\n                  disabled={field.disabled}\n                  className={cn(\n                    \"border-gray-600 data-[state=checked]:bg-gray-300 data-[state=checked]:text-black\",\n                  )}\n                  style={{\n                    borderColor: theme.controlBorderColor,\n                  }}\n                />\n                <label\n                  htmlFor={field.identifier}\n                  className=\"cursor-pointer text-sm font-medium leading-none text-white peer-disabled:cursor-not-allowed peer-disabled:opacity-70\"\n                  style={{ color: theme.textColor }}\n                >\n                  {field.label}\n                  {field.required && (\n                    <span className=\"ml-1 text-red-500\">*</span>\n                  )}\n                </label>\n              </div>\n            ) : isPhoneNumber ? (\n              <PhoneInput\n                id={field.identifier}\n                value={value as E164Number}\n                onChange={(phoneValue) =>\n                  handlePhoneChange(phoneValue, field.identifier!)\n                }\n                placeholder={field.placeholder || \"+1 123 456 7890\"}\n                defaultCountry=\"US\"\n                disabled={field.disabled}\n                translate=\"no\"\n                className={cn(\n                  \"notranslate flex w-full cursor-text rounded-md border-0 bg-black text-gray-500 placeholder:text-[var(--access-placeholder)] sm:text-sm sm:leading-6\",\n                )}\n                style={\n                  {\n                    \"--phone-input-bg\": theme.controlBgColor,\n                    \"--phone-input-color\": theme.textColor,\n                    \"--access-placeholder\": theme.controlPlaceholderColor,\n                  } as React.CSSProperties\n                }\n              />\n            ) : (\n              (() => {\n                const InputComponent = isLongText ? Textarea : Input;\n                return (\n                  <InputComponent\n                    name={field.identifier || \"\"}\n                    id={field.id || \"\"}\n                    type={\n                      field.type === \"NUMBER\"\n                        ? \"number\"\n                        : field.type === \"URL\"\n                          ? \"url\"\n                          : \"text\"\n                    }\n                    pattern={field.type === \"URL\" ? \"https://.*\" : undefined}\n                    onInvalid={(e) => {\n                      if (field.type === \"URL\") {\n                        e.currentTarget.setCustomValidity(\n                          \"Please enter a valid URL starting with https://\",\n                        );\n                      }\n                    }}\n                    onInput={(e) => e.currentTarget.setCustomValidity(\"\")}\n                    autoComplete=\"off\"\n                    data-1p-ignore\n                    required={field.required}\n                    disabled={field.disabled}\n                    translate=\"no\"\n                    className={cn(\n                      \"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6\",\n                      isLongText && \"min-h-[100px] resize-none\",\n                    )}\n                    style={\n                      {\n                        backgroundColor: theme.controlBgColor,\n                        borderColor: theme.controlBorderColor,\n                        \"--access-placeholder\":\n                          theme.controlPlaceholderColor,\n                        \"--access-input-focus\": theme.controlBorderStrongColor,\n                        color: theme.textColor,\n                      } as React.CSSProperties\n                    }\n                    value={value}\n                    placeholder={field.placeholder || \"\"}\n                    onChange={(e) => handleInputChange(e, field.identifier!)}\n                  />\n                );\n              })()\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/access-form/email-section.tsx",
    "content": "import {\n  Dispatch,\n  SetStateAction,\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\n\nimport { Brand, DataroomBrand } from \"@prisma/client\";\nimport { useDebouncedCallback } from \"use-debounce\";\n\nimport { cn } from \"@/lib/utils\";\nimport { validateEmail } from \"@/lib/utils/validate-email\";\n\nimport { DEFAULT_ACCESS_FORM_TYPE } from \".\";\nimport { useAccessFormTheme } from \"./access-form-theme\";\n\nexport default function EmailSection({\n  data,\n  setData,\n  brand,\n  disableEditEmail,\n  useCustomAccessForm,\n  onValidationChange,\n}: {\n  data: DEFAULT_ACCESS_FORM_TYPE;\n  setData: Dispatch<SetStateAction<DEFAULT_ACCESS_FORM_TYPE>>;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n  disableEditEmail?: boolean;\n  useCustomAccessForm?: boolean;\n  onValidationChange: (isValid: boolean) => void;\n}) {\n  const { email } = data;\n  const theme = useAccessFormTheme();\n  const [emailError, setEmailError] = useState<string | null>(null);\n  const [isDirty, setIsDirty] = useState(false);\n\n  useEffect(() => {\n    // Load email from localStorage when the component mounts\n    const storedEmail = window.localStorage.getItem(\"papermark.email\");\n    if (storedEmail) {\n      setData((prevData) => ({\n        ...prevData,\n        email: storedEmail.toLowerCase(),\n      }));\n    }\n  }, [setData]);\n\n  const handleInvalid = (e: React.InvalidEvent<HTMLInputElement>) => {\n    e.preventDefault(); // Prevent default browser validation popup\n    setEmailError(\"Please enter a valid email address\");\n  };\n\n  const debouncedValidation = useDebouncedCallback(\n    (value: string) => {\n      const isValid = !value || validateEmail(value);\n      if (isDirty && value && !isValid) {\n        setEmailError(\"Please enter a valid email address\");\n      } else {\n        setEmailError(null);\n      }\n      // Notify parent component about validation status\n      onValidationChange?.(isValid);\n    },\n    500, // 500ms delay\n  );\n\n  const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const newEmail = e.target.value.toLowerCase();\n    setEmailError(null); // Clear error when typing\n\n    debouncedValidation(newEmail);\n\n    // Update the state\n    setData({ ...data, email: newEmail });\n    // Store in localStorage\n    window.localStorage.setItem(\"papermark.email\", newEmail);\n\n    // Optional: Clear error if input becomes valid\n    if (e.target.validity.valid) {\n      setEmailError(null);\n    }\n  };\n\n  const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {\n    setIsDirty(true);\n    const value = e.target.value;\n    const isValid = !value || validateEmail(value);\n    if (value && !isValid) {\n      setEmailError(\"Please enter a valid email address\");\n    }\n    onValidationChange?.(isValid);\n  };\n\n  const handleFocus = () => {\n    // Optionally clear error when user focuses the input to type again\n    setEmailError(null);\n  };\n\n  return (\n    <div className=\"relative space-y-2\">\n      <label\n        htmlFor=\"email\"\n        className=\"block text-sm font-medium leading-6 text-white\"\n        style={{ color: theme.textColor }}\n      >\n        Email address\n      </label>\n      <input\n        name=\"email\"\n        id=\"email\"\n        type=\"email\"\n        autoCorrect=\"off\"\n        autoComplete=\"email\"\n        autoFocus\n        required\n        translate=\"no\"\n        className={cn(\n          \"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6\",\n          emailError && isDirty && \"ring-red-500\",\n        )}\n        style={{\n          backgroundColor: theme.controlBgColor,\n          borderColor: theme.controlBorderColor,\n          \"--access-placeholder\": theme.controlPlaceholderColor,\n          \"--access-input-focus\": theme.controlBorderStrongColor,\n          color: disableEditEmail\n            ? theme.subtleTextColor\n            : theme.textColor,\n        } as CSSProperties}\n        value={email || \"\"}\n        placeholder=\"Enter email\"\n        onChange={handleEmailChange}\n        onInvalid={handleInvalid}\n        onBlur={handleBlur}\n        onFocus={handleFocus}\n        disabled={disableEditEmail}\n        data-1p-ignore\n        aria-invalid={emailError ? \"true\" : \"false\"}\n        aria-describedby={emailError ? \"email-error\" : undefined}\n      />\n      {emailError && (\n        <p\n          id=\"email-error\"\n          className=\"mt-1 text-sm text-red-500\"\n          style={{ color: theme.textColor }}\n        >\n          {emailError}\n        </p>\n      )}\n      <p className=\"text-sm\" style={{ color: theme.subtleTextColor }}>\n        {useCustomAccessForm\n          ? \"This data will be shared with the content provider.\"\n          : \"This data will be shared with the sender.\"}\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/access-form/email-verification-form.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { Brand, DataroomBrand } from \"@prisma/client\";\n\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  InputOTP,\n  InputOTPGroup,\n  InputOTPSlot,\n} from \"@/components/ui/input-otp\";\nimport { DEFAULT_ACCESS_FORM_TYPE } from \"@/components/view/access-form\";\nimport { createAccessFormTheme } from \"@/components/view/access-form/access-form-theme\";\n\nconst REGEXP_ONLY_DIGITS = \"^\\\\d+$\";\n\nexport default function EmailVerificationMessage({\n  onSubmitHandler,\n  isLoading,\n  data,\n  code,\n  setCode,\n  isInvalidCode,\n  setIsInvalidCode,\n  brand,\n}: {\n  onSubmitHandler: React.FormEventHandler<HTMLFormElement>;\n  data: DEFAULT_ACCESS_FORM_TYPE;\n  isLoading: boolean;\n  code: string | null;\n  setCode: (code: string | null) => void;\n  isInvalidCode: boolean;\n  setIsInvalidCode: (invalidCode: boolean) => void;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n}) {\n  const { isMobile } = useMediaQuery();\n  const theme = createAccessFormTheme(brand?.accentColor);\n  const [isResendLoading, setIsResendLoading] = useState(false);\n  const [delaySeconds, setDelaySeconds] = useState(60);\n\n  useEffect(() => {\n    if (delaySeconds > 0) {\n      const interval = setInterval(\n        () => setDelaySeconds(delaySeconds - 1),\n        1000,\n      );\n\n      return () => clearInterval(interval);\n    }\n  }, [delaySeconds]);\n\n  return (\n    <>\n      <div\n        className=\"flex h-screen flex-1 flex-col px-6 py-12 lg:px-8\"\n        style={{\n          backgroundColor: theme.backgroundColor,\n        }}\n      >\n        <div className=\"sm:mx-auto sm:w-full sm:max-w-md\">\n          <h2\n            className=\"mt-10 text-2xl font-bold leading-9 tracking-tight\"\n            style={{ color: theme.textColor }}\n          >\n            Verify your email address\n          </h2>\n          <p\n            className=\"text-pretty text-sm leading-6\"\n            style={{ color: theme.textColor }}\n          >\n            Enter the six digit verification code sent to{\" \"}\n            <strong className=\"font-medium\" title={data.email ?? \"\"}>\n              {data.email}\n            </strong>\n          </p>\n          <form onSubmit={onSubmitHandler} translate=\"no\">\n            <InputOTP\n              maxLength={6}\n              pattern={REGEXP_ONLY_DIGITS}\n              autoFocus={!isMobile}\n              value={code ?? \"\"}\n              onChange={(code) => {\n                setIsInvalidCode(false);\n                setCode(code || null);\n              }}\n              containerClassName=\"my-6\"\n              accentColor={brand?.accentColor}\n            >\n              <InputOTPGroup>\n                {[0, 1, 2, 3, 4, 5].map((index) => (\n                  <InputOTPSlot key={index} index={index} />\n                ))}\n              </InputOTPGroup>\n            </InputOTP>\n\n            {isInvalidCode && (\n              <p className=\"mb-6 mt-2 text-sm text-red-500\">\n                Invalid code. Please try again.\n              </p>\n            )}\n\n            <Button\n              type=\"submit\"\n              disabled={!code || isLoading}\n              loading={isLoading && !isResendLoading}\n              className=\"hover:opacity-90\"\n              style={{\n                backgroundColor: theme.ctaBgColor,\n                color: theme.ctaTextColor,\n              }}\n            >\n              {isLoading && !isResendLoading ? \"Verifying...\" : \"Continue\"}\n            </Button>\n          </form>\n\n          <div className=\"mt-10 space-y-4\">\n            <div className=\"flex items-center\">\n              <p\n                className=\"text-xs\"\n                style={{ color: theme.subtleTextColor }}\n              >\n                Didn&apos;t receive the email?\n              </p>{\" \"}\n              <Button\n                variant=\"link\"\n                size=\"sm\"\n                className=\"text-xs font-normal\"\n                style={{ color: theme.mutedTextColor }}\n                disabled={isLoading || delaySeconds > 0}\n                onClick={(e) => {\n                  e.preventDefault();\n                  setIsResendLoading(true);\n                  setDelaySeconds(60);\n                  setCode(null);\n                  setIsInvalidCode(false);\n                  onSubmitHandler(\n                    e as unknown as React.FormEvent<HTMLFormElement>,\n                  );\n                }}\n              >\n                {isResendLoading && !isLoading\n                  ? \"Resending code...\"\n                  : delaySeconds > 0\n                    ? `Resend Code (${delaySeconds}s)`\n                    : \"Resend Code\"}\n              </Button>\n            </div>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/access-form/index.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { Brand, CustomField, DataroomBrand } from \"@prisma/client\";\nimport { ArrowUpRightIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport AgreementSection from \"./agreement-section\";\nimport {\n  AccessFormThemeProvider,\n  createAccessFormTheme,\n} from \"./access-form-theme\";\nimport CustomFieldsSection from \"./custom-fields-section\";\nimport EmailSection from \"./email-section\";\nimport NameSection from \"./name-section\";\nimport PasswordSection from \"./password-section\";\n\nexport const DEFAULT_ACCESS_FORM_DATA = {\n  email: null,\n  password: null,\n};\n\nexport type DEFAULT_ACCESS_FORM_TYPE = {\n  email: string | null;\n  password: string | null;\n  hasConfirmedAgreement?: boolean;\n  name?: string | null;\n  customFields?: { [key: string]: string };\n};\n\nexport default function AccessForm({\n  data,\n  email,\n  brand,\n  setData,\n  onSubmitHandler,\n  requireEmail,\n  requirePassword,\n  requireAgreement,\n  agreementName,\n  agreementContent,\n  agreementContentType,\n  requireName,\n  isLoading,\n  linkId,\n  disableEditEmail,\n  useCustomAccessForm,\n  customFields,\n  logoOnAccessForm,\n  linkWelcomeMessage,\n}: {\n  data: DEFAULT_ACCESS_FORM_TYPE;\n  email: string | null | undefined;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_ACCESS_FORM_TYPE>>;\n  onSubmitHandler: React.FormEventHandler<HTMLFormElement>;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n  requireEmail: boolean;\n  requirePassword: boolean;\n  requireAgreement?: boolean;\n  agreementName?: string;\n  agreementContent?: string;\n  agreementContentType?: string;\n  requireName?: boolean;\n  isLoading: boolean;\n  linkId?: string;\n  disableEditEmail?: boolean;\n  useCustomAccessForm?: boolean;\n  customFields?: Partial<CustomField>[];\n  logoOnAccessForm?: boolean;\n  linkWelcomeMessage?: string | null;\n}) {\n  const [isEmailValid, setIsEmailValid] = useState(true);\n  const accessFormTheme = createAccessFormTheme(brand?.accentColor);\n\n  useEffect(() => {\n    const userEmail = email;\n    if (userEmail) {\n      setData((prevData: DEFAULT_ACCESS_FORM_TYPE) => ({\n        ...prevData,\n        email: userEmail || prevData.email,\n      }));\n    }\n  }, [email, setData]);\n\n  const isFormValid = () => {\n    if (requireEmail) {\n      if (!data.email || !isEmailValid) return false;\n    }\n    if (requirePassword && !data.password) return false;\n    if (requireAgreement && !data.hasConfirmedAgreement) return false;\n    if (requireAgreement && requireName && !data.name) return false;\n    if (customFields?.length) {\n      for (const field of customFields) {\n        if (field.required) {\n          const fieldValue = data.customFields?.[field.identifier!];\n          // For checkbox fields, required means it must be checked (true)\n          if (field.type === \"CHECKBOX\") {\n            if (fieldValue !== \"true\") {\n              return false;\n            }\n          } else {\n            // For other field types, required means it must have a value\n            if (!fieldValue) {\n              return false;\n            }\n          }\n        }\n      }\n    }\n    return true;\n  };\n\n  const updateCustomFields = (fields: { [key: string]: string }) => {\n    setData((prevData) => ({\n      ...prevData,\n      customFields: fields,\n    }));\n  };\n\n  return (\n    <AccessFormThemeProvider value={accessFormTheme}>\n      <div\n        className=\"flex h-full min-h-dvh flex-col justify-between pb-4\"\n        style={{\n          backgroundColor: accessFormTheme.backgroundColor,\n          color: accessFormTheme.textColor,\n        }}\n      >\n      {/* Light Navbar */}\n      {logoOnAccessForm && brand && brand.logo && (\n        <nav\n          className=\"w-full\"\n          style={{\n            backgroundColor: brand.brandColor ? brand.brandColor : \"black\",\n          }}\n        >\n          <div className=\"flex h-16 items-center justify-start px-2 sm:px-6 lg:px-8\">\n            <img\n              src={brand.logo as string}\n              alt=\"Brand Logo\"\n              className=\"h-16 w-auto object-contain\"\n            />\n          </div>\n        </nav>\n      )}\n\n      <div className=\"flex flex-1 flex-col px-6 pb-12 pt-8 lg:px-8\">\n        <div className=\"sm:mx-auto sm:w-full sm:max-w-md\">\n          <h1\n            className=\"mt-10 text-2xl font-bold leading-9 tracking-tight text-white\"\n            style={{ color: accessFormTheme.textColor }}\n          >\n            {linkWelcomeMessage ||\n              (brand && \"welcomeMessage\" in brand && brand.welcomeMessage) ||\n              \"Your action is requested to continue\"}\n          </h1>\n        </div>\n\n        <div className=\"mt-10 sm:mx-auto sm:w-full sm:max-w-md\">\n          <form className=\"space-y-4\" onSubmit={onSubmitHandler} translate=\"no\">\n            {requireAgreement && agreementContent && requireName ? (\n              <NameSection {...{ data, setData, brand }} />\n            ) : null}\n            {requireEmail ? (\n              <EmailSection\n                {...{ data, setData, brand }}\n                disableEditEmail={disableEditEmail}\n                useCustomAccessForm={useCustomAccessForm}\n                onValidationChange={setIsEmailValid}\n              />\n            ) : null}\n            {requirePassword ? (\n              <PasswordSection {...{ data, setData, brand }} />\n            ) : null}\n            {customFields?.length ? (\n              <CustomFieldsSection\n                fields={customFields}\n                data={data.customFields || {}}\n                setData={updateCustomFields}\n                brand={brand}\n              />\n            ) : null}\n            {requireAgreement && agreementContent && agreementName ? (\n              <AgreementSection\n                {...{ data, setData, brand }}\n                agreementContent={agreementContent}\n                agreementName={agreementName}\n                agreementContentType={agreementContentType}\n                useCustomAccessForm={useCustomAccessForm}\n              />\n            ) : null}\n\n            <div className=\"flex justify-center pt-5\">\n              <Button\n                type=\"submit\"\n                disabled={!isFormValid()}\n                className=\"w-1/3 min-w-fit bg-white text-gray-950 hover:bg-white/90\"\n                loading={isLoading}\n                style={{\n                  backgroundColor: accessFormTheme.ctaBgColor,\n                  color: accessFormTheme.ctaTextColor,\n                }}\n              >\n                Continue\n              </Button>\n            </div>\n          </form>\n        </div>\n      </div>\n      {!useCustomAccessForm ? (\n        <div className=\"flex flex-col items-center gap-0.5\">\n          <p\n            className=\"text-center text-sm tracking-tight\"\n            style={{ color: accessFormTheme.subtleTextColor }}\n          >\n            This document is securely shared with you using{\" \"}\n            <a\n              href=\"https://www.papermark.com\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"font-medium\"\n              style={{ color: accessFormTheme.mutedTextColor }}\n            >\n              Papermark\n            </a>\n            .\n          </p>\n          <p\n            className=\"text-center text-sm tracking-tight\"\n            style={{ color: accessFormTheme.subtleTextColor }}\n          >\n            See how we protect your data in our{\" \"}\n            <a\n              href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-0.5\"\n              style={{ color: accessFormTheme.mutedTextColor }}\n            >\n              <span>Privacy Policy</span>\n              <ArrowUpRightIcon className=\"h-3 w-3\" />\n            </a>\n          </p>\n        </div>\n      ) : null}\n      </div>\n    </AccessFormThemeProvider>\n  );\n}\n"
  },
  {
    "path": "components/view/access-form/name-section.tsx",
    "content": "import { Dispatch, SetStateAction, useEffect } from \"react\";\nimport type { CSSProperties } from \"react\";\n\nimport { Brand, DataroomBrand } from \"@prisma/client\";\n\nimport { DEFAULT_ACCESS_FORM_TYPE } from \".\";\nimport { useAccessFormTheme } from \"./access-form-theme\";\n\nexport default function NameSection({\n  data,\n  setData,\n  brand,\n}: {\n  data: DEFAULT_ACCESS_FORM_TYPE;\n  setData: Dispatch<SetStateAction<DEFAULT_ACCESS_FORM_TYPE>>;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n}) {\n  const { name } = data;\n  const theme = useAccessFormTheme();\n\n  useEffect(() => {\n    // Load name from localStorage when the component mounts\n    const storedName = window.localStorage.getItem(\"papermark.name\");\n    if (storedName) {\n      setData((prevData) => ({\n        ...prevData,\n        name: storedName,\n      }));\n    }\n  }, [setData]);\n\n  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const newName = e.target.value;\n    // Store the new email in localStorage\n    window.localStorage.setItem(\"papermark.name\", newName);\n    // Update the state\n    setData({ ...data, name: newName });\n  };\n\n  return (\n    <div className=\"relative space-y-2 rounded-md shadow-sm\">\n      <label\n        htmlFor=\"name\"\n        className=\"block text-sm font-medium leading-6 text-white\"\n        style={{ color: theme.textColor }}\n      >\n        Name\n      </label>\n      <input\n        name=\"name\"\n        id=\"name\"\n        type=\"text\"\n        autoCorrect=\"off\"\n        autoComplete=\"off\"\n        autoFocus\n        translate=\"no\"\n        className=\"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6\"\n        style={{\n          backgroundColor: theme.controlBgColor,\n          borderColor: theme.controlBorderColor,\n          \"--access-placeholder\": theme.controlPlaceholderColor,\n          \"--access-input-focus\": theme.controlBorderStrongColor,\n          color: theme.textColor,\n        } as CSSProperties}\n        value={name || \"\"}\n        placeholder=\"Enter your full name\"\n        onChange={handleNameChange}\n        aria-invalid=\"true\"\n        data-1p-ignore\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/access-form/password-section.tsx",
    "content": "import { Dispatch, SetStateAction, useState } from \"react\";\nimport type { CSSProperties } from \"react\";\n\nimport { Brand, DataroomBrand } from \"@prisma/client\";\n\nimport Eye from \"@/components/shared/icons/eye\";\nimport EyeOff from \"@/components/shared/icons/eye-off\";\n\nimport { DEFAULT_ACCESS_FORM_TYPE } from \".\";\nimport { useAccessFormTheme } from \"./access-form-theme\";\n\nexport default function PasswordSection({\n  data,\n  setData,\n  brand,\n}: {\n  data: DEFAULT_ACCESS_FORM_TYPE;\n  setData: Dispatch<SetStateAction<DEFAULT_ACCESS_FORM_TYPE>>;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n}) {\n  const { password } = data;\n  const theme = useAccessFormTheme();\n  const [showPassword, setShowPassword] = useState<boolean>(false);\n\n  return (\n    <div className=\"space-y-2 rounded-md shadow-sm\">\n      <label\n        htmlFor=\"password\"\n        className=\"block text-sm font-medium leading-6 text-white\"\n        style={{ color: theme.textColor }}\n      >\n        Passcode\n      </label>\n      <div className=\"relative\">\n        <input\n          name=\"password\"\n          id=\"password\"\n          type={showPassword ? \"text\" : \"password\"}\n          autoCorrect=\"off\"\n          autoComplete=\"off\"\n          translate=\"no\"\n          className=\"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6\"\n          style={{\n            backgroundColor: theme.controlBgColor,\n            borderColor: theme.controlBorderColor,\n            \"--access-placeholder\": theme.controlPlaceholderColor,\n            \"--access-input-focus\": theme.controlBorderStrongColor,\n            color: theme.textColor,\n          } as CSSProperties}\n          value={password || \"\"}\n          placeholder=\"Enter passcode\"\n          onChange={(e) => {\n            setData({ ...data, password: e.target.value });\n          }}\n          aria-invalid=\"true\"\n          data-1p-ignore\n        />\n        <button\n          type=\"button\"\n          onClick={() => setShowPassword(!showPassword)}\n          className=\"absolute inset-y-0 right-0 flex items-center pr-3\"\n        >\n          {showPassword ? (\n            <Eye\n              className=\"h-4 w-4\"\n              style={{ color: theme.controlIconColor }}\n              aria-hidden=\"true\"\n            />\n          ) : (\n            <EyeOff\n              className=\"h-4 w-4\"\n              style={{ color: theme.controlIconColor }}\n              aria-hidden=\"true\"\n            />\n          )}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/annotations/annotation-panel.tsx",
    "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\n\nimport { Brand, DataroomBrand } from \"@prisma/client\";\nimport { ChevronDown, ChevronRight } from \"lucide-react\";\n\nimport { useViewerAnnotations } from \"@/lib/swr/use-annotations\";\nimport { createAdaptiveSurfacePalette } from \"@/lib/utils/create-adaptive-surface-palette\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport {\n  ResizableHandle,\n  ResizablePanel,\n  ResizablePanelGroup,\n} from \"@/components/ui/resizable\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\n\ninterface AnnotationPanelProps {\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n  linkId: string;\n  documentId?: string;\n  viewId?: string;\n  currentPage: number;\n  isVisible: boolean;\n  onResize?: (size: number) => void;\n}\n\nexport function AnnotationPanel({\n  brand,\n  linkId,\n  documentId,\n  viewId,\n  currentPage,\n  isVisible,\n  onResize,\n}: AnnotationPanelProps) {\n  const palette = createAdaptiveSurfacePalette(brand?.accentColor);\n  const { annotations, loading } = useViewerAnnotations(\n    linkId,\n    documentId,\n    viewId,\n  );\n  const [expandedAnnotations, setExpandedAnnotations] = useState<Set<string>>(\n    new Set(),\n  );\n\n  // Filter annotations for current page\n  const currentPageAnnotations = useMemo(() => {\n    if (!annotations) return [];\n    return annotations.filter((annotation) =>\n      annotation.pages.includes(currentPage),\n    );\n  }, [annotations, currentPage]);\n\n  const toggleAnnotation = (annotationId: string) => {\n    const newExpanded = new Set(expandedAnnotations);\n    if (newExpanded.has(annotationId)) {\n      newExpanded.delete(annotationId);\n    } else {\n      newExpanded.add(annotationId);\n    }\n    setExpandedAnnotations(newExpanded);\n  };\n\n  const renderContent = (content: any) => {\n    if (!content) return null;\n\n    // Properly render Tiptap JSON content\n    if (typeof content === \"object\" && content.content) {\n      return (\n        <div className=\"prose prose-sm max-w-none text-gray-700\">\n          {content.content.map((node: any, index: number) => {\n            if (node.type === \"paragraph\") {\n              return (\n                <p\n                  key={index}\n                  className=\"mb-2 text-sm leading-relaxed text-gray-700\"\n                >\n                  {node.content?.map((textNode: any, textIndex: number) => {\n                    if (textNode.type === \"text\") {\n                      let text = textNode.text;\n                      // Apply formatting if marks exist\n                      if (textNode.marks) {\n                        textNode.marks.forEach((mark: any) => {\n                          if (mark.type === \"bold\") {\n                            text = (\n                              <strong key={textIndex} className=\"font-semibold\">\n                                {text}\n                              </strong>\n                            );\n                          } else if (mark.type === \"italic\") {\n                            text = (\n                              <em key={textIndex} className=\"italic\">\n                                {text}\n                              </em>\n                            );\n                          }\n                        });\n                      }\n                      return text;\n                    } else if (textNode.type === \"image\") {\n                      return (\n                        <img\n                          key={textIndex}\n                          src={textNode.attrs?.src}\n                          alt={textNode.attrs?.alt || \"\"}\n                          className=\"my-2 h-auto max-w-full rounded-md\"\n                        />\n                      );\n                    }\n                    return null;\n                  })}\n                </p>\n              );\n            } else if (node.type === \"bulletList\") {\n              return (\n                <ul\n                  key={index}\n                  className=\"mb-2 list-inside list-disc text-sm text-gray-700\"\n                >\n                  {node.content?.map((listItem: any, listIndex: number) => (\n                    <li key={listIndex} className=\"mb-1\">\n                      {listItem.content?.[0]?.content?.[0]?.text}\n                    </li>\n                  ))}\n                </ul>\n              );\n            } else if (node.type === \"orderedList\") {\n              return (\n                <ol\n                  key={index}\n                  className=\"mb-2 list-inside list-decimal text-sm text-gray-700\"\n                >\n                  {node.content?.map((listItem: any, listIndex: number) => (\n                    <li key={listIndex} className=\"mb-1\">\n                      {listItem.content?.[0]?.content?.[0]?.text}\n                    </li>\n                  ))}\n                </ol>\n              );\n            } else if (node.type === \"blockquote\") {\n              return (\n                <blockquote\n                  key={index}\n                  className=\"mb-2 border-l-2 border-gray-300 pl-3 text-sm italic text-gray-600\"\n                >\n                  {node.content?.[0]?.content?.[0]?.text}\n                </blockquote>\n              );\n            } else if (node.type === \"image\") {\n              return (\n                <img\n                  key={index}\n                  src={node.attrs?.src}\n                  alt={node.attrs?.alt || \"\"}\n                  className=\"my-2 h-auto max-w-full rounded-md\"\n                />\n              );\n            }\n            return null;\n          })}\n        </div>\n      );\n    }\n\n    return <p className=\"text-sm text-gray-500\">No content</p>;\n  };\n\n  if (!isVisible) return null;\n\n  return (\n    <div className=\"h-full w-full bg-transparent\">\n      <ScrollArea className=\"h-full\">\n        <div className=\"space-y-3 p-4\">\n          {loading ? (\n            <div className=\"flex items-center justify-center py-6\">\n              <div\n                className=\"text-xs opacity-70\"\n                style={{ color: palette.textColor }}\n              >\n                Loading annotations...\n              </div>\n            </div>\n          ) : currentPageAnnotations.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center py-6 text-center\">\n              <p\n                className=\"mb-1 text-xs opacity-70\"\n                style={{ color: palette.textColor }}\n              >\n                No annotations on this page\n              </p>\n            </div>\n          ) : (\n            currentPageAnnotations.map((annotation) => (\n              <div\n                key={annotation.id}\n                className=\"rounded-lg border border-gray-300/50 bg-white/95 shadow-sm backdrop-blur-sm transition-all hover:border-gray-400/60 hover:bg-white\"\n              >\n                <Collapsible\n                  open={expandedAnnotations.has(annotation.id)}\n                  onOpenChange={() => toggleAnnotation(annotation.id)}\n                >\n                  <CollapsibleTrigger asChild>\n                    <div className=\"w-full cursor-pointer\">\n                      <div className=\"flex w-full items-center justify-between p-3\">\n                        <div className=\"min-w-0 flex-1\">\n                          <h4 className=\"truncate text-sm font-medium text-gray-800\">\n                            {annotation.title}\n                          </h4>\n                          <p className=\"mt-1 text-xs text-gray-500\">\n                            Page{annotation.pages.length > 1 ? \"s\" : \"\"}{\" \"}\n                            {annotation.pages.join(\", \")}\n                          </p>\n                        </div>\n                        <button\n                          className=\"ml-2 shrink-0 rounded-md p-1 transition-colors hover:bg-gray-100\"\n                          onClick={(e) => {\n                            e.stopPropagation();\n                            toggleAnnotation(annotation.id);\n                          }}\n                        >\n                          {expandedAnnotations.has(annotation.id) ? (\n                            <ChevronDown className=\"h-4 w-4 text-gray-600\" />\n                          ) : (\n                            <ChevronRight className=\"h-4 w-4 text-gray-600\" />\n                          )}\n                        </button>\n                      </div>\n                    </div>\n                  </CollapsibleTrigger>\n                  <CollapsibleContent>\n                    <div className=\"border-t border-gray-200 px-3 pb-3 pt-3\">\n                      <div className=\"text-sm text-gray-700\">\n                        {renderContent(annotation.content)}\n                      </div>\n\n                      {annotation.images && annotation.images.length > 0 && (\n                        <div\n                          className=\"mt-3 grid gap-2\"\n                          style={{\n                            gridTemplateColumns:\n                              annotation.images.length === 1\n                                ? \"1fr\"\n                                : annotation.images.length === 2\n                                  ? \"repeat(2, 1fr)\"\n                                  : \"repeat(auto-fit, minmax(120px, 1fr))\",\n                          }}\n                        >\n                          {annotation.images.map((image) => (\n                            <div\n                              key={image.id}\n                              className=\"overflow-hidden rounded-md border border-gray-200\"\n                            >\n                              <img\n                                src={image.url}\n                                alt={image.filename}\n                                className=\"h-auto max-h-32 w-full object-contain\"\n                                loading=\"lazy\"\n                              />\n                            </div>\n                          ))}\n                        </div>\n                      )}\n                    </div>\n                  </CollapsibleContent>\n                </Collapsible>\n              </div>\n            ))\n          )}\n        </div>\n      </ScrollArea>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/annotations/annotation-toggle.tsx",
    "content": "\"use client\";\n\nimport { MessageSquare, MessageSquareOff } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\ninterface AnnotationToggleProps {\n  enabled: boolean;\n  onToggle: (enabled: boolean) => void;\n  hasAnnotations?: boolean;\n}\n\nexport function AnnotationToggle({\n  enabled,\n  onToggle,\n  hasAnnotations = false,\n}: AnnotationToggleProps) {\n  return (\n    <TooltipProvider delayDuration={100}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => onToggle(!enabled)}\n            className={`${\n              enabled ? \"bg-muted text-foreground\" : \"text-muted-foreground\"\n            } ${!hasAnnotations ? \"cursor-not-allowed opacity-50\" : \"\"}`}\n            disabled={!hasAnnotations}\n          >\n            {enabled ? (\n              <MessageSquare className=\"h-4 w-4\" />\n            ) : (\n              <MessageSquareOff className=\"h-4 w-4\" />\n            )}\n          </Button>\n        </TooltipTrigger>\n        <TooltipContent>\n          <p>\n            {!hasAnnotations\n              ? \"No annotations available\"\n              : enabled\n                ? \"Hide annotations\"\n                : \"Show annotations\"}\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n"
  },
  {
    "path": "components/view/conversations/sidebar.tsx",
    "content": "\"use client\";\n\nimport {\n  ConversationSidebarProps,\n  ConversationViewSidebar as ConversationViewSidebarEE,\n} from \"@/ee/features/conversations/components/viewer/conversation-view-sidebar\";\n\nexport function ConversationSidebar(props: ConversationSidebarProps) {\n  return <ConversationViewSidebarEE {...props} />;\n}\n"
  },
  {
    "path": "components/view/custom-metatag.tsx",
    "content": "import Head from \"next/head\";\n\nconst CustomMetaTag = ({\n  enableBranding,\n  title,\n  description,\n  imageUrl,\n  url,\n  favicon,\n}: {\n  enableBranding: boolean;\n  title: string | null;\n  description: string | null;\n  imageUrl: string | null;\n  favicon: string | null;\n  url: string | null;\n}) => {\n  return (\n    <Head>\n      {/* meta URL */}\n      {url && (\n        <>\n          <link rel=\"canonical\" href={url} key=\"canonical\" />\n          <meta property=\"og:url\" content={url} key=\"og-url\" />\n        </>\n      )}\n\n      {favicon && (\n        <>\n          <link rel=\"icon\" type=\"image/x-icon\" href={favicon} key=\"favicon\" />\n          {favicon.endsWith(\".ico\") && (\n            <link rel=\"icon\" type=\"image/x-icon\" href={favicon} />\n          )}\n          {favicon.endsWith(\".png\") && (\n            <link rel=\"icon\" type=\"image/png\" href={favicon} sizes=\"32x32\" />\n          )}\n          {favicon.endsWith(\".svg\") && (\n            <link rel=\"icon\" type=\"image/svg+xml\" href={favicon} />\n          )}\n          <link rel=\"apple-touch-icon\" href={favicon} />\n        </>\n      )}\n\n      {/* meta title */}\n      {enableBranding && title && (\n        <>\n          <title>{title}</title>\n          <meta property=\"og:title\" content={title} key=\"og-title\" />\n          <meta name=\"twitter:title\" content={title} key=\"tw-title\" />\n        </>\n      )}\n\n      {/* meta description */}\n      {enableBranding && description && (\n        <>\n          <meta name=\"description\" content={description} key=\"description\" />\n          <meta\n            property=\"og:description\"\n            content={description}\n            key=\"og-description\"\n          />\n          <meta\n            name=\"twitter:description\"\n            content={description}\n            key=\"tw-description\"\n          />\n        </>\n      )}\n\n      {/* meta image */}\n      {enableBranding && imageUrl && (\n        <>\n          <meta property=\"og:image\" content={imageUrl} key=\"og-image\" />\n          <meta name=\"twitter:image\" content={imageUrl} key=\"tw-image\" />\n        </>\n      )}\n    </Head>\n  );\n};\n\nexport default CustomMetaTag;\n"
  },
  {
    "path": "components/view/dataroom/dataroom-document-view.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\n\nimport { DataroomBrand } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from \"@/lib/constants\";\nimport { useDisablePrint } from \"@/lib/hooks/use-disable-print\";\nimport { LinkWithDataroomDocument, NotionTheme } from \"@/lib/types\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport AccessForm, {\n  DEFAULT_ACCESS_FORM_DATA,\n  DEFAULT_ACCESS_FORM_TYPE,\n} from \"@/components/view/access-form\";\n\nimport EmailVerificationMessage from \"../access-form/email-verification-form\";\nimport ViewData, { TViewDocumentData } from \"../view-data\";\n\ntype RowData = { [key: string]: any };\ntype SheetData = {\n  sheetName: string;\n  columnData: string[];\n  rowData: RowData[];\n};\n\nexport type TSupportedDocumentSimpleType =\n  (typeof SUPPORTED_DOCUMENT_SIMPLE_TYPES)[number];\n\nexport type DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE = {\n  viewId?: string;\n  dataroomViewId?: string;\n  file?: string | null;\n  pages?:\n    | {\n        file: string | null;\n        pageNumber: string;\n        embeddedLinks: string[];\n        pageLinks: {\n          href: string;\n          coords: string;\n          isInternal?: boolean;\n          targetPage?: number;\n        }[];\n        metadata: { width: number; height: number; scaleFactor: number };\n      }[]\n    | null;\n  sheetData?: SheetData[] | null;\n  notionData?: {\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null | undefined;\n  };\n  fileType?: string;\n  ipAddress?: string;\n  useAdvancedExcelViewer?: boolean;\n  isPreview?: boolean;\n  canDownload?: boolean;\n  verificationToken?: string;\n  viewerEmail?: string;\n  viewerId?: string;\n  conversationsEnabled?: boolean;\n  isTeamMember?: boolean;\n  agentsEnabled?: boolean;\n  dataroomName?: string;\n};\n\nexport default function DataroomDocumentView({\n  link,\n  userEmail,\n  userId,\n  isProtected,\n  notionData,\n  brand,\n  token,\n  verifiedEmail,\n  useAdvancedExcelViewer,\n  previewToken,\n  disableEditEmail,\n  useCustomAccessForm,\n  isEmbedded,\n  preview,\n  logoOnAccessForm,\n  textSelectionEnabled,\n}: {\n  link: LinkWithDataroomDocument;\n  userEmail: string | null | undefined;\n  userId: string | null | undefined;\n  isProtected: boolean;\n  notionData?: {\n    rootNotionPageId: string | null;\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null;\n  };\n  brand?: Partial<DataroomBrand> | null;\n  token?: string;\n  verifiedEmail?: string;\n  useAdvancedExcelViewer?: boolean;\n  previewToken?: string;\n  disableEditEmail?: boolean;\n  useCustomAccessForm?: boolean;\n  isEmbedded?: boolean;\n  preview?: boolean;\n  logoOnAccessForm?: boolean;\n  textSelectionEnabled?: boolean;\n}) {\n  useDisablePrint();\n  const {\n    linkType,\n    emailProtected,\n    password: linkPassword,\n    enableAgreement,\n  } = link;\n\n  const analytics = useAnalytics();\n  const router = useRouter();\n\n  const didMount = useRef<boolean>(false);\n  const [submitted, setSubmitted] = useState<boolean>(false);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [viewData, setViewData] = useState<DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE>(\n    { viewId: \"\" },\n  );\n  const [data, setData] = useState<DEFAULT_ACCESS_FORM_TYPE>(\n    DEFAULT_ACCESS_FORM_DATA,\n  );\n  const [verificationRequested, setVerificationRequested] =\n    useState<boolean>(false);\n  const [verificationToken, setVerificationToken] = useState<string | null>(\n    token ?? null,\n  );\n\n  const [code, setCode] = useState<string | null>(null);\n  const [isInvalidCode, setIsInvalidCode] = useState<boolean>(false);\n\n  const handleSubmission = async (): Promise<void> => {\n    setIsLoading(true);\n    const response = await fetch(\"/api/views-dataroom\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...data,\n        email: data.email ?? verifiedEmail ?? userEmail ?? null,\n        linkId: link.id,\n        documentId: link.dataroomDocument.document.id,\n        documentName: link.dataroomDocument.document.name,\n        userId: userId ?? null,\n        documentVersionId: link.dataroomDocument.document.versions[0].id,\n        hasPages: link.dataroomDocument.document.versions[0].hasPages,\n        startPage: router.query.p ? Number(router.query.p) : undefined,\n        dataroomId: link.dataroomId,\n        linkType: \"DATAROOM_LINK\",\n        dataroomViewId: viewData.dataroomViewId ?? null,\n        viewType: \"DOCUMENT_VIEW\",\n        useAdvancedExcelViewer,\n        previewToken,\n        code: code ?? undefined,\n        token: verificationToken ?? undefined,\n        verifiedEmail: verifiedEmail ?? undefined,\n      }),\n    });\n\n    if (response.ok) {\n      const fetchData = await response.json();\n\n      if (fetchData.type === \"email-verification\") {\n        analytics.capture(\"Email Verification Requested\", {\n          linkId: link.id,\n          documentId: link.dataroomDocument.document.id,\n          documentName: link.dataroomDocument.document.name,\n          dataroomId: link.dataroomId,\n          linkType: \"DATAROOM_LINK\",\n          viewerEmail: data.email ?? verifiedEmail ?? userEmail,\n          teamId: link.teamId,\n        });\n        setVerificationRequested(true);\n        setIsLoading(false);\n      } else {\n        const {\n          viewId,\n          file,\n          pages,\n          notionData,\n          sheetData,\n          fileType,\n          isPreview,\n          ipAddress,\n          useAdvancedExcelViewer,\n          canDownload,\n          verificationToken,\n          viewerEmail,\n          viewerId,\n          conversationsEnabled,\n          isTeamMember,\n          agentsEnabled,\n          dataroomName,\n        } = fetchData as DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE;\n        analytics.identify(\n          userEmail ?? viewerEmail ?? verifiedEmail ?? data.email ?? undefined,\n        );\n        analytics.capture(\"Link Viewed\", {\n          linkId: link.id,\n          documentId: link.dataroomDocument.document.id,\n          dataroomId: link.dataroomId,\n          linkType: linkType,\n          viewerId: viewerId,\n          viewerEmail: viewerEmail ?? data.email ?? verifiedEmail ?? userEmail,\n          isEmbedded,\n          isTeamMember,\n          teamId: link.teamId,\n        });\n\n        // set the verification token to the cookie\n        // TODO: remove verificaiton token for something simpler as we are setting the token on cookie directly\n        if (verificationToken) {\n          // Cookies.set(\"pm_vft\", verificationToken, {\n          //   path: router.asPath.split(\"?\")[0],\n          //   expires: 1,\n          //   sameSite: \"strict\",\n          //   secure: true,\n          // });\n          setCode(null);\n        }\n\n        setViewData((prev) => ({\n          viewId,\n          dataroomViewId: prev.dataroomViewId,\n          file,\n          pages,\n          notionData,\n          sheetData,\n          fileType,\n          isPreview,\n          ipAddress,\n          useAdvancedExcelViewer,\n          canDownload,\n          viewerEmail,\n          viewerId,\n          conversationsEnabled,\n          isTeamMember,\n          agentsEnabled,\n          dataroomName,\n        }));\n        setSubmitted(true);\n        setVerificationRequested(false);\n        setIsLoading(false);\n      }\n    } else {\n      const data = await response.json();\n      toast.error(data.message);\n\n      if (data.resetVerification) {\n        const currentPath = router.asPath.split(\"?\")[0];\n\n        Cookies.remove(\"pm_vft\", { path: currentPath });\n        setVerificationToken(null);\n        setCode(null);\n        setIsInvalidCode(true);\n      }\n      setIsLoading(false);\n    }\n  };\n\n  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (\n    event: React.FormEvent,\n  ): Promise<void> => {\n    event.preventDefault();\n    await handleSubmission();\n  };\n\n  // If token is present, run handle submit which will verify token and get document\n  // If link is not submitted and does not have email / password protection, show the access form\n  useEffect(() => {\n    if (!didMount.current) {\n      if (\n        (!submitted && !isProtected) ||\n        token ||\n        preview ||\n        viewData.dataroomViewId ||\n        previewToken\n      ) {\n        handleSubmission();\n        didMount.current = true;\n      }\n    }\n  }, [\n    submitted,\n    isProtected,\n    token,\n    preview,\n    viewData.dataroomViewId,\n    previewToken,\n  ]);\n\n  // Components to render when email is submitted but verification is pending\n  if (verificationRequested) {\n    return (\n      <EmailVerificationMessage\n        onSubmitHandler={handleSubmit}\n        data={data}\n        isLoading={isLoading}\n        code={code}\n        setCode={setCode}\n        isInvalidCode={isInvalidCode}\n        setIsInvalidCode={setIsInvalidCode}\n        brand={brand}\n      />\n    );\n  }\n\n  // If link is not submitted and does not have email / password protection, show the access form\n  if (!submitted && isProtected) {\n    return (\n      <AccessForm\n        data={data}\n        email={userEmail}\n        setData={setData}\n        onSubmitHandler={handleSubmit}\n        requireEmail={emailProtected}\n        requirePassword={!!linkPassword}\n        requireAgreement={enableAgreement!}\n        agreementName={link.agreement?.name}\n        agreementContent={link.agreement?.content}\n        agreementContentType={link.agreement?.contentType}\n        requireName={link.agreement?.requireName}\n        isLoading={isLoading}\n        disableEditEmail={disableEditEmail}\n        useCustomAccessForm={useCustomAccessForm}\n        brand={brand}\n        customFields={link.customFields}\n        logoOnAccessForm={logoOnAccessForm}\n      />\n    );\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"flex h-screen items-center justify-center\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n  return (\n    <div\n      className=\"bg-gray-950\"\n      style={{\n        backgroundColor:\n          brand && brand.accentColor ? brand.accentColor : \"rgb(3, 7, 18)\",\n      }}\n    >\n      {submitted ? (\n        <ViewData\n          dataroomId={link.dataroomId!}\n          link={link}\n          document={link.dataroomDocument.document as TViewDocumentData}\n          viewData={viewData}\n          notionData={notionData}\n          brand={brand}\n          showPoweredByBanner={false}\n          showAccountCreationSlide={false}\n          useAdvancedExcelViewer={\n            viewData.useAdvancedExcelViewer ?? useAdvancedExcelViewer\n          }\n          viewerEmail={\n            viewData.viewerEmail ??\n            data.email ??\n            verifiedEmail ??\n            userEmail ??\n            undefined\n          }\n          canDownload={viewData.canDownload}\n          textSelectionEnabled={textSelectionEnabled}\n        />\n      ) : (\n        <div className=\"flex h-screen items-center justify-center\">\n          <LoadingSpinner className=\"h-20 w-20\" />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/dataroom-view.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\n\nimport { DataroomBrand } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { toast } from \"sonner\";\n\nimport { PendingUploadsProvider } from \"@/context/pending-uploads-context\";\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from \"@/lib/constants\";\nimport { useDisablePrint } from \"@/lib/hooks/use-disable-print\";\nimport { LinkWithDataroom } from \"@/lib/types\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport AccessForm, {\n  DEFAULT_ACCESS_FORM_DATA,\n  DEFAULT_ACCESS_FORM_TYPE,\n} from \"@/components/view/access-form\";\n\nimport EmailVerificationMessage from \"../access-form/email-verification-form\";\nimport DataroomViewer from \"../viewer/dataroom-viewer\";\n\nexport type TSupportedDocumentSimpleType =\n  (typeof SUPPORTED_DOCUMENT_SIMPLE_TYPES)[number];\n\nexport type TDocumentData = {\n  id: string;\n  name: string;\n  hasPages: boolean;\n  documentType: TSupportedDocumentSimpleType;\n  documentVersionId: string;\n  documentVersionNumber: number;\n  downloadOnly: boolean;\n  isVertical?: boolean;\n};\n\nexport type DEFAULT_DATAROOM_VIEW_TYPE = {\n  viewId?: string;\n  isPreview?: boolean;\n  verificationToken?: string;\n  viewerEmail?: string;\n  viewerId?: string;\n  conversationsEnabled?: boolean;\n  enableVisitorUpload?: boolean;\n  isTeamMember?: boolean;\n  agentsEnabled?: boolean;\n  dataroomName?: string;\n};\n\nexport default function DataroomView({\n  link,\n  userEmail,\n  userId,\n  isProtected,\n  brand,\n  token,\n  verifiedEmail,\n  previewToken,\n  disableEditEmail,\n  useCustomAccessForm,\n  logoOnAccessForm,\n  isEmbedded,\n  preview,\n  dataroomIndexEnabled,\n  textSelectionEnabled,\n}: {\n  link: LinkWithDataroom;\n  userEmail: string | null | undefined;\n  userId: string | null | undefined;\n  isProtected: boolean;\n  brand?: Partial<DataroomBrand> | null;\n  token?: string;\n  verifiedEmail?: string;\n  previewToken?: string;\n  disableEditEmail?: boolean;\n  useCustomAccessForm?: boolean;\n  isEmbedded?: boolean;\n  preview?: boolean;\n  logoOnAccessForm?: boolean;\n  dataroomIndexEnabled?: boolean;\n  textSelectionEnabled?: boolean;\n}) {\n  useDisablePrint();\n  const {\n    linkType,\n    dataroom,\n    emailProtected,\n    password: linkPassword,\n    enableAgreement,\n    group,\n  } = link;\n\n  const analytics = useAnalytics();\n  const router = useRouter();\n  const [folderId, setFolderId] = useState<string | null>(null);\n\n  const didMount = useRef<boolean>(false);\n  const [submitted, setSubmitted] = useState<boolean>(false);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [viewData, setViewData] = useState<DEFAULT_DATAROOM_VIEW_TYPE>({\n    viewId: \"\",\n  });\n  const [data, setData] = useState<DEFAULT_ACCESS_FORM_TYPE>(\n    DEFAULT_ACCESS_FORM_DATA,\n  );\n  const [verificationRequested, setVerificationRequested] =\n    useState<boolean>(false);\n  const [verificationToken, setVerificationToken] = useState<string | null>(\n    token ?? null,\n  );\n\n  const [code, setCode] = useState<string | null>(null);\n  const [isInvalidCode, setIsInvalidCode] = useState<boolean>(false);\n  const shouldApplyAccentToDataroomView = !!(brand as any)\n    ?.applyAccentColorToDataroomView;\n  const dataroomViewBackgroundColor = shouldApplyAccentToDataroomView\n    ? brand?.accentColor\n    : \"#ffffff\";\n\n  const handleSubmission = async (): Promise<void> => {\n    setIsLoading(true);\n    const response = await fetch(\"/api/views-dataroom\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...data,\n        email: data.email ?? verifiedEmail ?? userEmail ?? null,\n        linkId: link.id,\n        userId: userId ?? null,\n        dataroomId: dataroom?.id,\n        linkType: \"DATAROOM_LINK\",\n        viewType: \"DATAROOM_VIEW\",\n        previewToken,\n        code: code ?? undefined,\n        token: verificationToken ?? undefined,\n        verifiedEmail: verifiedEmail ?? undefined,\n      }),\n    });\n\n    if (response.ok) {\n      const fetchData = await response.json();\n\n      if (fetchData.type === \"email-verification\") {\n        analytics.capture(\"Email Verification Requested\", {\n          linkId: link.id,\n          dataroomId: dataroom?.id,\n          dataroomName: dataroom?.name,\n          linkType: \"DATAROOM_LINK\",\n          viewerEmail: data.email ?? verifiedEmail ?? userEmail,\n          teamId: link.teamId,\n        });\n        setVerificationRequested(true);\n        setIsLoading(false);\n      } else {\n        const {\n          viewId,\n          isPreview,\n          verificationToken,\n          viewerEmail,\n          viewerId,\n          conversationsEnabled,\n          enableVisitorUpload,\n          isTeamMember,\n          agentsEnabled,\n          dataroomName,\n        } = fetchData as DEFAULT_DATAROOM_VIEW_TYPE;\n\n        analytics.identify(\n          userEmail ?? viewerEmail ?? verifiedEmail ?? data.email ?? undefined,\n        );\n        analytics.capture(\"Link Viewed\", {\n          linkId: link.id,\n          dataroomId: dataroom?.id,\n          linkType: linkType,\n          viewerId: viewerId,\n          viewerEmail: viewerEmail ?? data.email ?? verifiedEmail ?? userEmail,\n          isEmbedded,\n          isTeamMember,\n          teamId: link.teamId,\n        });\n\n        // set the verification token to the cookie\n        if (verificationToken) {\n          // Cookies.set(\"pm_vft\", verificationToken, {\n          //   path: router.asPath.split(\"?\")[0],\n          //   expires: 1,\n          //   sameSite: \"strict\",\n          //   secure: true,\n          // });\n          setCode(null);\n        }\n\n        setViewData({\n          viewId,\n          isPreview,\n          viewerEmail,\n          viewerId,\n          conversationsEnabled,\n          enableVisitorUpload,\n          isTeamMember,\n          agentsEnabled,\n          dataroomName,\n        });\n        setSubmitted(true);\n        setVerificationRequested(false);\n        setIsLoading(false);\n      }\n    } else {\n      const data = await response.json();\n      toast.error(data.message);\n\n      if (data.resetVerification) {\n        const currentPath = router.asPath.split(\"?\")[0];\n\n        Cookies.remove(\"pm_vft\", { path: currentPath });\n        setVerificationToken(null);\n        setCode(null);\n        setIsInvalidCode(true);\n      }\n      setIsLoading(false);\n    }\n  };\n\n  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (\n    event: React.FormEvent,\n  ): Promise<void> => {\n    event.preventDefault();\n    await handleSubmission();\n  };\n\n  // If token is present, run handle submit which will verify token and get document\n  // If link is not submitted and does not have email / password protection, show the access form\n  useEffect(() => {\n    if (!didMount.current) {\n      if ((!submitted && !isProtected) || token || preview || previewToken) {\n        handleSubmission();\n        didMount.current = true;\n      }\n    }\n  }, [submitted, isProtected, token, preview, previewToken]);\n\n  // Components to render when email is submitted but verification is pending\n  if (verificationRequested) {\n    return (\n      <EmailVerificationMessage\n        onSubmitHandler={handleSubmit}\n        data={data}\n        isLoading={isLoading}\n        code={code}\n        setCode={setCode}\n        isInvalidCode={isInvalidCode}\n        setIsInvalidCode={setIsInvalidCode}\n        brand={brand}\n      />\n    );\n  }\n\n  // If link is not submitted and does not have email / password protection, show the access form\n  if (!submitted && isProtected) {\n    return (\n      <AccessForm\n        data={data}\n        email={userEmail}\n        setData={setData}\n        onSubmitHandler={handleSubmit}\n        requireEmail={emailProtected}\n        requirePassword={!!linkPassword}\n        requireAgreement={enableAgreement!}\n        agreementName={link.agreement?.name}\n        agreementContent={link.agreement?.content}\n        agreementContentType={link.agreement?.contentType}\n        requireName={link.agreement?.requireName}\n        isLoading={isLoading}\n        disableEditEmail={disableEditEmail}\n        useCustomAccessForm={useCustomAccessForm}\n        brand={brand}\n        customFields={link.customFields}\n        logoOnAccessForm={logoOnAccessForm}\n        linkWelcomeMessage={link.welcomeMessage}\n      />\n    );\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"flex h-screen items-center justify-center\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n\n  if (submitted) {\n    return (\n      <PendingUploadsProvider linkId={link.id} dataroomId={dataroom?.id}>\n        <div\n          className=\"min-h-screen bg-white\"\n          style={{ backgroundColor: dataroomViewBackgroundColor ?? undefined }}\n        >\n          <DataroomViewer\n            accessControls={link.accessControls || group?.accessControls || []}\n            brand={brand!}\n            viewId={viewData.viewId}\n            isPreview={viewData.isPreview}\n            linkId={link.id}\n            dataroom={dataroom}\n            allowDownload={link.allowDownload!}\n            enableIndexFile={link.enableIndexFile}\n            folderId={folderId}\n            setFolderId={setFolderId}\n            viewerId={viewData.viewerId}\n            viewData={viewData}\n            isEmbedded={isEmbedded}\n            dataroomIndexEnabled={dataroomIndexEnabled}\n            viewerEmail={\n              viewData.viewerEmail ??\n              data.email ??\n              verifiedEmail ??\n              userEmail ??\n              undefined\n            }\n          />\n        </div>\n      </PendingUploadsProvider>\n    );\n  }\n\n  return (\n    <div\n      className=\"min-h-screen bg-white\"\n      style={{ backgroundColor: dataroomViewBackgroundColor ?? undefined }}\n    >\n      <div className=\"flex h-screen items-center justify-center\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/document-card.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport React from \"react\";\n\nimport { Download, MoreVerticalIcon } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { toast } from \"sonner\";\n\nimport { timeAgo } from \"@/lib/utils\";\nimport { cn } from \"@/lib/utils\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  getHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { useViewerSurfaceTheme } from \"@/components/view/viewer/viewer-surface-theme\";\n\nimport { DocumentVersion } from \"../viewer/dataroom-viewer\";\n\ntype DRDocument = {\n  dataroomDocumentId: string;\n  id: string;\n  name: string;\n  downloadOnly: boolean;\n  versions: DocumentVersion[];\n  canDownload: boolean;\n  hierarchicalIndex: string | null;\n};\n\ntype DocumentsCardProps = {\n  document: DRDocument;\n  linkId: string;\n  viewId?: string;\n  isPreview: boolean;\n  allowDownload: boolean;\n  isProcessing?: boolean;\n  dataroomIndexEnabled?: boolean;\n  showLastUpdated?: boolean;\n};\n\nexport default function DocumentCard({\n  document,\n  linkId,\n  viewId,\n  isPreview,\n  allowDownload,\n  isProcessing = false,\n  dataroomIndexEnabled,\n  showLastUpdated = true,\n}: DocumentsCardProps) {\n  const { theme, systemTheme } = useTheme();\n  const { palette } = useViewerSurfaceTheme();\n  const canDownload = document.canDownload && allowDownload;\n\n  const isLight =\n    theme === \"light\" || (theme === \"system\" && systemTheme === \"light\");\n  const router = useRouter();\n\n  // Get hierarchical display name\n  const displayName = getHierarchicalDisplayName(\n    document.name,\n    document.hierarchicalIndex,\n    dataroomIndexEnabled || false,\n  );\n  const { previewToken, domain, slug } = router.query as {\n    previewToken?: string;\n    domain?: string;\n    slug?: string;\n  };\n\n  const handleDocumentClick = (e: React.MouseEvent) => {\n    if (isProcessing) {\n      e.preventDefault();\n      toast.error(\n        \"Document is still processing. Please wait a moment and try again.\",\n      );\n      return;\n    }\n\n    e.preventDefault();\n    // Open in new tab\n    if (domain && slug) {\n      window.open(`/${slug}/d/${document.dataroomDocumentId}`, \"_blank\");\n    } else {\n      window.open(\n        `/view/${linkId}/d/${document.dataroomDocumentId}${\n          previewToken ? `?previewToken=${previewToken}&preview=1` : \"\"\n        }`,\n        \"_blank\",\n      );\n    }\n  };\n\n  const downloadDocument = async () => {\n    if (isPreview) {\n      toast.error(\"You cannot download dataroom document in preview mode.\");\n      return;\n    }\n\n    const downloadPromise = (async () => {\n      const response = await fetch(`/api/links/download/dataroom-document`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          linkId,\n          viewId,\n          documentId: document.id,\n        }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({}));\n        const errorMessage = errorData.error || \"Failed to download file\";\n        throw new Error(errorMessage);\n      }\n\n      // Check if the response is JSON (for direct downloads) or binary (for buffered files)\n      const contentType = response.headers.get(\"content-type\");\n\n      // If it's a watermarked PDF, handle it with the buffer method\n      if (contentType?.includes(\"application/pdf\")) {\n        const blob = await response.blob();\n        const url = window.URL.createObjectURL(blob);\n\n        const link = window.document.createElement(\"a\");\n        link.href = url;\n        const disposition = response.headers.get(\"content-disposition\");\n        const filenameMatch =\n          disposition && disposition.match(/filename=\"(.+)\"/);\n        link.download = filenameMatch\n          ? decodeURIComponent(filenameMatch[1])\n          : document.name;\n        link.rel = \"noopener noreferrer\";\n        window.document.body.appendChild(link);\n        link.click();\n\n        setTimeout(() => {\n          window.URL.revokeObjectURL(url);\n          window.document.body.removeChild(link);\n        }, 100);\n\n        return \"File downloaded successfully\";\n      }\n\n      // For all other files, use a hidden iframe to trigger the download\n      if (contentType?.includes(\"application/json\")) {\n        const data = await response.json();\n\n        const iframe = window.document.createElement(\"iframe\");\n        iframe.style.display = \"none\";\n        window.document.body.appendChild(iframe);\n        iframe.src = data.downloadUrl;\n\n        setTimeout(() => {\n          if (iframe.parentNode) {\n            window.document.body.removeChild(iframe);\n          }\n        }, 5000);\n\n        return \"File downloaded successfully\";\n      }\n\n      throw new Error(\"Unexpected response format\");\n    })();\n\n    toast.promise(downloadPromise, {\n      loading: \"Preparing download...\",\n      success: (message) => message,\n      error: (err) => err.message || \"Failed to download file\",\n    });\n  };\n\n  return (\n    <div\n      className={cn(\n        \"group/row relative flex items-center justify-between rounded-lg border p-3 transition-all sm:p-4\",\n        \"bg-[var(--viewer-panel-bg)] hover:bg-[var(--viewer-panel-bg-hover)]\",\n        \"border-[var(--viewer-panel-border)] hover:border-[var(--viewer-panel-border-hover)]\",\n        isProcessing && \"cursor-not-allowed opacity-60\",\n      )}\n      style={\n        {\n          \"--viewer-panel-bg\": palette.panelBgColor,\n          \"--viewer-panel-bg-hover\": palette.panelHoverBgColor,\n          \"--viewer-panel-border\": palette.panelBorderColor,\n          \"--viewer-panel-border-hover\": palette.panelBorderHoverColor,\n          \"--viewer-text\": palette.textColor,\n          \"--viewer-muted-text\": palette.mutedTextColor,\n          \"--viewer-control-bg\": palette.controlBgColor,\n          \"--viewer-control-border\": palette.controlBorderColor,\n          \"--viewer-control-border-strong\": palette.controlBorderStrongColor,\n          \"--viewer-control-icon\": palette.controlIconColor,\n        } as React.CSSProperties\n      }\n    >\n      {/* Click target - outside of text hierarchy to fix Safari truncation issue */}\n      <button\n        onClick={handleDocumentClick}\n        className=\"absolute inset-0 z-0 cursor-pointer\"\n        disabled={isProcessing}\n        aria-hidden=\"true\"\n      />\n      <div className=\"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\">\n        <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n          {fileIcon({\n            fileType: document.versions[0].type ?? \"\",\n            className: \"h-8 w-8\",\n            isLight,\n          })}\n        </div>\n\n        <div className=\"min-w-0 flex-1 flex-col\">\n          <div className=\"flex items-center\">\n            <h2\n              className=\"truncate text-sm font-semibold leading-6 text-[var(--viewer-text)]\"\n              style={HIERARCHICAL_DISPLAY_STYLE}\n            >\n              {displayName}\n              {isProcessing && (\n                <span\n                  className=\"ml-2 text-xs text-[var(--viewer-muted-text)]\"\n                >\n                  (Processing...)\n                </span>\n              )}\n            </h2>\n          </div>\n          {showLastUpdated && (\n            <div\n              className=\"mt-1 flex items-center space-x-1 text-xs leading-5 text-[var(--viewer-muted-text)]\"\n            >\n              <p className=\"truncate\">\n                Updated {timeAgo(document.versions[0].updatedAt)}\n              </p>\n            </div>\n          )}\n        </div>\n      </div>\n      {canDownload && !isProcessing && (\n        <div className=\"z-10\">\n          <DropdownMenu>\n            <DropdownMenuTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className={cn(\n                  \"h-8 w-8 border bg-transparent p-0\",\n                  \"text-[var(--viewer-control-icon)] border-[var(--viewer-control-border)] hover:bg-[var(--viewer-control-bg)]\",\n                  \"group-hover/row:text-[var(--viewer-text)] group-hover/row:border-[var(--viewer-control-border-strong)]\",\n                )}\n                aria-label=\"Open menu\"\n              >\n                <MoreVerticalIcon className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuLabel>Actions</DropdownMenuLabel>\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  downloadDocument();\n                }}\n              >\n                <Download className=\"h-4 w-4\" />\n                Download\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/document-upload-modal.tsx",
    "content": "import { useState } from \"react\";\n\nimport {\n  CheckCircle2,\n  FolderIcon,\n  PlusIcon,\n  UploadIcon,\n} from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ViewerUploadComponent } from \"@/components/viewer-upload-component\";\n\nexport function DocumentUploadModal({\n  linkId,\n  dataroomId,\n  viewerId,\n  folderId,\n  folderName,\n}: {\n  linkId: string;\n  dataroomId: string;\n  viewerId: string;\n  folderId?: string;\n  /** Display name of the current folder (undefined = root) */\n  folderName?: string;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [uploadSuccess, setUploadSuccess] = useState(false);\n\n  const handleUploadSuccess = () => {\n    setUploadSuccess(true);\n    // Auto-close the dialog after a short delay to show success message\n    setTimeout(() => {\n      setIsOpen(false);\n      setUploadSuccess(false);\n    }, 1500);\n  };\n\n  const handleOpenChange = (open: boolean) => {\n    setIsOpen(open);\n    if (!open) {\n      setUploadSuccess(false);\n    }\n  };\n\n  return (\n    <>\n      <Button\n        onClick={() => setIsOpen(true)}\n        size=\"sm\"\n        variant=\"outline\"\n        className=\"group flex items-center justify-start gap-x-3 px-3 text-left\"\n        title=\"Add Document\"\n      >\n        <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n        <span>Add Document</span>\n      </Button>\n\n      <Dialog open={isOpen} onOpenChange={handleOpenChange}>\n        <DialogContent className=\"max-h-[85vh] max-w-2xl overflow-hidden border-0 p-0 shadow-2xl sm:max-w-xl sm:rounded-2xl\">\n          <DialogHeader className=\"border-b border-gray-100 bg-gray-50 px-6 py-5 dark:border-gray-800 dark:bg-gray-900\">\n            <DialogTitle className=\"flex items-center gap-2 text-lg font-semibold\">\n              <UploadIcon className=\"h-5 w-5 text-muted-foreground\" />\n              Upload Document to Dataroom\n            </DialogTitle>\n            <p className=\"mt-1 text-sm text-muted-foreground\">\n              Your document will appear immediately in the dataroom and will be\n              processed in the background.\n            </p>\n            {folderName && (\n              <div className=\"mt-2 flex items-center gap-1.5 text-xs text-muted-foreground\">\n                <FolderIcon className=\"h-3.5 w-3.5\" />\n                <span>\n                  Uploading to:{\" \"}\n                  <span className=\"font-medium text-foreground\">\n                    {folderName}\n                  </span>\n                </span>\n              </div>\n            )}\n          </DialogHeader>\n\n          <div className=\"px-6 py-5\">\n            {uploadSuccess ? (\n              <div className=\"flex flex-col items-center justify-center py-8\">\n                <CheckCircle2 className=\"h-12 w-12 text-green-500\" />\n                <p className=\"mt-3 text-sm font-medium text-foreground\">\n                  Document uploaded successfully!\n                </p>\n                <p className=\"mt-1 text-xs text-muted-foreground\">\n                  Your document is now visible in the dataroom.\n                </p>\n              </div>\n            ) : (\n              <ViewerUploadComponent\n                viewerData={{\n                  id: viewerId,\n                  linkId,\n                  dataroomId,\n                }}\n                teamId=\"visitor-upload\"\n                folderId={folderId}\n                onUploadSuccess={handleUploadSuccess}\n              />\n            )}\n          </div>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/download-otp-verification.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport {\n  InputOTP,\n  InputOTPGroup,\n  InputOTPSlot,\n} from \"@/components/ui/input-otp\";\nimport { Button } from \"@/components/ui/button\";\n\nconst REGEXP_ONLY_DIGITS = \"^\\\\d+$\";\n\nexport interface DownloadOtpVerificationProps {\n  linkId: string;\n  /** viewId is optional — when omitted the server resolves the view by email + linkId. */\n  viewId?: string;\n  email: string;\n  onVerified: () => void;\n  onCancel?: () => void;\n  compact?: boolean;\n  /** When true, send OTP email automatically on mount (e.g. when user just chose \"Notify me\" and clicked Start download). */\n  sendOtpOnMount?: boolean;\n}\n\nconst RESEND_COOLDOWN_SECONDS = 60;\n\nexport function DownloadOtpVerification({\n  linkId,\n  viewId,\n  email,\n  onVerified,\n  onCancel,\n  compact = false,\n  sendOtpOnMount = false,\n}: DownloadOtpVerificationProps) {\n  const [code, setCode] = useState<string | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n  const [isSending, setIsSending] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [resendCooldown, setResendCooldown] = useState(0);\n  const hasSentOnMountRef = useRef(false);\n\n  const sendOtp = useCallback(async () => {\n    setIsSending(true);\n    setError(null);\n    try {\n      const body: Record<string, string> = { linkId, email };\n      if (viewId) body.viewId = viewId;\n      const res = await fetch(\"/api/links/download/verify\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        credentials: \"include\",\n        body: JSON.stringify(body),\n      });\n      const data = await res.json().catch(() => ({}));\n      if (!res.ok) {\n        setError(data.error ?? \"Failed to send code\");\n        return;\n      }\n      setResendCooldown(RESEND_COOLDOWN_SECONDS);\n    } finally {\n      setIsSending(false);\n    }\n  }, [linkId, viewId, email]);\n\n  useEffect(() => {\n    if (!sendOtpOnMount || hasSentOnMountRef.current) return;\n    hasSentOnMountRef.current = true;\n    sendOtp();\n  }, [sendOtpOnMount, sendOtp]);\n\n  const verifyOtp = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!code || code.length !== 6) return;\n    setIsLoading(true);\n    setError(null);\n    try {\n      const body: Record<string, string> = { linkId, email, code: code! };\n      if (viewId) body.viewId = viewId;\n      const res = await fetch(\"/api/links/download/verify\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        credentials: \"include\",\n        body: JSON.stringify(body),\n      });\n      const data = await res.json().catch(() => ({}));\n      if (!res.ok) {\n        setError(data.error ?? \"Invalid code\");\n        return;\n      }\n      onVerified();\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  useEffect(() => {\n    if (resendCooldown <= 0) return;\n    const t = setInterval(\n      () => setResendCooldown((s) => Math.max(0, s - 1)),\n      1000,\n    );\n    return () => clearInterval(t);\n  }, [resendCooldown]);\n\n  return (\n    <form onSubmit={verifyOtp} className=\"space-y-4\">\n      <p className=\"text-sm text-muted-foreground\">\n        We sent a 6-digit code to <strong>{email}</strong>. Enter it below to\n        verify and receive download notifications.\n      </p>\n      <InputOTP\n        maxLength={6}\n        pattern={REGEXP_ONLY_DIGITS}\n        value={code ?? \"\"}\n        onChange={(v) => {\n          setError(null);\n          setCode(v || null);\n        }}\n        containerClassName=\"my-4\"\n        accentColor=\"#e5e5e5\"\n      >\n        <InputOTPGroup className=\"[&>div]:border-input [&>div]:text-foreground [&>div]:caret-foreground\">\n          {[0, 1, 2, 3, 4, 5].map((index) => (\n            <InputOTPSlot key={index} index={index} />\n          ))}\n        </InputOTPGroup>\n      </InputOTP>\n      {error ? (\n        <p className=\"text-sm text-destructive\">{error}</p>\n      ) : null}\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <Button\n          type=\"submit\"\n          disabled={!code || code.length !== 6 || isLoading}\n        >\n          {isLoading ? \"Verifying...\" : \"Verify\"}\n        </Button>\n      </div>\n      <p className=\"text-sm text-muted-foreground\">\n        Didn&apos;t receive the email?{\" \"}\n        <Button\n          type=\"button\"\n          variant=\"link\"\n          className=\"h-auto p-0 text-sm font-normal text-muted-foreground underline\"\n          disabled={isSending || resendCooldown > 0}\n          onClick={sendOtp}\n        >\n          {isSending\n            ? \"Sending...\"\n            : resendCooldown > 0\n              ? `Resend code (${resendCooldown}s)`\n              : \"Resend code\"}\n        </Button>\n      </p>\n    </form>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/downloads-panel.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport {\n  CheckCircle2,\n  ChevronDown,\n  ChevronUp,\n  Download,\n  FileArchive,\n  FolderOpen,\n  Loader2,\n} from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { DownloadOtpVerification } from \"@/components/view/dataroom/download-otp-verification\";\n\ntype Job = {\n  id: string;\n  status: string;\n  progress: number;\n  totalFiles: number;\n  processedFiles: number;\n  downloadUrls?: string[];\n  error?: string;\n  dataroomName: string;\n  type?: \"bulk\" | \"folder\";\n  folderName?: string;\n  createdAt: string;\n  expiresAt?: string;\n};\n\nconst POLL_INTERVAL_MS = 5_000;\n\nexport function DownloadsPanel({ linkId }: { linkId: string }) {\n  const [loading, setLoading] = useState(true);\n  const [hasSession, setHasSession] = useState<boolean | null>(null);\n  const [email, setEmail] = useState(\"\");\n  const [step, setStep] = useState<\"session\" | \"email\" | \"otp\" | \"list\">(\n    \"session\",\n  );\n  const [jobs, setJobs] = useState<Job[]>([]);\n  const [jobsLoading, setJobsLoading] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [expandedJobId, setExpandedJobId] = useState<string | null>(null);\n  const [emailSubmitting, setEmailSubmitting] = useState(false);\n  const [downloadProgress, setDownloadProgress] = useState<{\n    jobId: string;\n    current: number;\n    total: number;\n  } | null>(null);\n\n  // ── Session check ──────────────────────────────────────────────────\n  const checkSession = useCallback(async () => {\n    if (!linkId) return;\n    setLoading(true);\n    setError(null);\n    try {\n      const res = await fetch(\n        `/api/links/download/verify?linkId=${encodeURIComponent(linkId)}`,\n        { credentials: \"include\" },\n      );\n      if (res.ok) {\n        setHasSession(true);\n        setStep(\"list\");\n        return;\n      }\n      setHasSession(false);\n      setStep(\"email\");\n    } catch {\n      setHasSession(false);\n      setStep(\"email\");\n    } finally {\n      setLoading(false);\n    }\n  }, [linkId]);\n\n  useEffect(() => {\n    if (!linkId) return;\n    checkSession();\n  }, [linkId, checkSession]);\n\n  // ── Fetch jobs ─────────────────────────────────────────────────────\n  const fetchJobs = useCallback(async () => {\n    if (!linkId) return;\n    setJobsLoading(true);\n    try {\n      const res = await fetch(\n        `/api/links/download/jobs?linkId=${encodeURIComponent(linkId)}`,\n        { credentials: \"include\" },\n      );\n      if (res.ok) {\n        const data = await res.json();\n        setJobs(Array.isArray(data) ? data : []);\n      } else {\n        setJobs([]);\n      }\n    } catch {\n      setJobs([]);\n    } finally {\n      setJobsLoading(false);\n    }\n  }, [linkId]);\n\n  // Initial fetch when step transitions to \"list\"\n  useEffect(() => {\n    if (step === \"list\" && linkId) {\n      fetchJobs();\n    }\n  }, [step, linkId, fetchJobs]);\n\n  // ── Polling while jobs are in-flight ───────────────────────────────\n  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n  useEffect(() => {\n    // Only poll when we're on the list view\n    if (step !== \"list\") return;\n\n    const hasInFlightJobs = jobs.some(\n      (j) => j.status === \"PROCESSING\" || j.status === \"PENDING\",\n    );\n\n    if (hasInFlightJobs) {\n      // Start polling if not already running\n      if (!intervalRef.current) {\n        intervalRef.current = setInterval(() => {\n          fetchJobs();\n        }, POLL_INTERVAL_MS);\n      }\n    } else {\n      // Stop polling when nothing is in-flight\n      if (intervalRef.current) {\n        clearInterval(intervalRef.current);\n        intervalRef.current = null;\n      }\n    }\n\n    return () => {\n      if (intervalRef.current) {\n        clearInterval(intervalRef.current);\n        intervalRef.current = null;\n      }\n    };\n  }, [step, jobs, fetchJobs]);\n\n  // ── Email submit — send OTP directly (view is resolved server-side) ─\n  const handleEmailSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!linkId || !email.trim()) return;\n    setError(null);\n    setEmailSubmitting(true);\n    try {\n      const res = await fetch(`/api/links/download/verify`, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        credentials: \"include\",\n        body: JSON.stringify({\n          linkId,\n          email: email.trim(),\n        }),\n      });\n      const data = await res.json().catch(() => ({}));\n      if (res.status === 404) {\n        setError(\n          \"No access found for this email. Use the email you used to open the dataroom.\",\n        );\n        return;\n      }\n      if (res.status === 429) {\n        setError(data.error ?? \"Too many requests. Please try again later.\");\n        return;\n      }\n      if (!res.ok) {\n        setError(data.error ?? \"Something went wrong.\");\n        return;\n      }\n      // OTP sent successfully — move to verification step\n      setStep(\"otp\");\n    } catch {\n      setError(\"Something went wrong.\");\n    } finally {\n      setEmailSubmitting(false);\n    }\n  };\n\n  // ── OTP verified ───────────────────────────────────────────────────\n  const handleOtpVerified = () => {\n    setHasSession(true);\n    setStep(\"list\");\n  };\n\n  // ── Download helpers ───────────────────────────────────────────────\n  const handleDownload = (url: string) => {\n    // Ensure we use a relative path so the request goes to the current origin\n    // (where the session cookie lives), not a potentially different subdomain.\n    let href = url;\n    try {\n      const parsed = new URL(url, window.location.origin);\n      href = parsed.pathname + parsed.search;\n    } catch {\n      // url is already relative, use as-is\n    }\n    const a = document.createElement(\"a\");\n    a.href = href;\n    a.rel = \"noopener noreferrer\";\n    document.body.appendChild(a);\n    a.click();\n    setTimeout(() => document.body.removeChild(a), 100);\n  };\n\n  const handleDownloadAll = async (jobId: string, urls: string[]) => {\n    if (downloadProgress) return;\n    setDownloadProgress({ jobId, current: 0, total: urls.length });\n    for (let i = 0; i < urls.length; i++) {\n      setDownloadProgress({ jobId, current: i + 1, total: urls.length });\n      handleDownload(urls[i]);\n      if (i < urls.length - 1) {\n        await new Promise((resolve) => setTimeout(resolve, 2000));\n      }\n    }\n    setDownloadProgress(null);\n  };\n\n  // ── Formatters ─────────────────────────────────────────────────────\n  const formatExpiration = (expiresAt?: string) => {\n    if (!expiresAt) return \"\";\n    const exp = new Date(expiresAt);\n    const now = new Date();\n    if (exp.getTime() <= now.getTime()) return \"expired\";\n    const d = Math.floor(\n      (exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),\n    );\n    if (d > 0) return `${d} day${d > 1 ? \"s\" : \"\"}`;\n    const h = Math.floor(\n      (exp.getTime() - now.getTime()) / (1000 * 60 * 60),\n    );\n    return h > 0 ? `${h} hour${h > 1 ? \"s\" : \"\"}` : \"soon\";\n  };\n\n  const getJobTitle = (job: Job) => {\n    if (job.type === \"folder\" && job.folderName) {\n      return (\n        <>\n          <span className=\"font-medium\">{job.dataroomName}</span>\n          <span className=\"text-muted-foreground\">\n            {\" \"}\n            — <FolderOpen className=\"mr-0.5 inline h-3.5 w-3\" />\n            Folder: {job.folderName}\n          </span>\n        </>\n      );\n    }\n    return (\n      <>\n        <span className=\"font-medium\">{job.dataroomName}</span>\n        {job.type === \"bulk\" && (\n          <span className=\"text-muted-foreground\"> — Full dataroom</span>\n        )}\n      </>\n    );\n  };\n\n  // ── Render ─────────────────────────────────────────────────────────\n\n  if (loading) {\n    return (\n      <div className=\"flex min-h-screen items-center justify-center\">\n        <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  if (step === \"email\") {\n    return (\n      <div className=\"mx-auto flex min-h-screen max-w-sm flex-col justify-center px-4\">\n        <h1 className=\"text-xl font-semibold\">View your downloads</h1>\n        <p className=\"mt-2 text-sm text-muted-foreground\">\n          Enter the email you used to access this dataroom.\n        </p>\n        <form onSubmit={handleEmailSubmit} className=\"mt-6 space-y-4\">\n          <Input\n            type=\"email\"\n            placeholder=\"you@example.com\"\n            value={email}\n            onChange={(e) => setEmail(e.target.value)}\n            required\n          />\n          {error && <p className=\"text-sm text-destructive\">{error}</p>}\n          <Button type=\"submit\" className=\"w-full\" disabled={emailSubmitting}>\n            {emailSubmitting ? \"Sending code...\" : \"Continue\"}\n          </Button>\n        </form>\n      </div>\n    );\n  }\n\n  if (step === \"otp\") {\n    return (\n      <div className=\"mx-auto flex min-h-screen max-w-sm flex-col justify-center px-4\">\n        <h1 className=\"text-xl font-semibold\">Verify your email</h1>\n        <DownloadOtpVerification\n          linkId={linkId}\n          email={email.trim()}\n          onVerified={handleOtpVerified}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"mx-auto min-h-screen max-w-2xl px-4 py-10\">\n      <h1 className=\"text-xl font-semibold\">Your downloads</h1>\n      <p className=\"mt-1 text-sm text-muted-foreground\">\n        Download your prepared files below. Links expire after 3 days.\n      </p>\n\n      {jobsLoading && jobs.length === 0 ? (\n        <div className=\"mt-8 flex justify-center\">\n          <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n        </div>\n      ) : jobs.length === 0 ? (\n        <p className=\"mt-8 text-sm text-muted-foreground\">\n          You have no downloads yet. Start a download from the dataroom to see\n          it here.\n        </p>\n      ) : (\n        <ul className=\"mt-6 space-y-4\">\n          {jobs.map((job) => (\n            <li\n              key={job.id}\n              className=\"flex flex-col gap-2 rounded-lg border p-4\"\n            >\n              <div className=\"flex items-center justify-between\">\n                <div className=\"min-w-0 flex-1 text-sm\">\n                  {getJobTitle(job)}\n                </div>\n                <span className=\"ml-2 shrink-0 text-xs text-muted-foreground\">\n                  {new Date(job.createdAt).toLocaleString()}\n                </span>\n              </div>\n              <div className=\"flex items-center gap-2 text-sm\">\n                {job.status === \"COMPLETED\" ? (\n                  <CheckCircle2 className=\"h-4 w-4 shrink-0 text-green-500\" />\n                ) : job.status === \"PROCESSING\" || job.status === \"PENDING\" ? (\n                  <Loader2 className=\"h-4 w-4 shrink-0 animate-spin text-muted-foreground\" />\n                ) : (\n                  <FileArchive className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n                )}\n                <span className=\"text-muted-foreground\">\n                  {job.status === \"COMPLETED\"\n                    ? \"Ready\"\n                    : job.status === \"PROCESSING\"\n                      ? `${job.processedFiles} / ${job.totalFiles} files`\n                      : job.status}\n                </span>\n              </div>\n              {job.status === \"COMPLETED\" &&\n                job.downloadUrls &&\n                job.downloadUrls.length > 0 && (\n                  <div className=\"mt-2 flex flex-wrap items-center gap-2\">\n                    {job.downloadUrls.length === 1 ? (\n                      <Button\n                        size=\"sm\"\n                        onClick={() => handleDownload(job.downloadUrls![0])}\n                      >\n                        <Download className=\"mr-1 h-3 w-3\" />\n                        Download\n                      </Button>\n                    ) : (\n                      <>\n                        <Button\n                          size=\"sm\"\n                          variant=\"outline\"\n                          onClick={() =>\n                            setExpandedJobId(\n                              expandedJobId === job.id ? null : job.id,\n                            )\n                          }\n                        >\n                          {expandedJobId === job.id ? (\n                            <>\n                              <ChevronUp className=\"mr-1 h-3 w-3\" />\n                              Hide parts\n                            </>\n                          ) : (\n                            <>\n                              <ChevronDown className=\"mr-1 h-3 w-3\" />\n                              Show parts ({job.downloadUrls!.length})\n                            </>\n                          )}\n                        </Button>\n                        {expandedJobId === job.id && (\n                          <div className=\"w-full space-y-2 rounded-md border bg-muted/30 p-3\">\n                            <Button\n                              size=\"sm\"\n                              className=\"w-full\"\n                              disabled={!!downloadProgress}\n                              onClick={() =>\n                                handleDownloadAll(job.id, job.downloadUrls!)\n                              }\n                            >\n                              {downloadProgress?.jobId === job.id ? (\n                                <>\n                                  <Loader2 className=\"mr-2 h-3 w-3 animate-spin\" />\n                                  Downloading {downloadProgress.current} of{\" \"}\n                                  {downloadProgress.total}...\n                                </>\n                              ) : (\n                                <>\n                                  <Download className=\"mr-2 h-3 w-3\" />\n                                  Download all ({job.downloadUrls!.length} parts)\n                                </>\n                              )}\n                            </Button>\n                            <p className=\"text-xs text-muted-foreground\">\n                              Or download individually:\n                            </p>\n                            <div className=\"max-h-32 space-y-1 overflow-y-auto\">\n                              {job.downloadUrls!.map((url, i) => (\n                                <Button\n                                  key={i}\n                                  variant=\"outline\"\n                                  size=\"sm\"\n                                  className=\"w-full justify-start\"\n                                  onClick={() => handleDownload(url)}\n                                >\n                                  <FileArchive className=\"mr-2 h-3 w-3\" />\n                                  Part {i + 1} of {job.downloadUrls!.length}\n                                </Button>\n                              ))}\n                            </div>\n                          </div>\n                        )}\n                      </>\n                    )}\n                    {job.expiresAt && (\n                      <span className=\"text-xs text-muted-foreground\">\n                        Expires {formatExpiration(job.expiresAt)}\n                      </span>\n                    )}\n                  </div>\n                )}\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/folder-card.tsx",
    "content": "import { useState } from \"react\";\nimport type { CSSProperties } from \"react\";\n\nimport { DataroomFolder } from \"@prisma/client\";\nimport { Download, MoreVerticalIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { getFolderColorClasses, getFolderIcon } from \"@/lib/constants/folder-constants\";\nimport { cn, timeAgo } from \"@/lib/utils\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  getHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { useViewerSurfaceTheme } from \"@/components/view/viewer/viewer-surface-theme\";\n\ntype FolderCardProps = {\n  folder: DataroomFolder;\n  dataroomId: string;\n  setFolderId: (id: string) => void;\n  isPreview: boolean;\n  linkId: string;\n  viewId?: string;\n  allowDownload: boolean;\n  dataroomIndexEnabled?: boolean;\n  showLastUpdated?: boolean;\n};\nexport default function FolderCard({\n  folder,\n  dataroomId,\n  setFolderId,\n  isPreview,\n  linkId,\n  viewId,\n  allowDownload,\n  dataroomIndexEnabled,\n  showLastUpdated = true,\n}: FolderCardProps) {\n  const [open, setOpen] = useState(false);\n  const { palette } = useViewerSurfaceTheme();\n\n  // Get hierarchical display name\n  const displayName = getHierarchicalDisplayName(\n    folder.name,\n    folder.hierarchicalIndex,\n    dataroomIndexEnabled || false,\n  );\n  const openFolderDownloadModal = () => {\n    if (!allowDownload) {\n      toast.error(\"Downloading folders is not allowed.\");\n      return;\n    }\n    if (isPreview) {\n      toast.error(\"You cannot download dataroom folders in preview mode.\");\n      return;\n    }\n\n    window.dispatchEvent(\n      new CustomEvent(\"viewer-download-modal-open\", {\n        detail: { folderId: folder.id, folderName: folder.name },\n      }),\n    );\n  };\n\n  return (\n    <div\n      className={cn(\n        \"group/row relative flex items-center justify-between rounded-lg border p-3 transition-all sm:p-4\",\n        \"bg-[var(--viewer-panel-bg)] hover:bg-[var(--viewer-panel-bg-hover)]\",\n        \"border-[var(--viewer-panel-border)] hover:border-[var(--viewer-panel-border-hover)]\",\n      )}\n      style={\n        {\n          \"--viewer-panel-bg\": palette.panelBgColor,\n          \"--viewer-panel-bg-hover\": palette.panelHoverBgColor,\n          \"--viewer-panel-border\": palette.panelBorderColor,\n          \"--viewer-panel-border-hover\": palette.panelBorderHoverColor,\n          \"--viewer-text\": palette.textColor,\n          \"--viewer-muted-text\": palette.mutedTextColor,\n          \"--viewer-control-bg\": palette.controlBgColor,\n          \"--viewer-control-border\": palette.controlBorderColor,\n          \"--viewer-control-border-strong\": palette.controlBorderStrongColor,\n          \"--viewer-control-icon\": palette.controlIconColor,\n        } as CSSProperties\n      }\n    >\n      {/* Click target - outside of text hierarchy to fix Safari truncation issue */}\n      <button\n        onClick={() => setFolderId(folder.id)}\n        className=\"absolute inset-0 z-0 cursor-pointer\"\n        aria-hidden=\"true\"\n      />\n      <div className=\"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\">\n        <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n          {(() => {\n            const FolderIconComponent = getFolderIcon(folder.icon);\n            const colorClasses = getFolderColorClasses(folder.color);\n            return (\n              <FolderIconComponent\n                className={`h-8 w-8 ${colorClasses.iconClass}`}\n                strokeWidth={1}\n              />\n            );\n          })()}\n        </div>\n\n        <div className=\"min-w-0 flex-1 flex-col\">\n          <div className=\"flex items-center\">\n            <h2\n              className=\"truncate text-sm font-semibold leading-6 text-[var(--viewer-text)]\"\n              style={HIERARCHICAL_DISPLAY_STYLE}\n            >\n              {displayName}\n            </h2>\n          </div>\n          {showLastUpdated && (\n            <div\n              className=\"mt-1 flex items-center space-x-1 text-xs leading-5 text-[var(--viewer-muted-text)]\"\n            >\n              <p className=\"truncate\">Updated {timeAgo(folder.updatedAt)}</p>\n            </div>\n          )}\n        </div>\n      </div>\n      {allowDownload ? (\n        <div className=\"z-10\">\n          <DropdownMenu open={open} onOpenChange={setOpen}>\n            <DropdownMenuTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className={cn(\n                  \"h-8 w-8 border bg-transparent p-0\",\n                  \"text-[var(--viewer-control-icon)] border-[var(--viewer-control-border)] hover:bg-[var(--viewer-control-bg)]\",\n                  \"group-hover/row:text-[var(--viewer-text)] group-hover/row:border-[var(--viewer-control-border-strong)]\",\n                )}\n                aria-label=\"Open menu\"\n              >\n                <MoreVerticalIcon className=\"h-4 w-4\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuLabel>Actions</DropdownMenuLabel>\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  openFolderDownloadModal();\n                  setOpen(false);\n                }}\n                disabled={isPreview}\n              >\n                <Download className=\"h-4 w-4\" />\n                Download\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/index-file-dialog.tsx",
    "content": "import { useState } from \"react\";\n\nimport {\n  FileJson,\n  FileSlidersIcon,\n  FileSpreadsheet,\n  FileText,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { IndexFileFormat } from \"@/lib/types/index-file\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\n\ninterface IndexFileDialogProps {\n  linkId: string;\n  viewId: string;\n  disabled?: boolean;\n  dataroomId: string;\n  viewerEmail?: string;\n  viewerId?: string;\n}\n\nexport default function IndexFileDialog({\n  linkId,\n  viewId,\n  disabled = false,\n  dataroomId,\n  viewerEmail,\n  viewerId,\n}: IndexFileDialogProps) {\n  const [isLoading, setIsLoading] = useState(false);\n  const [selectedFormat, setSelectedFormat] =\n    useState<IndexFileFormat>(\"excel\");\n  const [isOpen, setIsOpen] = useState(false);\n  const analytics = useAnalytics();\n\n  const handleGenerateIndex = async () => {\n    if (!linkId) {\n      toast.error(\"Something went wrong. Please try again.\");\n      return;\n    }\n\n    try {\n      setIsLoading(true);\n\n      const response = await fetch(`/api/links/generate-index`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          linkId,\n          format: selectedFormat,\n          dataroomId,\n          viewId,\n          viewerId,\n        }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        throw new Error(errorData.error || \"Failed to generate index\");\n      }\n      analytics.identify(viewerEmail);\n      analytics.capture(\"Generated Index File by visitor\", {\n        linkId: linkId,\n        dataroomId: dataroomId,\n        linkType: \"dataroom\",\n        viewerId: viewerId,\n        viewerEmail: viewerEmail,\n        format: selectedFormat,\n        viewId: viewId,\n      });\n\n      // Get filename from Content-Disposition header\n      const contentDisposition = response.headers.get(\"Content-Disposition\");\n      const filename = contentDisposition?.split(\"filename=\")[1] || \"index\";\n\n      // Create a blob from the response\n      const blob = await response.blob();\n\n      // Create a download link and trigger it\n      const url = window.URL.createObjectURL(blob);\n      const link = document.createElement(\"a\");\n      link.href = url;\n      link.download = filename;\n      link.rel = \"noopener noreferrer\";\n      document.body.appendChild(link);\n      link.click();\n\n      setTimeout(() => {\n        window.URL.revokeObjectURL(url);\n        document.body.removeChild(link);\n      }, 100);\n\n      toast.success(\"Index file generated successfully\");\n      setIsOpen(false);\n    } catch (error) {\n      console.error(\"Error generating index:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to generate index\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={setIsOpen}>\n      <DialogTrigger asChild>\n        <Button variant=\"outline\" size=\"sm\" disabled={disabled}>\n          <FileSlidersIcon />\n          Generate Index File\n        </Button>\n      </DialogTrigger>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>Generate Dataroom Index File</DialogTitle>\n          <DialogDescription>\n            Select a format to generate the index file.\n          </DialogDescription>\n        </DialogHeader>\n        <div className=\"grid gap-4 py-4\">\n          <div className=\"space-y-2\">\n            <h4 className=\"text-sm font-medium\">Select Format</h4>\n            <div className=\"grid grid-cols-2 gap-2\">\n              <Button\n                variant={selectedFormat === \"excel\" ? \"default\" : \"outline\"}\n                onClick={() => {\n                  setSelectedFormat(\"excel\");\n                }}\n                className=\"justify-start\"\n                size=\"sm\"\n              >\n                <FileSpreadsheet className=\"mr-2 h-4 w-4\" />\n                Excel\n              </Button>\n              <Button\n                variant={selectedFormat === \"csv\" ? \"default\" : \"outline\"}\n                onClick={() => setSelectedFormat(\"csv\")}\n                className=\"justify-start\"\n                size=\"sm\"\n              >\n                <FileText className=\"mr-2 h-4 w-4\" />\n                CSV\n              </Button>\n              <Button\n                variant={selectedFormat === \"json\" ? \"default\" : \"outline\"}\n                onClick={() => setSelectedFormat(\"json\")}\n                className=\"justify-start\"\n                size=\"sm\"\n              >\n                <FileJson className=\"mr-2 h-4 w-4\" />\n                JSON\n              </Button>\n            </div>\n          </div>\n        </div>\n        <DialogFooter>\n          <Button\n            onClick={handleGenerateIndex}\n            disabled={isLoading || disabled}\n          >\n            {isLoading ? \"Generating...\" : \"Generate\"}\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/introduction-modal.tsx",
    "content": "\"use client\";\n\nimport React, {\n  createContext,\n  useContext,\n  useEffect,\n  useState,\n  useCallback,\n} from \"react\";\n\nimport { InfoIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\ninterface IntroductionModalProps {\n  dataroom: {\n    id?: string;\n    name?: string;\n    introductionEnabled?: boolean;\n    introductionContent?: any;\n  };\n  viewerId?: string;\n}\n\n// Context to share modal state\ninterface IntroductionContextType {\n  openIntroduction: () => void;\n  hasIntroduction: boolean;\n  hasSeen: boolean;\n}\n\nconst IntroductionContext = createContext<IntroductionContextType>({\n  openIntroduction: () => {},\n  hasIntroduction: false,\n  hasSeen: false,\n});\n\nexport const useIntroduction = () => useContext(IntroductionContext);\n\n// Info button component to be placed inline (e.g., next to dataroom name)\nexport function IntroductionInfoButton() {\n  const { openIntroduction, hasIntroduction, hasSeen } = useIntroduction();\n\n  if (!hasIntroduction) return null;\n\n  return (\n    <TooltipProvider delayDuration={0}>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <button\n            onClick={openIntroduction}\n            className={`inline-flex items-center justify-center rounded-full p-1.5 transition-colors ${\n              hasSeen\n                ? \"text-muted-foreground hover:bg-muted hover:text-foreground\"\n                : \"border-2 border-gray-900 text-gray-900 hover:bg-gray-100 dark:border-white dark:text-white dark:hover:bg-gray-800\"\n            }`}\n            aria-label=\"View introduction\"\n          >\n            <InfoIcon className=\"h-5 w-5\" />\n          </button>\n        </TooltipTrigger>\n        <TooltipContent side=\"left\">\n          <p>View introduction</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n\n// Helper to render inline text nodes with marks (bold, italic, etc.)\nfunction renderInlineContent(nodes: any[] | undefined): React.ReactNode {\n  if (!nodes) return null;\n\n  return nodes.map((textNode: any, textIndex: number) => {\n    if (textNode.type === \"text\") {\n      let text: React.ReactNode = textNode.text;\n      if (textNode.marks) {\n        textNode.marks.forEach((mark: any) => {\n          if (mark.type === \"bold\") {\n            text = (\n              <strong key={`bold-${textIndex}`} className=\"font-semibold\">\n                {text}\n              </strong>\n            );\n          } else if (mark.type === \"italic\") {\n            text = (\n              <em key={`italic-${textIndex}`} className=\"italic\">\n                {text}\n              </em>\n            );\n          }\n        });\n      }\n      return <React.Fragment key={textIndex}>{text}</React.Fragment>;\n    } else if (textNode.type === \"image\") {\n      return (\n        <img\n          key={textIndex}\n          src={textNode.attrs?.src}\n          alt={textNode.attrs?.alt || \"\"}\n          className=\"my-2 h-auto max-w-full rounded-md\"\n        />\n      );\n    }\n    return null;\n  });\n}\n\n// Render TipTap JSON content\nfunction renderContent(content: any): React.ReactNode {\n  if (!content || !content.content) return null;\n\n  return content.content.map((node: any, index: number) => {\n    if (node.type === \"heading\") {\n      const level = node.attrs?.level || 1;\n      const text = node.content?.[0]?.text || \"\";\n      if (level === 1) {\n        return (\n          <h1\n            key={index}\n            className=\"mb-3 mt-4 text-xl font-bold text-gray-900 first:mt-0 dark:text-gray-100\"\n          >\n            {text}\n          </h1>\n        );\n      }\n      return (\n        <h2\n          key={index}\n          className=\"mb-2 mt-4 text-base font-semibold text-gray-800 first:mt-0 dark:text-gray-200\"\n        >\n          {text}\n        </h2>\n      );\n    } else if (node.type === \"paragraph\") {\n      return (\n        <p key={index} className=\"mb-3 text-sm leading-relaxed text-gray-700\">\n          {renderInlineContent(node.content)}\n        </p>\n      );\n    } else if (node.type === \"bulletList\") {\n      return (\n        <ul key={index} className=\"mb-3 list-disc pl-5 text-sm text-gray-700\">\n          {node.content?.map((item: any, itemIndex: number) => (\n            <li key={itemIndex} className=\"mb-1\">\n              {renderInlineContent(item.content?.[0]?.content)}\n            </li>\n          ))}\n        </ul>\n      );\n    } else if (node.type === \"orderedList\") {\n      return (\n        <ol\n          key={index}\n          className=\"mb-3 list-decimal pl-5 text-sm text-gray-700\"\n        >\n          {node.content?.map((item: any, itemIndex: number) => (\n            <li key={itemIndex} className=\"mb-1\">\n              {renderInlineContent(item.content?.[0]?.content)}\n            </li>\n          ))}\n        </ol>\n      );\n    } else if (node.type === \"blockquote\") {\n      return (\n        <blockquote\n          key={index}\n          className=\"mb-3 border-l-4 border-gray-300 pl-4 italic text-gray-600\"\n        >\n          {node.content?.map((p: any, pIndex: number) =>\n            p.content?.map((textNode: any, textIndex: number) =>\n              textNode.type === \"text\" ? textNode.text : null,\n            ),\n          )}\n        </blockquote>\n      );\n    } else if (node.type === \"image\") {\n      return (\n        <img\n          key={index}\n          src={node.attrs?.src}\n          alt={node.attrs?.alt || \"\"}\n          className=\"my-3 h-auto max-w-full rounded-md\"\n        />\n      );\n    } else if (node.type === \"youtube\") {\n      // Extract video ID from the src URL\n      const src = node.attrs?.src || \"\";\n      let videoId = \"\";\n\n      // Handle different YouTube URL formats\n      const youtubeMatch = src.match(\n        /(?:youtube(?:-nocookie)?\\.com\\/(?:embed\\/|watch\\?v=)|youtu\\.be\\/)([^?&]+)/,\n      );\n      if (youtubeMatch) {\n        videoId = youtubeMatch[1];\n      }\n\n      if (!videoId) return null;\n\n      return (\n        <div key={index} className=\"my-4 aspect-video w-full\">\n          <iframe\n            src={`https://www.youtube-nocookie.com/embed/${videoId}`}\n            title=\"YouTube video\"\n            className=\"h-full w-full rounded-lg\"\n            allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"\n            allowFullScreen\n          />\n        </div>\n      );\n    }\n    return null;\n  });\n}\n\ninterface IntroductionProviderProps extends IntroductionModalProps {\n  children: React.ReactNode;\n}\n\nexport function IntroductionProvider({\n  dataroom,\n  viewerId,\n  children,\n}: IntroductionProviderProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [hasSeen, setHasSeen] = useState(true); // Default to true, will be updated on mount\n\n  const storageKey = `dataroom-intro-seen-${dataroom.id}${viewerId ? `-${viewerId}` : \"\"}`;\n\n  const content = dataroom.introductionContent as any;\n  const hasContent =\n    dataroom.introductionEnabled &&\n    content?.content &&\n    content.content.length > 0;\n\n  useEffect(() => {\n    // Check if introduction is enabled and has content\n    if (!hasContent) {\n      return;\n    }\n\n    // Check if user has already seen the introduction\n    const seen = localStorage.getItem(storageKey);\n    setHasSeen(!!seen);\n\n    // Show modal only first time\n    if (!seen) {\n      setIsOpen(true);\n    }\n  }, [hasContent, storageKey]);\n\n  const handleClose = () => {\n    setIsOpen(false);\n    localStorage.setItem(storageKey, \"true\");\n    setHasSeen(true);\n  };\n\n  const openIntroduction = useCallback(() => {\n    setIsOpen(true);\n  }, []);\n\n  return (\n    <IntroductionContext.Provider\n      value={{ openIntroduction, hasIntroduction: hasContent, hasSeen }}\n    >\n      {children}\n\n      {/* Introduction Modal */}\n      {hasContent && (\n        <Dialog\n          open={isOpen}\n          onOpenChange={(open) => {\n            if (!open) {\n              handleClose();\n            } else {\n              setIsOpen(true);\n            }\n          }}\n        >\n          <DialogContent className=\"max-h-[85vh] max-w-2xl overflow-hidden border-0 p-0 shadow-2xl sm:max-w-xl sm:rounded-2xl md:max-w-2xl\">\n            <DialogHeader className=\"border-b border-gray-100 bg-gray-50 px-6 py-6 dark:border-gray-800 dark:bg-gray-900\">\n              <DialogTitle className=\"text-xl font-semibold\">\n                Welcome to {dataroom.name || \"Data Room\"}\n              </DialogTitle>\n            </DialogHeader>\n            <ScrollArea className=\"max-h-[60vh] px-6 py-5\">\n              <div className=\"prose prose-sm max-w-none dark:prose-invert\">\n                {renderContent(content)}\n              </div>\n            </ScrollArea>\n            <div className=\"flex justify-end border-t border-gray-100 bg-gray-50 px-6 py-4 dark:border-gray-800 dark:bg-gray-900\">\n              <Button onClick={handleClose} size=\"lg\">\n                Continue to Data Room\n              </Button>\n            </div>\n          </DialogContent>\n        </Dialog>\n      )}\n    </IntroductionContext.Provider>\n  );\n}\n\n// Backwards compatible export\nexport function IntroductionModal({\n  dataroom,\n  viewerId,\n}: IntroductionModalProps) {\n  return (\n    <IntroductionProvider dataroom={dataroom} viewerId={viewerId}>\n      {null}\n    </IntroductionProvider>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/nav-dataroom.tsx",
    "content": "import Link from \"next/link\";\n\nimport React, { useEffect, useMemo, useState } from \"react\";\n\nimport { DataroomBrand } from \"@prisma/client\";\nimport { BadgeInfoIcon, Download } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { formatDate } from \"@/lib/utils\";\n\nimport {\n  ButtonTooltip,\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\n\nimport { Button } from \"../../ui/button\";\nimport { ConversationSidebar } from \"../conversations/sidebar\";\nimport { ViewerDownloadProgressModal } from \"./viewer-download-progress-modal\";\n\nconst DEFAULT_BANNER_IMAGE = \"/_static/papermark-banner.png\";\n\nexport default function DataroomNav({\n  allowDownload,\n  allowBulkDownload,\n  brand,\n  viewId,\n  linkId,\n  dataroom,\n  isPreview,\n  dataroomId,\n  viewerId,\n  viewerEmail,\n  conversationsEnabled,\n  isTeamMember,\n}: {\n  allowDownload?: boolean;\n  allowBulkDownload?: boolean;\n  brand?: Partial<DataroomBrand>;\n  viewId?: string;\n  linkId?: string;\n  dataroom?: any;\n  isPreview?: boolean;\n  dataroomId?: string;\n  viewerId?: string;\n  viewerEmail?: string | null;\n  conversationsEnabled?: boolean;\n  isTeamMember?: boolean;\n}) {\n  const [showConversations, setShowConversations] = useState<boolean>(false);\n  const [showDownloadModal, setShowDownloadModal] = useState<boolean>(false);\n  const [downloadModalJobId, setDownloadModalJobId] = useState<string | null>(null);\n  const [downloadFolderId, setDownloadFolderId] = useState<string | null>(null);\n  const [downloadFolderName, setDownloadFolderName] = useState<string | null>(null);\n\n  // Derive downloads page URL from current path so it works for both\n  // /view/<linkId>/downloads and /<slug>/downloads (custom domains)\n  const downloadsPageUrl = useMemo(() => {\n    if (typeof window === \"undefined\") return \"/downloads\";\n    const path = window.location.pathname.replace(/\\/+$/, \"\");\n    return `${path}/downloads`;\n  }, []);\n\n  useEffect(() => {\n    const handler = (e: CustomEvent<{ jobId?: string; folderId?: string; folderName?: string }>) => {\n      setDownloadModalJobId(e.detail?.jobId ?? null);\n      setDownloadFolderId(e.detail?.folderId ?? null);\n      setDownloadFolderName(e.detail?.folderName ?? null);\n      setShowDownloadModal(true);\n    };\n    window.addEventListener(\n      \"viewer-download-modal-open\" as any,\n      handler as EventListener,\n    );\n    return () =>\n      window.removeEventListener(\n        \"viewer-download-modal-open\" as any,\n        handler as EventListener,\n      );\n  }, []);\n\n  const openDownloadModal = () => {\n    if (isPreview) {\n      toast.error(\"You cannot download datarooms in preview mode.\");\n      return;\n    }\n    if (!allowDownload || !allowBulkDownload) return;\n    if (!viewerEmail) {\n      toast.error(\"Enter your email in the dataroom to download.\");\n      return;\n    }\n    setDownloadModalJobId(null);\n    setDownloadFolderId(null);\n    setDownloadFolderName(null);\n    setShowDownloadModal(true);\n  };\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Toggle conversations with 'c' key\n      if (\n        e.key === \"c\" &&\n        !e.metaKey &&\n        !e.ctrlKey &&\n        !e.altKey &&\n        conversationsEnabled &&\n        !showConversations // if conversations are already open, don't toggle them\n      ) {\n        e.preventDefault();\n        setShowConversations((prev) => !prev);\n      }\n\n      if (e.key === \"Escape\" && showConversations) {\n        e.preventDefault();\n        setShowConversations(false);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [conversationsEnabled, showConversations]);\n\n  return (\n    <nav\n      className=\"bg-black\"\n      style={{\n        backgroundColor: brand && brand.brandColor ? brand.brandColor : \"black\",\n      }}\n    >\n      <div className=\"mx-auto px-2 sm:px-6 lg:px-8\">\n        <div className=\"relative flex h-16 items-center justify-between\">\n          <div className=\"flex flex-1 items-center justify-start\">\n            <div className=\"relative flex h-16 w-36 flex-shrink-0 items-center\">\n              {brand && brand.logo ? (\n                <img\n                  className=\"h-16 w-36 object-contain\"\n                  src={brand.logo}\n                  alt=\"Logo\"\n                />\n              ) : (\n                <Link\n                  href={`https://www.papermark.com?utm_campaign=navbar&utm_medium=navbar&utm_source=papermark-${linkId}`}\n                  target=\"_blank\"\n                  className=\"text-2xl font-bold tracking-tighter text-white\"\n                >\n                  Papermark\n                </Link>\n              )}\n            </div>\n          </div>\n          <div className=\"absolute inset-y-0 right-0 flex items-center space-x-4 pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0\">\n            {isTeamMember ? (\n              <TooltipProvider delayDuration={100}>\n                <Tooltip defaultOpen>\n                  <TooltipTrigger asChild>\n                    <Button\n                      className=\"size-8 bg-gray-900 text-white hover:bg-gray-900/80 sm:size-10\"\n                      size=\"icon\"\n                    >\n                      <BadgeInfoIcon className=\"h-5 w-5\" />\n                    </Button>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <p className=\"max-w-xs text-wrap text-center\">\n                      Skipped verification because you are a team member; no\n                      analytics will be collected\n                    </p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            ) : null}\n            {conversationsEnabled && (\n              <Button onClick={() => setShowConversations(!showConversations)}>\n                View FAQ\n              </Button>\n            )}\n            {allowDownload && allowBulkDownload && viewerEmail ? (\n              <ButtonTooltip content=\"Download Dataroom\">\n                <Button\n                  onClick={openDownloadModal}\n                  className=\"m-1 bg-gray-900 text-white hover:bg-gray-900/80\"\n                  size=\"icon\"\n                >\n                  <Download className=\"h-5 w-5\" />\n                </Button>\n              </ButtonTooltip>\n            ) : null}\n          </div>\n        </div>\n      </div>\n\n      {/* Banner section */}\n      {brand?.banner !== \"no-banner\" && (\n        <div className=\"relative h-[20vh] sm:h-auto sm:max-h-80\">\n          <img\n            className=\"h-full w-full object-cover sm:max-h-80 sm:object-contain xl:object-cover\"\n            src={brand?.banner || DEFAULT_BANNER_IMAGE}\n            alt=\"Banner\"\n            width={1920}\n            height={320}\n          />\n          <div className=\"absolute bottom-5 w-fit rounded-r-md bg-white/30 backdrop-blur-md\">\n            <div className=\"px-5 py-2 sm:px-10\">\n              <div className=\"text-3xl\">{dataroom.name}</div>\n              {dataroom.showLastUpdated ? (\n                <time\n                  className=\"mt-1 block text-sm\"\n                  dateTime={new Date(dataroom.lastUpdatedAt).toISOString()}\n                >\n                  {`Last updated ${formatDate(dataroom.lastUpdatedAt)}`}\n                </time>\n              ) : null}\n            </div>\n          </div>\n        </div>\n      )}\n\n      {linkId && viewId && viewerEmail && (\n        <ViewerDownloadProgressModal\n          isOpen={showDownloadModal}\n          onClose={() => {\n            setShowDownloadModal(false);\n            setDownloadModalJobId(null);\n            setDownloadFolderId(null);\n            setDownloadFolderName(null);\n          }}\n          linkId={linkId}\n          viewId={viewId}\n          viewerEmail={viewerEmail}\n          dataroomName={dataroom?.name ?? \"\"}\n          dataroomId={dataroomId}\n          downloadsPageUrl={downloadsPageUrl}\n          initialJobId={downloadModalJobId ?? undefined}\n          folderId={downloadFolderId}\n          folderName={downloadFolderName}\n        />\n      )}\n      {conversationsEnabled && showConversations ? (\n        <ConversationSidebar\n          dataroomId={dataroomId}\n          viewId={viewId || \"\"}\n          viewerId={viewerId}\n          linkId={linkId!}\n          isEnabled={true}\n          isOpen={showConversations}\n          onOpenChange={setShowConversations}\n        />\n      ) : null}\n    </nav>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/pending-document-card.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect } from \"react\";\nimport type { CSSProperties } from \"react\";\n\nimport {\n  AlertCircle,\n  CheckCircle2,\n  FileIcon,\n  FolderIcon,\n  Loader2,\n  Upload,\n} from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\n\nimport {\n  PendingUploadDocument,\n  usePendingUploads,\n} from \"@/context/pending-uploads-context\";\nimport { cn, fetcher } from \"@/lib/utils\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\nimport { useDocumentProgressStatus } from \"@/lib/utils/use-progress-status\";\n\nimport { Progress } from \"@/components/ui/progress\";\nimport { useViewerSurfaceTheme } from \"@/components/view/viewer/viewer-surface-theme\";\n\ntype FolderInfo = {\n  id: string;\n  name: string;\n  parentId: string | null;\n};\n\ntype PendingDocumentCardProps = {\n  pendingUpload: PendingUploadDocument;\n  /** Flat list of all folders for resolving folder paths */\n  folders?: FolderInfo[];\n  /** Link ID for building document URLs */\n  linkId?: string;\n  /** Callback to navigate to a folder */\n  onNavigateToFolder?: (folderId: string | null) => void;\n  /** Whether to show the folder path (used in My Uploads tab) */\n  showFolderPath?: boolean;\n};\n\n/** Build a breadcrumb path like \"Home > Company Info > Financials > Q4\" */\nfunction getFolderPath(\n  folderId: string | null,\n  folders: FolderInfo[],\n): string {\n  if (!folderId) return \"Home\";\n\n  const parts: string[] = [\"Home\"];\n  const folderParts: string[] = [];\n  let current = folders.find((f) => f.id === folderId);\n  while (current) {\n    folderParts.unshift(current.name);\n    current = current.parentId\n      ? folders.find((f) => f.id === current!.parentId)\n      : undefined;\n  }\n  return [...parts, ...folderParts].join(\" > \");\n}\n\nexport default function PendingDocumentCard({\n  pendingUpload,\n  folders = [],\n  linkId,\n  onNavigateToFolder,\n  showFolderPath = false,\n}: PendingDocumentCardProps) {\n  const { theme, systemTheme } = useTheme();\n  const router = useRouter();\n  const { updatePendingUpload } = usePendingUploads();\n  const { palette } = useViewerSurfaceTheme();\n  const isLight =\n    theme === \"light\" || (theme === \"system\" && systemTheme === \"light\");\n\n  const { previewToken, domain, slug } = router.query as {\n    previewToken?: string;\n    domain?: string;\n    slug?: string;\n  };\n\n  // Fetch trigger public access token for processing documents\n  const needsTriggerTracking =\n    pendingUpload.status === \"processing\" && pendingUpload.documentVersionId;\n\n  const { data: tokenData } = useSWRImmutable<{ publicAccessToken: string }>(\n    needsTriggerTracking\n      ? `/api/progress-token?documentVersionId=${pendingUpload.documentVersionId}`\n      : null,\n    fetcher,\n  );\n\n  // Subscribe to Trigger.dev realtime for processing status\n  const { status: triggerStatus } = useDocumentProgressStatus(\n    pendingUpload.documentVersionId ?? \"\",\n    tokenData?.publicAccessToken,\n  );\n\n  // When trigger reports COMPLETED, update the upload status\n  useEffect(() => {\n    if (\n      needsTriggerTracking &&\n      triggerStatus.state === \"COMPLETED\" &&\n      pendingUpload.status === \"processing\"\n    ) {\n      updatePendingUpload(pendingUpload.id, { status: \"complete\" });\n    }\n  }, [\n    triggerStatus.state,\n    needsTriggerTracking,\n    pendingUpload.status,\n    pendingUpload.id,\n    updatePendingUpload,\n  ]);\n\n  const isError = pendingUpload.status === \"error\";\n  const isUploading = pendingUpload.status === \"uploading\";\n  const isProcessing = pendingUpload.status === \"processing\";\n  const isComplete = pendingUpload.status === \"complete\";\n  const isClickable = isComplete && pendingUpload.dataroomDocumentId && linkId;\n\n  const getStatusText = () => {\n    switch (pendingUpload.status) {\n      case \"uploading\":\n        return `Uploading... ${pendingUpload.progress}%`;\n      case \"processing\":\n        // Use trigger realtime text if available\n        if (triggerStatus.state === \"EXECUTING\" && triggerStatus.text) {\n          return triggerStatus.text;\n        }\n        if (triggerStatus.state === \"QUEUED\") {\n          return \"Queued for processing...\";\n        }\n        return \"Processing document...\";\n      case \"error\":\n        return pendingUpload.errorMessage || \"Upload failed\";\n      case \"complete\":\n        return \"Ready\";\n      default:\n        return \"Pending\";\n    }\n  };\n\n  const getStatusIcon = () => {\n    switch (pendingUpload.status) {\n      case \"uploading\":\n        return <Upload className=\"h-4 w-4 animate-pulse text-blue-500\" />;\n      case \"processing\":\n        return <Loader2 className=\"h-4 w-4 animate-spin text-orange-500\" />;\n      case \"error\":\n        return <AlertCircle className=\"h-4 w-4 text-red-500\" />;\n      case \"complete\":\n        return <CheckCircle2 className=\"h-4 w-4 text-green-500\" />;\n      default:\n        return null;\n    }\n  };\n\n  // Real processing progress from trigger (0-100)\n  const processingProgress =\n    isProcessing && triggerStatus.state === \"EXECUTING\"\n      ? triggerStatus.progress\n      : undefined;\n\n  const handleDocumentClick = (e: React.MouseEvent) => {\n    if (!isClickable) {\n      if (isProcessing) {\n        e.preventDefault();\n        toast.error(\n          \"Document is still processing. Please wait a moment and try again.\",\n        );\n      }\n      return;\n    }\n\n    e.preventDefault();\n    if (domain && slug) {\n      window.open(\n        `/${slug}/d/${pendingUpload.dataroomDocumentId}`,\n        \"_blank\",\n      );\n    } else if (linkId) {\n      window.open(\n        `/view/${linkId}/d/${pendingUpload.dataroomDocumentId}${\n          previewToken ? `?previewToken=${previewToken}&preview=1` : \"\"\n        }`,\n        \"_blank\",\n      );\n    }\n  };\n\n  const handleFolderClick = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    if (onNavigateToFolder) {\n      onNavigateToFolder(pendingUpload.folderId);\n    }\n  };\n\n  const folderPath =\n    showFolderPath && folders.length > 0\n      ? getFolderPath(pendingUpload.folderId, folders)\n      : null;\n\n  return (\n    <div\n      className={cn(\n        \"group/row relative flex items-center justify-between rounded-lg border p-3 transition-all sm:p-4\",\n        \"bg-[var(--viewer-panel-bg)] hover:bg-[var(--viewer-panel-bg-hover)]\",\n        \"border-[var(--viewer-panel-border)] hover:border-[var(--viewer-panel-border-hover)]\",\n        isError && \"border-red-400/40\",\n        (isUploading || isProcessing) && \"opacity-80\",\n      )}\n      style={\n        {\n          \"--viewer-panel-bg\": palette.panelBgColor,\n          \"--viewer-panel-bg-hover\": palette.panelHoverBgColor,\n          \"--viewer-panel-border\": palette.panelBorderColor,\n          \"--viewer-panel-border-hover\": palette.panelBorderHoverColor,\n          \"--viewer-text\": palette.textColor,\n          \"--viewer-muted-text\": palette.mutedTextColor,\n          \"--viewer-subtle-text\": palette.subtleTextColor,\n          \"--viewer-control-bg\": palette.controlBgColor,\n          \"--viewer-control-border\": palette.controlBorderColor,\n        } as CSSProperties\n      }\n    >\n      {/* Clickable overlay for opening documents */}\n      {isClickable && (\n        <button\n          onClick={handleDocumentClick}\n          className=\"absolute inset-0 z-0 cursor-pointer\"\n          aria-hidden=\"true\"\n        />\n      )}\n\n      {/* pointer-events-none so clicks fall through to the button overlay above */}\n      <div\n        className={cn(\n          \"flex min-w-0 shrink items-center space-x-2 sm:space-x-4\",\n          isClickable && \"pointer-events-none\",\n        )}\n      >\n        <div className=\"mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1\">\n          {pendingUpload.fileType ? (\n            fileIcon({\n              fileType: pendingUpload.fileType,\n              className: \"h-8 w-8 opacity-60\",\n              isLight,\n            })\n          ) : (\n            <FileIcon className=\"h-8 w-8 opacity-60\" style={{ color: palette.mutedTextColor }} />\n          )}\n        </div>\n\n        <div className=\"min-w-0 flex-1 flex-col\">\n          <div className=\"flex items-center gap-2\">\n            <h2 className=\"min-w-0 max-w-[300px] truncate text-sm font-semibold leading-6 text-[var(--viewer-text)] sm:max-w-lg\">\n              {pendingUpload.name}\n            </h2>\n            {getStatusIcon()}\n          </div>\n          <div className=\"mt-1 flex items-center gap-2\">\n            <p\n              className={cn(\n                \"text-xs leading-5\",\n                isError\n                  ? \"text-red-500\"\n                  : isComplete\n                    ? \"text-green-500\"\n                    : \"text-[var(--viewer-muted-text)]\",\n              )}\n            >\n              {getStatusText()}\n            </p>\n            {folderPath && isComplete && (\n              <>\n                <span className=\"text-xs text-[var(--viewer-subtle-text)]\">\n                  ·\n                </span>\n                <button\n                  onClick={handleFolderClick}\n                  className=\"pointer-events-auto z-10 flex items-center gap-1 text-xs text-[var(--viewer-muted-text)] transition-colors hover:text-[var(--viewer-text)]\"\n                >\n                  <FolderIcon className=\"h-3 w-3\" />\n                  <span className=\"max-w-[200px] truncate sm:max-w-[300px]\">\n                    {folderPath}\n                  </span>\n                </button>\n              </>\n            )}\n            {folderPath && !isComplete && (\n              <>\n                <span className=\"text-xs text-[var(--viewer-subtle-text)]\">\n                  ·\n                </span>\n                <span className=\"flex items-center gap-1 text-xs text-[var(--viewer-muted-text)]\">\n                  <FolderIcon className=\"h-3 w-3\" />\n                  <span className=\"max-w-[200px] truncate sm:max-w-[300px]\">\n                    {folderPath}\n                  </span>\n                </span>\n              </>\n            )}\n          </div>\n          {(isUploading || isProcessing) && (\n            <Progress\n              value={\n                isProcessing\n                  ? (processingProgress ?? 0)\n                  : pendingUpload.progress\n              }\n              className={cn(\n                \"mt-1.5 h-1 w-full max-w-[200px]\",\n                isProcessing && !processingProgress && \"animate-pulse\",\n              )}\n            />\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/dataroom/viewer-download-progress-modal.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport {\n  AlertCircle,\n  CheckCircle2,\n  Download,\n  FileArchive,\n  Loader2,\n  XCircle,\n} from \"lucide-react\";\n\nimport { DownloadOtpVerification } from \"./download-otp-verification\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Progress } from \"@/components/ui/progress\";\n\nexport type ViewerDownloadProgressModalProps = {\n  isOpen: boolean;\n  onClose: () => void;\n  linkId: string;\n  viewId: string;\n  viewerEmail: string;\n  dataroomName: string;\n  dataroomId?: string;\n  downloadsPageUrl: string;\n  initialJobId?: string | null;\n  /** When set, the modal downloads a single folder instead of the entire dataroom */\n  folderId?: string | null;\n  folderName?: string | null;\n};\n\ntype Step = \"choose\" | \"otp\" | \"progress\" | \"complete\";\n\ninterface JobStatus {\n  id: string;\n  status: string;\n  progress: number;\n  totalFiles: number;\n  processedFiles: number;\n  downloadUrls?: string[];\n  error?: string;\n  isReady: boolean;\n  dataroomName: string;\n  createdAt: string;\n  completedAt?: string;\n  expiresAt?: string;\n}\n\nexport function ViewerDownloadProgressModal({\n  isOpen,\n  onClose,\n  linkId,\n  viewId,\n  viewerEmail,\n  dataroomName,\n  dataroomId,\n  downloadsPageUrl,\n  initialJobId,\n  folderId,\n  folderName,\n}: ViewerDownloadProgressModalProps) {\n  const isFolderDownload = !!folderId;\n  const [step, setStep] = useState<Step>(\"choose\");\n  const [wantEmail, setWantEmail] = useState(false);\n  const [verified, setVerified] = useState<boolean | null>(null);\n  const [jobId, setJobId] = useState<string | null>(initialJobId ?? null);\n  const [otpKey, setOtpKey] = useState(0);\n  const [status, setStatus] = useState<JobStatus | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [isStarting, setIsStarting] = useState(false);\n  const [isPolling, setIsPolling] = useState(false);\n  const [downloadProgress, setDownloadProgress] = useState<{\n    current: number;\n    total: number;\n  } | null>(null);\n  const pollRef = useRef<NodeJS.Timeout | null>(null);\n\n  const fetchVerified = useCallback(async () => {\n    try {\n      const res = await fetch(\n        `/api/links/download/verify?linkId=${encodeURIComponent(linkId)}`,\n        { credentials: \"include\" },\n      );\n      if (res.ok) {\n        const data = await res.json();\n        setVerified(!!data.verified);\n        return !!data.verified;\n      }\n      setVerified(false);\n      return false;\n    } catch {\n      setVerified(false);\n      return false;\n    }\n  }, [linkId]);\n\n  useEffect(() => {\n    if (!isOpen) return;\n    setStep(initialJobId ? \"progress\" : \"choose\");\n    setJobId(initialJobId ?? null);\n    setStatus(null);\n    setError(null);\n    if (initialJobId) {\n      setIsPolling(true);\n    } else {\n      fetchVerified();\n    }\n  }, [isOpen, initialJobId, fetchVerified]);\n\n  const fetchStatus = useCallback(\n    async (id: string) => {\n      const res = await fetch(\n        `/api/links/download/${id}?linkId=${encodeURIComponent(linkId)}`,\n        { credentials: \"include\" },\n      );\n      if (!res.ok) {\n        const data = await res.json().catch(() => ({}));\n        throw new Error(data.error ?? \"Failed to fetch status\");\n      }\n      return res.json();\n    },\n    [linkId],\n  );\n\n  useEffect(() => {\n    if (!isOpen || !jobId || !isPolling) return;\n    const poll = async () => {\n      try {\n        const data = await fetchStatus(jobId);\n        setStatus(data);\n        setError(null);\n        if (data.status === \"COMPLETED\" || data.status === \"FAILED\") {\n          setIsPolling(false);\n          if (pollRef.current) {\n            clearInterval(pollRef.current);\n            pollRef.current = null;\n          }\n        }\n      } catch (err) {\n        setError(err instanceof Error ? err.message : \"Error\");\n      }\n    };\n    poll();\n    pollRef.current = setInterval(poll, 2000);\n    return () => {\n      if (pollRef.current) {\n        clearInterval(pollRef.current);\n        pollRef.current = null;\n      }\n    };\n  }, [isOpen, jobId, isPolling, fetchStatus]);\n\n  const startDownload = async (withEmail: boolean) => {\n    setIsStarting(true);\n    setError(null);\n    try {\n      const endpoint = isFolderDownload\n        ? \"/api/links/download/dataroom-folder\"\n        : \"/api/links/download/bulk\";\n\n      const body = isFolderDownload\n        ? { folderId, dataroomId, viewId, linkId, emailNotification: withEmail }\n        : { linkId, viewId, emailNotification: withEmail };\n\n      const res = await fetch(endpoint, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        credentials: \"include\",\n        body: JSON.stringify(body),\n      });\n      const data = await res.json().catch(() => ({}));\n      if (!res.ok) {\n        setError(data.error ?? \"Failed to start download\");\n        return;\n      }\n      if (data.jobId) {\n        setJobId(data.jobId);\n        setStep(\"progress\");\n        setIsPolling(true);\n      }\n    } finally {\n      setIsStarting(false);\n    }\n  };\n\n  const handleStartClick = () => {\n    if (wantEmail && verified === false) {\n      setStep(\"otp\");\n      setOtpKey((k) => k + 1);\n      return;\n    }\n    startDownload(!!wantEmail);\n  };\n\n  const handleOtpVerified = () => {\n    setVerified(true);\n    setStep(\"choose\");\n    // Defer bulk request so the browser has committed any Set-Cookie from the verify response\n    setTimeout(() => {\n      startDownload(true);\n    }, 0);\n  };\n\n  const handleDownload = (url: string) => {\n    // Ensure we use a relative path so the request goes to the current origin\n    // (where the session cookie lives), not a potentially different subdomain.\n    let href = url;\n    try {\n      const parsed = new URL(url, window.location.origin);\n      href = parsed.pathname + parsed.search;\n    } catch {\n      // url is already relative, use as-is\n    }\n    const a = document.createElement(\"a\");\n    a.href = href;\n    a.rel = \"noopener noreferrer\";\n    document.body.appendChild(a);\n    a.click();\n    setTimeout(() => document.body.removeChild(a), 100);\n  };\n\n  const handleDownloadAll = async (urls: string[]) => {\n    if (downloadProgress) return;\n    setDownloadProgress({ current: 0, total: urls.length });\n    for (let i = 0; i < urls.length; i++) {\n      setDownloadProgress({ current: i + 1, total: urls.length });\n      handleDownload(urls[i]);\n      if (i < urls.length - 1) {\n        await new Promise((resolve) => setTimeout(resolve, 2000));\n      }\n    }\n    setDownloadProgress(null);\n  };\n\n  const handleClose = () => {\n    if (pollRef.current) {\n      clearInterval(pollRef.current);\n      pollRef.current = null;\n    }\n    setStep(\"choose\");\n    setJobId(null);\n    setStatus(null);\n    setError(null);\n    setIsPolling(false);\n    onClose();\n  };\n\n  const formatExpirationTime = (expiresAt?: string) => {\n    if (!expiresAt) return null;\n    const expires = new Date(expiresAt);\n    const now = new Date();\n    const diffMs = expires.getTime() - now.getTime();\n    const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n    const diffDays = Math.floor(diffHours / 24);\n    if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? \"s\" : \"\"}`;\n    if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? \"s\" : \"\"}`;\n    return \"less than an hour\";\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle>\n            {isFolderDownload\n              ? `Download folder: ${folderName}`\n              : `Download ${dataroomName || \"Dataroom\"}`}\n          </DialogTitle>\n          <DialogDescription>\n            {step === \"choose\" &&\n              \"Start the download. You can optionally get an email when it's ready.\"}\n            {step === \"otp\" && \"Verify your email to receive download notifications.\"}\n            {step === \"progress\" &&\n              (status?.status === \"COMPLETED\"\n                ? \"Your files are ready to download.\"\n                : \"Preparing your files...\")}\n          </DialogDescription>\n        </DialogHeader>\n\n        {step === \"choose\" && (\n          <div className=\"space-y-4 py-4\">\n            <label className=\"flex items-center gap-2 text-sm\">\n              <input\n                type=\"checkbox\"\n                checked={wantEmail}\n                onChange={(e) => setWantEmail(e.target.checked)}\n              />\n              Notify me by email when the download is ready\n            </label>\n            {wantEmail && verified === false && (\n              <p className=\"text-xs text-muted-foreground\">\n                You’ll need to verify your email with a one-time code first.\n              </p>\n            )}\n            <div className=\"flex gap-2\">\n              <Button\n                onClick={handleStartClick}\n                disabled={isStarting}\n              >\n                {isStarting ? \"Starting...\" : \"Start download\"}\n              </Button>\n              <Button variant=\"outline\" onClick={handleClose}>\n                Cancel\n              </Button>\n            </div>\n            <p className=\"text-xs text-muted-foreground\">\n              <a\n                href={downloadsPageUrl}\n                className=\"underline\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n              >\n                View your downloads\n              </a>\n            </p>\n          </div>\n        )}\n\n        {step === \"otp\" && (\n          <DownloadOtpVerification\n            key={otpKey}\n            linkId={linkId}\n            viewId={viewId}\n            email={viewerEmail}\n            onVerified={handleOtpVerified}\n            sendOtpOnMount\n          />\n        )}\n\n        {step === \"progress\" && (\n          <div className=\"space-y-4 py-4\">\n            <div className=\"flex justify-center\">\n              {status?.status === \"COMPLETED\" ? (\n                <CheckCircle2 className=\"h-10 w-10 text-green-500\" />\n              ) : status?.status === \"FAILED\" ? (\n                <XCircle className=\"h-10 w-10 text-destructive\" />\n              ) : (\n                <FileArchive className=\"h-10 w-10 animate-pulse text-primary\" />\n              )}\n            </div>\n            <p className=\"text-center text-sm text-muted-foreground\">\n              {!status\n                ? \"Starting...\"\n                : status.status === \"PENDING\"\n                  ? \"Preparing...\"\n                  : status.status === \"PROCESSING\"\n                    ? `Processing ${status.processedFiles} of ${status.totalFiles} files...`\n                    : status.status === \"COMPLETED\"\n                      ? \"Your download is ready!\"\n                      : status.error ?? \"Download failed.\"}\n            </p>\n            {(status?.status === \"PROCESSING\" || status?.status === \"PENDING\") && (\n              <Progress value={status?.progress ?? 0} className=\"h-2\" />\n            )}\n            {status?.status === \"COMPLETED\" && status.downloadUrls && status.downloadUrls.length > 0 && (\n              <div className=\"space-y-2\">\n                {status.downloadUrls.length === 1 ? (\n                  <Button\n                    className=\"w-full\"\n                    onClick={() => handleDownload(status.downloadUrls![0])}\n                  >\n                    <Download className=\"mr-2 h-4 w-4\" />\n                    Download ZIP\n                  </Button>\n                ) : (\n                  <>\n                    <Button\n                      className=\"w-full\"\n                      disabled={!!downloadProgress}\n                      onClick={() => handleDownloadAll(status.downloadUrls!)}\n                    >\n                      {downloadProgress ? (\n                        <>\n                          <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                          Downloading {downloadProgress.current} of{\" \"}\n                          {downloadProgress.total}...\n                        </>\n                      ) : (\n                        <>\n                          <Download className=\"mr-2 h-4 w-4\" />\n                          Download all ({status.downloadUrls.length} parts)\n                        </>\n                      )}\n                    </Button>\n                    <div className=\"max-h-32 space-y-1 overflow-y-auto\">\n                      {status.downloadUrls.map((url, i) => (\n                        <Button\n                          key={i}\n                          variant=\"outline\"\n                          size=\"sm\"\n                          className=\"w-full justify-start\"\n                          onClick={() => handleDownload(url)}\n                        >\n                          Part {i + 1}\n                        </Button>\n                      ))}\n                    </div>\n                  </>\n                )}\n                {status.expiresAt && (\n                  <p className=\"text-center text-xs text-muted-foreground\">\n                    Expires in {formatExpirationTime(status.expiresAt)}\n                  </p>\n                )}\n              </div>\n            )}\n            {(status?.status === \"PENDING\" || status?.status === \"PROCESSING\") && (\n              <DialogFooter>\n                <p className=\"text-xs text-muted-foreground\">\n                  {wantEmail\n                    ? \"You can close this. We’ll email you when it’s ready.\"\n                    : \"You can close this. Check back on the downloads page when it’s ready.\"}\n                </p>\n              </DialogFooter>\n            )}\n            {status?.status === \"FAILED\" && (\n              <Button variant=\"outline\" onClick={() => setStep(\"choose\")}>\n                Try again\n              </Button>\n            )}\n            <p className=\"text-xs text-muted-foreground\">\n              <a href={downloadsPageUrl} className=\"underline\" target=\"_blank\" rel=\"noopener noreferrer\">\n                Open downloads page\n              </a>\n            </p>\n            {error && (\n              <p className=\"text-sm text-destructive\">{error}</p>\n            )}\n          </div>\n        )}\n\n        {error && step !== \"progress\" && (\n          <p className=\"text-sm text-destructive\">{error}</p>\n        )}\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/view/document-view.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\n\nimport { Brand } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { useDisablePrint } from \"@/lib/hooks/use-disable-print\";\nimport { LinkWithDocument, NotionTheme } from \"@/lib/types\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport AccessForm, {\n  DEFAULT_ACCESS_FORM_DATA,\n  DEFAULT_ACCESS_FORM_TYPE,\n} from \"@/components/view/access-form\";\n\nimport EmailVerificationMessage from \"./access-form/email-verification-form\";\nimport ViewData, { TViewDocumentData } from \"./view-data\";\n\ntype RowData = { [key: string]: any };\ntype SheetData = {\n  sheetName: string;\n  columnData: string[];\n  rowData: RowData[];\n};\n\nexport type DEFAULT_DOCUMENT_VIEW_TYPE = {\n  viewId?: string;\n  file?: string | null;\n  pages?:\n    | {\n        file: string | null;\n        pageNumber: string;\n        embeddedLinks: string[];\n        pageLinks: {\n          href: string;\n          coords: string;\n          isInternal?: boolean;\n          targetPage?: number;\n        }[];\n        metadata: { width: number; height: number; scaleFactor: number };\n      }[]\n    | null;\n  sheetData?: SheetData[] | null;\n  fileType?: string;\n  isPreview?: boolean;\n  ipAddress?: string;\n  verificationToken?: string;\n  isTeamMember?: boolean;\n  agentsEnabled?: boolean;\n  viewerId?: string;\n};\n\nexport default function DocumentView({\n  link,\n  userEmail,\n  userId,\n  isProtected,\n  notionData,\n  brand,\n  token,\n  verifiedEmail,\n  showPoweredByBanner,\n  showAccountCreationSlide,\n  useAdvancedExcelViewer,\n  previewToken,\n  disableEditEmail,\n  useCustomAccessForm,\n  logoOnAccessForm,\n  isEmbedded,\n  annotationsEnabled,\n  textSelectionEnabled,\n}: {\n  link: LinkWithDocument;\n  userEmail: string | null | undefined;\n  userId: string | null | undefined;\n  isProtected: boolean;\n  notionData?: {\n    rootNotionPageId: string | null;\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null;\n  };\n  brand?: Partial<Brand> | null;\n  token?: string;\n  verifiedEmail?: string;\n  showPoweredByBanner?: boolean;\n  showAccountCreationSlide?: boolean;\n  useAdvancedExcelViewer?: boolean;\n  previewToken?: string;\n  disableEditEmail?: boolean;\n  useCustomAccessForm?: boolean;\n  isEmbedded?: boolean;\n  logoOnAccessForm?: boolean;\n  annotationsEnabled?: boolean;\n  textSelectionEnabled?: boolean;\n}) {\n  useDisablePrint();\n  const {\n    document,\n    emailProtected,\n    password: linkPassword,\n    enableAgreement,\n  } = link;\n\n  const analytics = useAnalytics();\n  const router = useRouter();\n\n  const didMount = useRef<boolean>(false);\n  const [submitted, setSubmitted] = useState<boolean>(false);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [viewData, setViewData] = useState<DEFAULT_DOCUMENT_VIEW_TYPE>({\n    viewId: \"\",\n  });\n  const [data, setData] = useState<DEFAULT_ACCESS_FORM_TYPE>(\n    DEFAULT_ACCESS_FORM_DATA,\n  );\n  const [verificationRequested, setVerificationRequested] =\n    useState<boolean>(false);\n  const [verificationToken, setVerificationToken] = useState<string | null>(\n    token ?? null,\n  );\n  const [code, setCode] = useState<string | null>(null);\n  const [isInvalidCode, setIsInvalidCode] = useState<boolean>(false);\n\n  const handleSubmission = async (): Promise<void> => {\n    setIsLoading(true);\n    const response = await fetch(\"/api/views\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...data,\n        email: data.email ?? verifiedEmail ?? userEmail ?? null,\n        linkId: link.id,\n        documentId: document.id,\n        documentName: document.name,\n        ownerId: document.ownerId,\n        userId: userId ?? null,\n        documentVersionId: document.versions[0].id,\n        hasPages: document.versions[0].hasPages,\n        startPage: router.query.p ? Number(router.query.p) : undefined,\n        useAdvancedExcelViewer,\n        previewToken,\n        code: code ?? undefined,\n        token: verificationToken ?? undefined,\n        verifiedEmail: verifiedEmail ?? undefined,\n      }),\n    });\n\n    if (response.ok) {\n      const fetchData = await response.json();\n\n      if (fetchData.type === \"email-verification\") {\n        analytics.capture(\"Email Verification Requested\", {\n          linkId: link.id,\n          documentId: document.id,\n          documentName: document.name,\n          linkType: \"DOCUMENT_LINK\",\n          viewerEmail: data.email ?? verifiedEmail ?? userEmail,\n          teamId: link.teamId,\n        });\n        setVerificationRequested(true);\n        setIsLoading(false);\n      } else {\n        const {\n          viewId,\n          file,\n          pages,\n          sheetData,\n          fileType,\n          isPreview,\n          ipAddress,\n          verificationToken,\n          agentsEnabled,\n          isTeamMember,\n          viewerId,\n        } = fetchData as DEFAULT_DOCUMENT_VIEW_TYPE;\n\n        analytics.identify(\n          userEmail ?? verifiedEmail ?? data.email ?? undefined,\n        );\n        analytics.capture(\"Link Viewed\", {\n          linkId: link.id,\n          documentId: document.id,\n          linkType: \"DOCUMENT_LINK\",\n          viewerId: viewId,\n          viewerEmail: data.email ?? verifiedEmail ?? userEmail,\n          isEmbedded,\n          isTeamMember,\n          teamId: link.teamId,\n        });\n\n        // set the verification token to the cookie\n        if (verificationToken) {\n          Cookies.set(\"pm_vft\", verificationToken, {\n            path: router.asPath.split(\"?\")[0],\n            expires: 1,\n            sameSite: \"strict\",\n            secure: true,\n          });\n          setCode(null);\n        }\n\n        setViewData({\n          viewId,\n          file,\n          pages,\n          sheetData,\n          fileType,\n          isPreview,\n          ipAddress,\n          isTeamMember,\n          agentsEnabled,\n          viewerId,\n        });\n        setSubmitted(true);\n        setVerificationRequested(false);\n        setIsLoading(false);\n      }\n    } else {\n      const data = await response.json();\n      toast.error(data.message);\n\n      if (data.resetVerification) {\n        const currentPath = router.asPath.split(\"?\")[0];\n\n        Cookies.remove(\"pm_vft\", { path: currentPath });\n        setVerificationToken(null);\n        setCode(null);\n        setIsInvalidCode(true);\n      }\n      setIsLoading(false);\n    }\n  };\n\n  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (\n    event: React.FormEvent,\n  ): Promise<void> => {\n    event.preventDefault();\n    await handleSubmission();\n  };\n\n  // If token is present, run handle submit which will verify token and get document\n  // If link is not submitted and does not have email / password protection, show the access form\n  useEffect(() => {\n    if (!didMount.current) {\n      if ((!submitted && !isProtected) || token || previewToken) {\n        handleSubmission();\n      }\n      didMount.current = true;\n    }\n  }, [submitted, isProtected, token, previewToken]);\n\n  // Components to render when email is submitted but verification is pending\n  if (verificationRequested) {\n    return (\n      <EmailVerificationMessage\n        onSubmitHandler={handleSubmit}\n        data={data}\n        isLoading={isLoading}\n        code={code}\n        setCode={setCode}\n        isInvalidCode={isInvalidCode}\n        setIsInvalidCode={setIsInvalidCode}\n        brand={brand}\n      />\n    );\n  }\n\n  // If link is not submitted and does not have email / password protection, show the access form\n  if (!submitted && isProtected) {\n    return (\n      <AccessForm\n        data={data}\n        email={userEmail}\n        setData={setData}\n        onSubmitHandler={handleSubmit}\n        requireEmail={emailProtected}\n        requirePassword={!!linkPassword}\n        requireAgreement={enableAgreement!}\n        agreementName={link.agreement?.name}\n        agreementContent={link.agreement?.content}\n        agreementContentType={link.agreement?.contentType}\n        requireName={link.agreement?.requireName}\n        isLoading={isLoading}\n        brand={brand}\n        disableEditEmail={disableEditEmail}\n        useCustomAccessForm={useCustomAccessForm}\n        customFields={link.customFields}\n        logoOnAccessForm={logoOnAccessForm}\n        linkWelcomeMessage={link.welcomeMessage}\n      />\n    );\n  }\n\n  if (isLoading) {\n    return (\n      <div className=\"flex h-screen items-center justify-center\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className=\"bg-gray-950\"\n      style={{\n        backgroundColor:\n          brand && brand.accentColor ? brand.accentColor : \"rgb(3, 7, 18)\",\n      }}\n    >\n      {submitted ? (\n        <ViewData\n          link={link}\n          viewData={viewData}\n          document={document as unknown as TViewDocumentData}\n          notionData={notionData}\n          brand={brand}\n          showPoweredByBanner={showPoweredByBanner}\n          showAccountCreationSlide={showAccountCreationSlide}\n          useAdvancedExcelViewer={useAdvancedExcelViewer}\n          viewerEmail={data.email ?? verifiedEmail ?? userEmail ?? undefined}\n          annotationsEnabled={annotationsEnabled}\n          textSelectionEnabled={textSelectionEnabled}\n        />\n      ) : (\n        <div className=\"flex h-screen items-center justify-center\">\n          <LoadingSpinner className=\"h-20 w-20\" />\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/view/link-preview.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useRef } from \"react\";\n\nimport { ExternalLink } from \"lucide-react\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport Nav, { TNavData } from \"./nav\";\nimport { AwayPoster } from \"./viewer/away-poster\";\n\ninterface LinkPreviewProps {\n  linkUrl: string;\n  linkName: string;\n  navData: TNavData;\n  versionNumber: number;\n}\n\nexport default function LinkPreview({\n  linkUrl,\n  linkName,\n  navData,\n  versionNumber,\n}: LinkPreviewProps) {\n  const router = useRouter();\n  const startTimeRef = useRef(Date.now());\n  const visibilityRef = useRef<boolean>(true);\n\n  const domain = useMemo(() => {\n    if (!linkUrl) return \"\";\n\n    try {\n      const url = new URL(linkUrl);\n      return url.hostname.replace(\"www.\", \"\");\n    } catch (e) {\n      // If URL parsing fails, try to extract domain from string\n      const match = linkUrl.match(/https?:\\/\\/([^\\/]+)/);\n      if (match) {\n        return match[1].replace(\"www.\", \"\");\n      }\n      return linkUrl.length > 50 ? linkUrl.substring(0, 50) + \"...\" : linkUrl;\n    }\n  }, [linkUrl]);\n\n  const trackingOptions = getTrackingOptions();\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...trackingOptions,\n    externalStartTimeRef: startTimeRef,\n  });\n\n  const { linkId, documentId, viewId, isPreview, dataroomId } = navData;\n\n  useEffect(() => {\n    // Remove token and email query parameters on component mount\n    const removeQueryParams = (queries: string[]) => {\n      const currentQuery = { ...router.query };\n      const currentPath = router.asPath.split(\"?\")[0];\n      queries.map((query) => delete currentQuery[query]);\n\n      router.replace(\n        {\n          pathname: currentPath,\n          query: currentQuery,\n        },\n        undefined,\n        { shallow: true },\n      );\n    };\n\n    if (router.query.token) {\n      removeQueryParams([\"token\", \"email\", \"domain\", \"slug\", \"linkId\"]);\n    }\n  }, []);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n\n        // Restart interval tracking\n        const trackingData = {\n          linkId,\n          documentId,\n          viewId,\n          pageNumber: 1,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        };\n        startIntervalTracking(trackingData);\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n\n        // Track final duration using activity-aware calculation\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId,\n            documentId,\n            viewId,\n            duration,\n            pageNumber: 1,\n            versionNumber,\n            dataroomId,\n            isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      const duration = getActiveDuration();\n      trackPageViewSafely(\n        {\n          linkId,\n          documentId,\n          viewId,\n          duration,\n          pageNumber: 1,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        },\n        true,\n      );\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const trackingData = {\n      linkId,\n      documentId,\n      viewId,\n      pageNumber: 1,\n      versionNumber,\n      dataroomId,\n      isPreview,\n    };\n\n    startIntervalTracking(trackingData);\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  const handleLinkClick = () => {\n    // Track the link click (non-blocking)\n    if (!isPreview && viewId && linkUrl) {\n      fetch(\"/api/record_click\", {\n        method: \"POST\",\n        body: JSON.stringify({\n          timestamp: new Date().toISOString(),\n          sessionId: viewId,\n          linkId,\n          documentId,\n          viewId,\n          pageNumber: \"1\",\n          href: linkUrl,\n          versionNumber,\n          dataroomId,\n        }),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      }).catch(console.error);\n    }\n\n    window.open(linkUrl, \"_blank\", \"noopener,noreferrer\");\n    return;\n  };\n\n  return (\n    <>\n      <Nav pageNumber={1} numPages={1} navData={navData} />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className=\"relative flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-900\"\n      >\n        <div className=\"flex flex-col items-center space-y-6 p-8 text-center\">\n          <div className=\"rounded-full bg-gray-100 p-6 dark:bg-gray-800\">\n            <ExternalLink className=\"h-12 w-12 text-gray-600 dark:text-gray-300\" />\n          </div>\n          <h2 className=\"text-2xl font-semibold text-gray-900 dark:text-gray-100\">\n            {domain || linkName || \"External Link\"}\n          </h2>\n          <p className=\"max-w-md text-sm text-gray-500 dark:text-gray-400\">\n            You&apos;re leaving Papermark. If you trust this link, click to\n            continue.\n          </p>\n          {linkUrl ? (\n            <a\n              href={linkUrl}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"block cursor-pointer break-all text-sm font-medium text-blue-600 underline transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300\"\n              onClick={(e) => {\n                e.preventDefault();\n                e.stopPropagation();\n                handleLinkClick();\n              }}\n            >\n              {linkUrl}\n            </a>\n          ) : (\n            <p className=\"text-sm text-gray-500 dark:text-gray-400\">\n              Link URL not available\n            </p>\n          )}\n          <Button\n            onClick={handleLinkClick}\n            className=\"w-full max-w-md space-x-2\"\n            size=\"lg\"\n            disabled={!linkUrl}\n          >\n            <ExternalLink className=\"h-4 w-4\" />\n            <span>Continue to {domain || \"link\"}</span>\n          </Button>\n        </div>\n      </div>\n      <AwayPoster\n        isVisible={isInactive}\n        inactivityThreshold={trackingOptions.inactivityThreshold}\n        onDismiss={updateActivity}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/nav.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport React, { useEffect, useState } from \"react\";\n\nimport { useViewerChatSafe } from \"@/ee/features/ai/components/viewer-chat-provider\";\nimport { Brand, DataroomBrand } from \"@prisma/client\";\nimport {\n  ArrowUpRight,\n  BadgeInfoIcon,\n  Download,\n  Maximize,\n  MessageCircle,\n  Slash,\n  ZoomInIcon,\n  ZoomOutIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { createAdaptiveSurfacePalette } from \"@/lib/utils/create-adaptive-surface-palette\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport PapermarkSparkle from \"../shared/icons/papermark-sparkle\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"../ui/breadcrumb\";\nimport { Button } from \"../ui/button\";\nimport { AnnotationToggle } from \"./annotations/annotation-toggle\";\nimport { ConversationSidebar } from \"./conversations/sidebar\";\nimport ReportForm from \"./report-form\";\n\nexport type TNavData = {\n  linkId: string;\n  documentId: string;\n  allowDownload?: boolean;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n  isDataroom?: boolean;\n  viewId?: string;\n  viewerId?: string;\n  isMobile?: boolean;\n  isPreview?: boolean;\n  dataroomId?: string;\n  conversationsEnabled?: boolean;\n  isTeamMember?: boolean;\n  annotationsEnabled?: boolean;\n  hasAnnotations?: boolean;\n  annotationsFeatureEnabled?: boolean;\n  onToggleAnnotations?: (enabled: boolean) => void;\n};\n\nexport default function Nav({\n  navData,\n  type,\n  pageNumber,\n  numPages,\n  embeddedLinks,\n  hasWatermark,\n  handleZoomIn,\n  handleZoomOut,\n  handleFullscreen,\n}: {\n  navData: TNavData;\n  type?: \"pdf\" | \"notion\" | \"sheet\";\n  pageNumber?: number;\n  numPages?: number;\n  embeddedLinks?: string[];\n  hasWatermark?: boolean;\n  handleZoomIn?: () => void;\n  handleZoomOut?: () => void;\n  handleFullscreen?: () => void;\n}) {\n  const router = useRouter();\n  const asPath = router.asPath;\n  const { previewToken, preview } = router.query;\n\n  // Get chat context to adjust navbar when chat is open\n  const chatContext = useViewerChatSafe();\n  const isChatOpen = chatContext?.isOpen && chatContext?.isEnabled;\n\n  const {\n    linkId,\n    allowDownload,\n    brand,\n    isDataroom,\n    viewId,\n    viewerId,\n    isMobile,\n    isPreview,\n    documentId,\n    dataroomId,\n    conversationsEnabled,\n    isTeamMember,\n    annotationsEnabled,\n    hasAnnotations,\n    annotationsFeatureEnabled,\n    onToggleAnnotations,\n  } = navData;\n\n  const [showConversations, setShowConversations] = useState(false);\n  const navColorPalette = createAdaptiveSurfacePalette(brand?.brandColor);\n\n  // Extract the dataroom path from the URL\n  // This regex captures everything before \"/d/\" in the path\n  const dataroomPathMatch = asPath.match(/^(.*?)\\/d\\//);\n  const dataroomPath = dataroomPathMatch ? dataroomPathMatch[1] : \"\";\n\n  const downloadFile = async () => {\n    if (isPreview) {\n      toast.error(\"You cannot download documents in preview mode.\");\n      return;\n    }\n    if (!allowDownload || type === \"notion\") return;\n\n    const downloadPromise = (async () => {\n      const response = await fetch(`/api/links/download`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ linkId, viewId }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({}));\n        const errorMessage = errorData.error || \"Failed to download file\";\n        throw new Error(errorMessage);\n      }\n\n      // Check if the response is a PDF file (for watermarked PDFs)\n      const contentType = response.headers.get(\"content-type\");\n      if (contentType === \"application/pdf\") {\n        // Handle direct PDF download (watermarked PDFs)\n        const pdfBlob = await response.blob();\n        const blobUrl = window.URL.createObjectURL(pdfBlob);\n\n        // Extract filename from Content-Disposition header\n        const contentDisposition = response.headers.get(\"content-disposition\");\n        let filename = \"document.pdf\";\n        if (contentDisposition) {\n          const filenameMatch = contentDisposition.match(\n            /filename[^;=\\n]*=((['\"]).*?\\2|[^;\\n]*)/,\n          );\n          if (filenameMatch && filenameMatch[1]) {\n            filename = decodeURIComponent(\n              filenameMatch[1].replace(/['\"]/g, \"\"),\n            );\n          }\n        }\n\n        const link = document.createElement(\"a\");\n        link.href = blobUrl;\n        link.rel = \"noopener noreferrer\";\n        link.download = filename;\n        document.body.appendChild(link);\n        link.click();\n\n        setTimeout(() => {\n          window.URL.revokeObjectURL(blobUrl);\n          document.body.removeChild(link);\n        }, 100);\n      } else {\n        // Handle JSON response with downloadUrl (non-watermarked files)\n        const { downloadUrl } = await response.json();\n\n        const iframe = document.createElement(\"iframe\");\n        iframe.style.display = \"none\";\n        document.body.appendChild(iframe);\n        iframe.src = downloadUrl;\n\n        setTimeout(() => {\n          if (iframe.parentNode) {\n            document.body.removeChild(iframe);\n          }\n        }, 5000);\n      }\n\n      return \"File downloaded successfully\";\n    })();\n\n    toast.promise(downloadPromise, {\n      loading: hasWatermark\n        ? \"Preparing download with watermark...\"\n        : \"Preparing download...\",\n      success: (message) => message,\n      error: (err) => err.message || \"Failed to download file\",\n    });\n  };\n\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Toggle conversations with 'c' key\n      if (\n        e.key === \"c\" &&\n        !e.metaKey &&\n        !e.ctrlKey &&\n        !e.altKey &&\n        isDataroom &&\n        conversationsEnabled &&\n        !showConversations // if conversations are already open, don't toggle them\n      ) {\n        e.preventDefault();\n        setShowConversations((prev) => !prev);\n      }\n\n      if (e.key === \"Escape\" && showConversations) {\n        e.preventDefault();\n        setShowConversations(false);\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [isDataroom, conversationsEnabled, showConversations]);\n\n  return (\n    <nav\n      className=\"bg-black\"\n      style={{\n        backgroundColor: brand && brand.brandColor ? brand.brandColor : \"black\",\n        // Extend navbar to full width when chat panel is open (counteract parent padding)\n        marginRight: isChatOpen ? \"-400px\" : undefined,\n        // paddingRight: isChatOpen ? \"400px\" : undefined,\n      }}\n    >\n      <div className=\"mx-auto px-2 sm:px-6 lg:px-8\">\n        <div className=\"relative flex h-16 items-center justify-between\">\n          <div className=\"flex flex-1 items-center justify-start\">\n            <div className=\"relative flex h-16 w-36 flex-shrink-0 items-center\">\n              {brand && brand.logo ? (\n                <img\n                  className=\"h-16 w-36 object-contain\"\n                  src={brand.logo}\n                  alt=\"Logo\"\n                  // fill\n                  // quality={100}\n                  // priority\n                />\n              ) : (\n                <Link\n                  href={`https://www.papermark.com?utm_campaign=navbar&utm_medium=navbar&utm_source=papermark-${linkId}`}\n                  target=\"_blank\"\n                  className=\"text-2xl font-bold tracking-tighter text-white\"\n                >\n                  Papermark\n                </Link>\n              )}\n            </div>\n            {isDataroom ? (\n              <Breadcrumb className=\"ml-6\">\n                <BreadcrumbList>\n                  <BreadcrumbItem>\n                    <BreadcrumbLink\n                      className=\"cursor-pointer underline underline-offset-4 hover:font-medium\"\n                      href={`${dataroomPath}${isPreview ? \"?previewToken=\" + previewToken + \"&preview=\" + preview : \"\"}`}\n                      style={{\n                        color: navColorPalette.textColor,\n                      }}\n                    >\n                      Home\n                    </BreadcrumbLink>\n                  </BreadcrumbItem>\n                  {type === \"notion\" ? (\n                    <>\n                      <BreadcrumbSeparator>\n                        <Slash />\n                      </BreadcrumbSeparator>\n                      <div id=\"view-breadcrump-portal\"></div>\n                    </>\n                  ) : null}\n                </BreadcrumbList>\n              </Breadcrumb>\n            ) : type === \"notion\" ? (\n              <Breadcrumb>\n                <BreadcrumbList>\n                  <div id=\"view-breadcrump-portal\"></div>\n                </BreadcrumbList>\n              </Breadcrumb>\n            ) : null}\n          </div>\n          <div className=\"absolute inset-y-0 right-0 flex items-center space-x-2 pr-2 sm:static sm:inset-auto sm:ml-6 sm:space-x-4 sm:pr-0\">\n            {isTeamMember && (\n              <TooltipProvider delayDuration={100}>\n                <Tooltip defaultOpen>\n                  <TooltipTrigger asChild>\n                    <Button\n                      className=\"size-8 bg-gray-900 text-white hover:bg-gray-900/80 sm:size-10\"\n                      size=\"icon\"\n                    >\n                      <BadgeInfoIcon className=\"h-5 w-5\" />\n                    </Button>\n                  </TooltipTrigger>\n                  <TooltipContent>\n                    <p className=\"max-w-xs text-wrap text-center\">\n                      Skipped verification because you are a team member; no\n                      analytics will be collected\n                    </p>\n                  </TooltipContent>\n                </Tooltip>\n              </TooltipProvider>\n            )}\n            {/* Conversation toggle button for dataroom documents */}\n            {isDataroom && conversationsEnabled && (\n              <Button\n                onClick={() => setShowConversations(!showConversations)}\n                className=\"bg-gray-900 text-white hover:bg-gray-900/80\"\n              >\n                View FAQ\n              </Button>\n            )}\n            {/* Annotations toggle button */}\n            {onToggleAnnotations && annotationsFeatureEnabled && (\n              <AnnotationToggle\n                enabled={annotationsEnabled || false}\n                onToggle={onToggleAnnotations}\n                hasAnnotations={hasAnnotations}\n              />\n            )}\n            {embeddedLinks && embeddedLinks.length > 0 ? (\n              <DropdownMenu>\n                <DropdownMenuTrigger>\n                  <Button className=\"bg-gray-900 text-sm font-medium text-white hover:bg-gray-900/80\">\n                    Links on Page\n                  </Button>\n                </DropdownMenuTrigger>\n                <DropdownMenuContent className=\"space-y-2\" align=\"end\">\n                  <DropdownMenuLabel>Links on current page</DropdownMenuLabel>\n                  <DropdownMenuSeparator />\n                  {embeddedLinks.map((link, index) => (\n                    <Link\n                      href={link}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      key={index}\n                    >\n                      <DropdownMenuItem className=\"group h-10\">\n                        <span className=\"w-[200px] truncate group-focus:overflow-x-auto group-focus:text-clip\">\n                          {link}\n                        </span>\n                        <DropdownMenuShortcut className=\"pl-2 opacity-0 group-hover:opacity-60 group-focus:opacity-60\">\n                          <ArrowUpRight />\n                        </DropdownMenuShortcut>\n                      </DropdownMenuItem>\n                    </Link>\n                  ))}\n                </DropdownMenuContent>\n              </DropdownMenu>\n            ) : null}\n\n            {allowDownload ? (\n              <Button\n                onClick={downloadFile}\n                className=\"size-8 bg-gray-900 text-white hover:bg-gray-900/80 sm:size-10\"\n                size=\"icon\"\n                title=\"Download document\"\n              >\n                <Download className=\"size-4 sm:size-5\" />\n              </Button>\n            ) : null}\n\n            {!isMobile && handleZoomIn && handleZoomOut && (\n              <div className=\"flex gap-1\">\n                <TooltipProvider delayDuration={50}>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <Button\n                        onClick={handleZoomIn}\n                        className=\"bg-gray-900 text-white hover:bg-gray-900/80\"\n                        size=\"icon\"\n                      >\n                        <ZoomInIcon className=\"h-5 w-5\" />\n                      </Button>\n                    </TooltipTrigger>\n                    <TooltipContent>\n                      <span className=\"mr-2 text-xs\">Zoom in</span>\n                      <span className=\"ml-auto rounded-sm border bg-muted p-0.5 text-xs tracking-widest text-muted-foreground\">\n                        ⌘+\n                      </span>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n\n                <TooltipProvider delayDuration={50}>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <Button\n                        onClick={handleZoomOut}\n                        className=\"bg-gray-900 text-white hover:bg-gray-900/80\"\n                        size=\"icon\"\n                      >\n                        <ZoomOutIcon className=\"h-5 w-5\" />\n                      </Button>\n                    </TooltipTrigger>\n                    <TooltipContent>\n                      <span className=\"mr-2 text-xs\">Zoom out</span>\n                      <span className=\"ml-auto rounded-sm border bg-muted p-0.5 text-xs tracking-widest text-muted-foreground\">\n                        ⌘-\n                      </span>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n\n                {handleFullscreen && (\n                  <TooltipProvider delayDuration={50}>\n                    <Tooltip>\n                      <TooltipTrigger asChild>\n                        <Button\n                          onClick={handleFullscreen}\n                          className=\"bg-gray-900 text-white hover:bg-gray-900/80\"\n                          size=\"icon\"\n                        >\n                          <Maximize className=\"h-5 w-5\" />\n                        </Button>\n                      </TooltipTrigger>\n                      <TooltipContent>\n                        <span className=\"mr-2 text-xs\">Fullscreen</span>\n                        <span className=\"ml-auto rounded-sm border bg-muted p-0.5 text-xs tracking-widest text-muted-foreground\">\n                          F\n                        </span>\n                      </TooltipContent>\n                    </Tooltip>\n                  </TooltipProvider>\n                )}\n              </div>\n            )}\n\n            {pageNumber && numPages ? (\n              <div className=\"flex h-8 items-center space-x-1 rounded-md bg-gray-900 px-3 py-1.5 text-xs font-medium text-white sm:h-10 sm:px-4 sm:py-2 sm:text-sm\">\n                <span style={{ fontVariantNumeric: \"tabular-nums\" }}>\n                  {pageNumber}\n                </span>\n                <span className=\"text-gray-400\">/</span>\n                <span\n                  className=\"text-gray-400\"\n                  style={{ fontVariantNumeric: \"tabular-nums\" }}\n                >\n                  {numPages}\n                </span>\n              </div>\n            ) : null}\n            {/* add a separator that doesn't use radix or shadcn  */}\n            <div className=\"h-6 w-px bg-gray-800\" />\n            <ReportForm\n              linkId={linkId}\n              documentId={documentId}\n              viewId={viewId}\n            />\n          </div>\n        </div>\n      </div>\n      {isDataroom && conversationsEnabled && showConversations ? (\n        <ConversationSidebar\n          dataroomId={dataroomId}\n          documentId={documentId}\n          pageNumber={pageNumber}\n          viewId={viewId || \"\"}\n          viewerId={viewerId}\n          linkId={linkId!}\n          isEnabled={true}\n          isOpen={showConversations}\n          onOpenChange={setShowConversations}\n        />\n      ) : null}\n    </nav>\n  );\n}\n"
  },
  {
    "path": "components/view/powered-by.tsx",
    "content": "import { createPortal } from \"react-dom\";\n\nexport const PoweredBy = ({ linkId }: { linkId: string }) => {\n  return createPortal(\n    <div className=\"absolute bottom-0 right-0 z-[100] w-fit\">\n      <div className=\"p-6\">\n        <div className=\"pointer-events-auto relative z-20 flex min-h-8 w-auto items-center justify-end whitespace-nowrap rounded-md bg-black text-white ring-1 ring-white/40 hover:ring-white/90\">\n          <a\n            href={`https://www.papermark.com?utm_campaign=poweredby&utm_medium=poweredby&utm_source=papermark-${linkId}`}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"rounded-sm text-sm\"\n            style={{ paddingInlineStart: \"12px\", paddingInlineEnd: \"12px\" }}\n          >\n            Share docs via{\" \"}\n            <span className=\"font-semibold tracking-tighter\">Papermark</span>\n          </a>\n        </div>\n      </div>\n    </div>,\n    document.body,\n  );\n};\n"
  },
  {
    "path": "components/view/question.tsx",
    "content": "import { useState } from \"react\";\nimport type { CSSProperties } from \"react\";\n\nimport { ThumbsDownIcon, ThumbsUpIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\nimport { cn } from \"@/lib/utils\";\nimport { createAdaptiveSurfacePalette } from \"@/lib/utils/create-adaptive-surface-palette\";\n\nexport default function Question({\n  feedback,\n  viewId,\n  submittedFeedback,\n  setSubmittedFeedback,\n  isPreview,\n  accentColor,\n}: {\n  feedback: { id: string; data: { question: string; type: string } };\n  viewId?: string;\n  submittedFeedback: boolean;\n  setSubmittedFeedback: (submittedFeedback: boolean) => void;\n  isPreview?: boolean;\n  accentColor?: string | null;\n}) {\n  const [answer, setAnswer] = useState<\"yes\" | \"no\" | \"\">(\"\");\n  const palette = createAdaptiveSurfacePalette(accentColor);\n\n  const handleQuestionSubmit = async (answer: string) => {\n    if (answer === \"\") return;\n\n    // If in preview mode, skip recording the answer\n    if (isPreview) {\n      setAnswer(answer as \"yes\" | \"no\");\n      setSubmittedFeedback(true);\n      return;\n    }\n\n    const response = await fetch(`/api/feedback`, {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        answer: answer,\n        feedbackId: feedback.id,\n        viewId: viewId,\n      }),\n    });\n\n    if (response.status === 200) {\n      setAnswer(answer as \"yes\" | \"no\");\n      setSubmittedFeedback(true);\n    }\n  };\n\n  if (submittedFeedback) {\n    return (\n      <motion.div\n        className=\"mx-5 flex h-full flex-col items-center justify-center space-y-10 text-center sm:mx-auto\"\n        variants={{\n          hidden: { opacity: 0, scale: 0.95 },\n          show: {\n            opacity: 1,\n            scale: 1,\n            transition: {\n              staggerChildren: 0.2,\n            },\n          },\n        }}\n        initial=\"hidden\"\n        animate=\"show\"\n        exit=\"hidden\"\n        transition={{ duration: 0.3, type: \"spring\" }}\n      >\n        <motion.div\n          variants={STAGGER_CHILD_VARIANTS}\n          className=\"flex flex-col items-center space-y-10 text-center\"\n        >\n          <h1\n            className=\"font-display max-w-lg text-3xl font-semibold transition-colors sm:text-4xl\"\n            style={{ color: palette.textColor }}\n          >\n            Thanks for your feedback!\n          </h1>\n        </motion.div>\n      </motion.div>\n    );\n  }\n\n  return (\n    <motion.div\n      className=\"flex h-full w-full flex-col items-center justify-center space-y-10 text-center\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex w-full flex-col items-center space-y-10 text-center\"\n      >\n        <h1\n          className=\"font-display max-w-xl text-3xl font-semibold transition-colors sm:text-4xl\"\n          style={{ color: palette.textColor }}\n        >\n          {feedback.data.question}\n        </h1>\n      </motion.div>\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"grid w-full max-w-sm grid-cols-1 divide-y rounded-md border border-border md:grid-cols-2 md:divide-x md:divide-y-0\"\n        style={{\n          color: palette.textColor,\n          borderColor: palette.panelBorderColor,\n        }}\n      >\n        <button\n          onClick={() => handleQuestionSubmit(\"yes\")}\n          className={cn(\n            \"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-[var(--feedback-hover-bg)] md:p-10\",\n            answer === \"yes\" ? \"bg-[var(--feedback-active-bg)]\" : \"\",\n          )}\n          style={\n            {\n              \"--feedback-hover-bg\": palette.panelHoverBgColor,\n              \"--feedback-active-bg\": palette.panelActiveBgColor,\n            } as CSSProperties\n          }\n        >\n          <ThumbsUpIcon\n            className=\"pointer-events-none h-auto w-12 sm:w-12\"\n            strokeWidth={1}\n          />\n          <p>Yes</p>\n        </button>\n        <button\n          onClick={() => handleQuestionSubmit(\"no\")}\n          className={cn(\n            \"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-[var(--feedback-hover-bg)] md:p-10\",\n            answer === \"no\" ? \"bg-[var(--feedback-active-bg)]\" : \"\",\n          )}\n          style={\n            {\n              \"--feedback-hover-bg\": palette.panelHoverBgColor,\n              \"--feedback-active-bg\": palette.panelActiveBgColor,\n            } as CSSProperties\n          }\n        >\n          <ThumbsDownIcon\n            className=\"pointer-events-none h-auto w-12 sm:w-12\"\n            strokeWidth={1}\n          />\n          <p>No</p>\n        </button>\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/view/report-form.tsx",
    "content": "import { useState } from \"react\";\n\nimport { Flag } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\n\nimport { ButtonTooltip } from \"../ui/tooltip\";\n\nexport default function ReportForm({\n  linkId,\n  documentId,\n  viewId,\n}: {\n  linkId: string | undefined;\n  documentId: string | undefined;\n  viewId: string | undefined;\n}) {\n  const [abuseType, setAbuseType] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n  const [open, setOpen] = useState(false);\n\n  enum AbuseTypeEnum {\n    \"spam\" = 1,\n    \"malware\" = 2,\n    \"copyright\" = 3,\n    \"harmful\" = 4,\n    \"not-working\" = 5,\n    \"other\" = 6,\n  }\n\n  const handleSubmit = async () => {\n    if (!abuseType) {\n      toast.error(\"Please select an abuse type.\");\n      return;\n    }\n\n    const abuseTypeEnum =\n      AbuseTypeEnum[abuseType as keyof typeof AbuseTypeEnum]; // Convert string to enum number\n\n    setLoading(true);\n\n    const response = await fetch(\"/api/report\", {\n      method: \"POST\",\n      body: JSON.stringify({\n        linkId: linkId,\n        documentId: documentId,\n        viewId: viewId,\n        abuseType: abuseTypeEnum, // Send the numeric value of the abuse type\n      }),\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    });\n\n    if (!response.ok) {\n      const { message } = await response.json();\n      toast.error(message);\n      setOpen(false);\n      setLoading(false);\n      return;\n    }\n\n    toast.success(\"Report submitted successfully\");\n    setOpen(false);\n    setLoading(false);\n  };\n\n  return (\n    <>\n      <Popover open={open} onOpenChange={setOpen}>\n        <ButtonTooltip\n          content=\"Report abuse\"\n          sideOffset={8}\n          className=\"border-gray-800\"\n        >\n          <PopoverTrigger asChild>\n            <Button\n              className=\"h-8 w-8 bg-gray-900 text-xs text-gray-300 hover:bg-gray-900/80 hover:text-gray-50 sm:h-10 sm:w-10 sm:text-sm\"\n              size=\"icon\"\n              title=\"Report abuse\"\n            >\n              <Flag className=\"size-3 sm:size-4\" />\n            </Button>\n          </PopoverTrigger>\n        </ButtonTooltip>\n        <PopoverContent className=\"w-auto\" align=\"end\">\n          <div className=\"flex max-w-xs flex-col gap-4\">\n            <div className=\"space-y-2\">\n              <h4 className=\"font-medium leading-none\">Report an issue</h4>\n              <p className=\"text-sm text-muted-foreground\">\n                See something inappropriate? We will take a look and, when\n                appropriate, take action.\n              </p>\n            </div>\n            <div className=\"flex flex-col space-y-4\">\n              <RadioGroup value={abuseType} onValueChange={setAbuseType} className=\"grid gap-2\">\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"spam\" id=\"spam\" />\n                  <Label htmlFor=\"spam\" className=\"font-normal\">\n                    Spam, Fraud, or Scam\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"malware\" id=\"malware\" />\n                  <Label htmlFor=\"malware\" className=\"font-normal\">\n                    Malware or virus\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"copyright\" id=\"copyright\" />\n                  <Label htmlFor=\"copyright\" className=\"font-normal\">\n                    Copyright violation\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"harmful\" id=\"harmful\" />\n                  <Label htmlFor=\"harmful\" className=\"font-normal\">\n                    Harmful content\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"not-working\" id=\"not-working\" />\n                  <Label htmlFor=\"not-working\" className=\"font-normal\">\n                    Content is not working properly\n                  </Label>\n                </div>\n                <div className=\"flex items-center space-x-2\">\n                  <RadioGroupItem value=\"other\" id=\"other\" />\n                  <Label htmlFor=\"other\" className=\"font-normal\">\n                    Other\n                  </Label>\n                </div>\n              </RadioGroup>\n              <Button\n                onClick={handleSubmit}\n                disabled={!abuseType}\n                loading={loading}\n                size=\"sm\"\n              >\n                Submit Report\n              </Button>\n            </div>\n          </div>\n        </PopoverContent>\n      </Popover>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/toolbar.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nimport { createPortal } from \"react-dom\";\nimport Draggable from \"react-draggable\";\n\nimport { REACTIONS } from \"@/lib/constants\";\n\nimport GripVertical from \"../shared/icons/grip-vertical\";\n\nexport default function Toolbar({\n  viewId,\n  pageNumber,\n  isPreview,\n}: {\n  viewId?: string;\n  pageNumber: number;\n  isPreview?: boolean;\n}) {\n  const [currentEmoji, setCurrentEmoji] = useState<{\n    emoji: string;\n    id: number;\n  } | null>(null);\n  const clearEmojiTimeout = useRef<any>(null);\n\n  useEffect(() => {\n    setCurrentEmoji(null);\n  }, [pageNumber]);\n\n  useEffect(() => {\n    return () => {\n      if (clearEmojiTimeout.current) {\n        clearTimeout(clearEmojiTimeout.current);\n      }\n    };\n  }, []);\n\n  const handleEmojiClick = async (emoji: string, label: string) => {\n    // Clear any existing timeout\n    if (clearEmojiTimeout.current) {\n      clearTimeout(clearEmojiTimeout.current);\n    }\n\n    // Set the current emoji with a unique identifier\n    setCurrentEmoji({ emoji, id: Date.now() });\n\n    // Remove the emoji after the animation duration\n    clearEmojiTimeout.current = setTimeout(() => {\n      setCurrentEmoji(null);\n    }, 3000); // Adjust this duration to match your animation\n\n    // skip recording reactions if in preview mode\n    if (isPreview) return;\n\n    await fetch(\"/api/record_reaction\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        viewId: viewId,\n        type: label,\n        pageNumber: pageNumber,\n      }),\n    });\n  };\n\n  const Emoji = ({ label, emoji }: { label: string; emoji: string }) => (\n    <div className=\"relative w-fit\">\n      <button\n        className=\"font-emoji transition-bg-color duration-600 relative inline-flex items-center justify-center rounded-full bg-transparent px-1.5 py-1 align-middle text-xl leading-6 ease-in-out hover:bg-gray-200 active:bg-gray-400 active:duration-0\"\n        role=\"img\"\n        aria-label={label ? label : \"\"}\n        aria-hidden={label ? \"false\" : \"true\"}\n        onClick={() => handleEmojiClick(emoji, label)}\n      >\n        {emoji}\n        {currentEmoji && currentEmoji.emoji === emoji && (\n          <span\n            key={currentEmoji.id}\n            className=\"font-emoji duration-3000 absolute -top-10 left-0 right-0 mx-auto animate-flyEmoji\"\n          >\n            {currentEmoji.emoji}\n          </span>\n        )}\n      </button>\n    </div>\n  );\n\n  return createPortal(\n    <div className=\"pointer-events-none fixed inset-0 z-50\">\n      <Draggable bounds=\"parent\" handle=\".moveable-icon\">\n        <div className=\"pointer-events-auto absolute bottom-4 left-[45%] w-max -translate-x-1/2 transform rounded-full bg-gray-950/40\">\n          <div className=\"grid items-center justify-start\">\n            <div className=\"px-2 py-1\">\n              <div className=\"grid grid-flow-col items-center justify-start\">\n                {REACTIONS.map((reaction) => (\n                  <Emoji\n                    key={reaction.emoji}\n                    emoji={reaction.emoji}\n                    label={reaction.label}\n                  />\n                ))}\n                <GripVertical className=\"moveable-icon h-5 w-5 text-gray-100 active:text-gray-300\" />\n              </div>\n            </div>\n          </div>\n        </div>\n      </Draggable>\n    </div>,\n    document.body,\n  );\n}\n"
  },
  {
    "path": "components/view/view-data.tsx",
    "content": "import dynamic from \"next/dynamic\";\n\nimport { ViewerChatPanel } from \"@/ee/features/ai/components/viewer-chat-panel\";\nimport {\n  ViewerChatLayout,\n  ViewerChatProvider,\n} from \"@/ee/features/ai/components/viewer-chat-provider\";\nimport { ViewerChatToggle } from \"@/ee/features/ai/components/viewer-chat-toggle\";\nimport {\n  Brand,\n  DataroomBrand,\n  Document,\n  DocumentVersion,\n} from \"@prisma/client\";\nimport { ExtendedRecordMap } from \"notion-types\";\n\nimport { useLazyPages } from \"@/lib/hooks/use-lazy-pages\";\nimport {\n  LinkWithDataroomDocument,\n  LinkWithDocument,\n  NotionTheme,\n  WatermarkConfig,\n} from \"@/lib/types\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nimport { DEFAULT_DOCUMENT_VIEW_TYPE } from \"@/components/view/document-view\";\nimport { NotionPage } from \"@/components/view/viewer/notion-page\";\nimport PDFViewer from \"@/components/view/viewer/pdf-default-viewer\";\n\nimport { DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE } from \"./dataroom/dataroom-document-view\";\nimport LinkPreview from \"./link-preview\";\nimport { TNavData } from \"./nav\";\nimport AdvancedExcelViewer from \"./viewer/advanced-excel-viewer\";\nimport DownloadOnlyViewer from \"./viewer/download-only-viewer\";\nimport ImageViewer from \"./viewer/image-viewer\";\nimport PagesHorizontalViewer from \"./viewer/pages-horizontal-viewer\";\nimport PagesVerticalViewer from \"./viewer/pages-vertical-viewer\";\nimport VideoViewer from \"./viewer/video-viewer\";\n\nconst ExcelViewer = dynamic(\n  () => import(\"@/components/view/viewer/excel-viewer\"),\n  { ssr: false },\n);\n\nexport type TViewDocumentData = Document & {\n  versions: DocumentVersion[];\n};\n\nconst isDownloadAllowed = (\n  canDownload: boolean | undefined,\n  linkAllowDownload: boolean | undefined,\n): boolean => {\n  if (canDownload === false) return false;\n  return !!linkAllowDownload;\n};\n\nexport default function ViewData({\n  viewData,\n  link,\n  document,\n  notionData,\n  brand,\n  showPoweredByBanner,\n  showAccountCreationSlide,\n  useAdvancedExcelViewer,\n  viewerEmail,\n  dataroomId,\n  canDownload,\n  annotationsEnabled,\n  textSelectionEnabled,\n}: {\n  viewData: DEFAULT_DOCUMENT_VIEW_TYPE | DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE;\n  link: LinkWithDocument | LinkWithDataroomDocument;\n  document: TViewDocumentData;\n  notionData?: {\n    rootNotionPageId: string | null;\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null;\n  };\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n  showPoweredByBanner?: boolean;\n  showAccountCreationSlide?: boolean;\n  useAdvancedExcelViewer?: boolean;\n  viewerEmail?: string;\n  dataroomId?: string;\n  canDownload?: boolean;\n  annotationsEnabled?: boolean;\n  textSelectionEnabled?: boolean;\n}) {\n  const { isMobile } = useMediaQuery();\n\n  const documentVersionId = document.versions[0]?.id;\n\n  const { pages: lazyPages, ensurePagesLoaded } = useLazyPages({\n    initialPages: viewData.pages ?? [],\n    viewId: viewData.viewId,\n    documentVersionId: documentVersionId,\n  });\n\n  const navData: TNavData = {\n    viewId: viewData.viewId,\n    isPreview: viewData.isPreview,\n    linkId: link.id,\n    brand: brand,\n    viewerId: \"viewerId\" in viewData ? viewData.viewerId : undefined,\n    isMobile: isMobile,\n    isDataroom: !!dataroomId,\n    documentId: document.id,\n    dataroomId: dataroomId,\n    conversationsEnabled:\n      !!dataroomId &&\n      (\"conversationsEnabled\" in viewData\n        ? viewData.conversationsEnabled\n        : false),\n    allowDownload:\n      document.downloadOnly ||\n      isDownloadAllowed(canDownload, link.allowDownload ?? false),\n    isTeamMember: viewData.isTeamMember,\n    annotationsFeatureEnabled: annotationsEnabled,\n  };\n\n  // Check if agents are enabled (returned from views API after access is granted)\n  const agentsEnabled =\n    \"agentsEnabled\" in viewData ? viewData.agentsEnabled : false;\n\n  // Determine dataroom name if applicable\n  const dataroomName =\n    dataroomId && \"dataroomName\" in viewData\n      ? viewData.dataroomName\n      : undefined;\n\n  return (\n    <ViewerChatProvider\n      enabled={agentsEnabled}\n      documentId={document.id}\n      documentName={document.name}\n      dataroomId={dataroomId}\n      dataroomName={dataroomName}\n      linkId={link.id}\n      viewId={viewData.viewId}\n      viewerId={\"viewerId\" in viewData ? viewData.viewerId : undefined}\n    >\n      <ViewerChatLayout>\n        {notionData?.recordMap ? (\n          <NotionPage\n            recordMap={notionData.recordMap}\n            versionNumber={document.versions[0].versionNumber}\n            theme={notionData.theme}\n            screenshotProtectionEnabled={link.enableScreenshotProtection!}\n            textSelectionEnabled={textSelectionEnabled ?? false}\n            navData={navData}\n          />\n        ) : viewData.fileType === \"link\" ? (\n          <LinkPreview\n            linkUrl={viewData.file || document.versions[0]?.file || \"\"}\n            linkName={document.name}\n            versionNumber={document.versions[0]?.versionNumber || 1}\n            navData={navData}\n          />\n        ) : document.downloadOnly ? (\n          <DownloadOnlyViewer\n            versionNumber={document.versions[0].versionNumber}\n            documentName={document.name}\n            navData={navData}\n          />\n        ) : viewData.fileType === \"sheet\" && viewData.sheetData ? (\n          <ExcelViewer\n            versionNumber={document.versions[0].versionNumber}\n            sheetData={viewData.sheetData}\n            screenshotProtectionEnabled={link.enableScreenshotProtection!}\n            navData={navData}\n          />\n        ) : viewData.fileType === \"sheet\" && useAdvancedExcelViewer ? (\n          <AdvancedExcelViewer\n            file={viewData.file!}\n            versionNumber={document.versions[0].versionNumber}\n            navData={navData}\n          />\n        ) : viewData.fileType === \"image\" ? (\n          <ImageViewer\n            file={viewData.file!}\n            screenshotProtectionEnabled={link.enableScreenshotProtection!}\n            versionNumber={document.versions[0].versionNumber}\n            showPoweredByBanner={showPoweredByBanner}\n            viewerEmail={viewerEmail}\n            watermarkConfig={\n              link.enableWatermark\n                ? (link.watermarkConfig as WatermarkConfig)\n                : null\n            }\n            ipAddress={viewData.ipAddress}\n            linkName={link.name ?? `Link #${link.id.slice(-5)}`}\n            navData={navData}\n          />\n        ) : viewData.pages && !document.versions[0].isVertical ? (\n          <PagesHorizontalViewer\n            pages={lazyPages}\n            feedbackEnabled={link.enableFeedback!}\n            screenshotProtectionEnabled={link.enableScreenshotProtection!}\n            versionNumber={document.versions[0].versionNumber}\n            showPoweredByBanner={showPoweredByBanner}\n            showAccountCreationSlide={showAccountCreationSlide}\n            enableQuestion={link.enableQuestion}\n            feedback={link.feedback}\n            viewerEmail={viewerEmail}\n            watermarkConfig={\n              link.enableWatermark\n                ? (link.watermarkConfig as WatermarkConfig)\n                : null\n            }\n            ipAddress={viewData.ipAddress}\n            linkName={link.name ?? `Link #${link.id.slice(-5)}`}\n            navData={navData}\n            ensurePagesLoaded={ensurePagesLoaded}\n          />\n        ) : viewData.pages && document.versions[0].isVertical ? (\n          <PagesVerticalViewer\n            pages={lazyPages}\n            feedbackEnabled={link.enableFeedback!}\n            screenshotProtectionEnabled={link.enableScreenshotProtection!}\n            versionNumber={document.versions[0].versionNumber}\n            showPoweredByBanner={showPoweredByBanner}\n            enableQuestion={link.enableQuestion}\n            feedback={link.feedback}\n            viewerEmail={viewerEmail}\n            watermarkConfig={\n              link.enableWatermark\n                ? (link.watermarkConfig as WatermarkConfig)\n                : null\n            }\n            ipAddress={viewData.ipAddress}\n            linkName={link.name ?? `Link #${link.id.slice(-5)}`}\n            navData={navData}\n            ensurePagesLoaded={ensurePagesLoaded}\n          />\n        ) : viewData.fileType === \"video\" ? (\n          <VideoViewer\n            file={viewData.file!}\n            screenshotProtectionEnabled={link.enableScreenshotProtection!}\n            versionNumber={document.versions[0].versionNumber}\n            navData={navData}\n          />\n        ) : (\n          <PDFViewer\n            file={viewData.file}\n            name={document.name}\n            versionNumber={document.versions[0].versionNumber}\n            navData={navData}\n          />\n        )}\n      </ViewerChatLayout>\n\n      {/* AI Chat Components */}\n      <ViewerChatPanel />\n      <ViewerChatToggle />\n    </ViewerChatProvider>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/advanced-excel-viewer.tsx",
    "content": "import { useEffect, useRef } from \"react\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\n\nimport Nav, { TNavData } from \"../nav\";\nimport { AwayPoster } from \"./away-poster\";\n\nexport default function AdvancedExcelViewer({\n  file,\n  versionNumber,\n  navData,\n}: {\n  file: string;\n  versionNumber: number;\n  navData: TNavData;\n}) {\n  const { linkId, documentId, viewId, isPreview, dataroomId, brand } = navData;\n  const pageNumber = 1;\n\n  const startTimeRef = useRef(Date.now());\n  const visibilityRef = useRef<boolean>(true);\n\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...getTrackingOptions(),\n    externalStartTimeRef: startTimeRef,\n  });\n\n  // Start interval tracking when component mounts\n  useEffect(() => {\n    const trackingData = {\n      linkId,\n      documentId,\n      viewId,\n      pageNumber,\n      versionNumber,\n      dataroomId,\n      isPreview,\n    };\n\n    startIntervalTracking(trackingData);\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    pageNumber,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n        const trackingData = {\n          linkId,\n          documentId,\n          viewId,\n          pageNumber,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        };\n        startIntervalTracking(trackingData);\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n        const duration = getActiveDuration();\n        if (duration > 0) {\n          trackPageViewSafely(\n            {\n              linkId,\n              documentId,\n              viewId,\n              duration,\n              pageNumber,\n              versionNumber,\n              dataroomId,\n              isPreview,\n            },\n            true,\n          );\n        }\n      }\n    };\n\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      const duration = getActiveDuration();\n      if (duration > 0) {\n        trackPageViewSafely(\n          {\n            linkId,\n            documentId,\n            viewId,\n            duration,\n            pageNumber,\n            versionNumber,\n            dataroomId,\n            isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    pageNumber,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  return (\n    <>\n      <Nav type=\"sheet\" navData={navData} />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className=\"relative mx-2 flex h-screen flex-col sm:mx-6 lg:mx-8\"\n      >\n        <iframe\n          className=\"h-full w-full\"\n          src={`https://view.officeapps.live.com/op/embed.aspx?src=${file}&wdPrint=0&action=embedview&wdAllowInteractivity=False`}\n        ></iframe>\n        <div\n          className=\"absolute bottom-0 left-0 right-0 z-50 h-[26px] bg-gray-950\"\n          style={{\n            background: brand?.accentColor || \"rgb(3, 7, 18)\",\n          }}\n        />\n      </div>\n      <AwayPoster\n        isVisible={isInactive}\n        inactivityThreshold={getTrackingOptions().inactivityThreshold}\n        onDismiss={updateActivity}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/away-poster.tsx",
    "content": "import { Play } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\n\ninterface AwayPosterProps {\n  isVisible: boolean;\n  inactivityThreshold: number;\n  onDismiss?: () => void;\n  className?: string;\n}\n\nexport function AwayPoster({\n  isVisible,\n  inactivityThreshold,\n  onDismiss,\n  className,\n}: AwayPosterProps) {\n  const formatTime = (ms: number) => {\n    const seconds = Math.floor(ms / 1000);\n    const minutes = Math.floor(seconds / 60);\n    const remainingSeconds = seconds % 60;\n    \n    if (minutes > 0) {\n      return remainingSeconds > 0\n        ? `${minutes}min ${remainingSeconds}sec`\n        : `${minutes}min`;\n    }\n    return `${seconds}sec`;\n  };\n\n  if (!isVisible) return null;\n\n  return (\n    <>\n      <div\n        className=\"fixed inset-0 z-[99998] bg-black/50 backdrop-blur-sm\"\n        aria-hidden=\"true\"\n      />\n\n      <div\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby=\"away-poster-title\"\n        aria-describedby=\"away-poster-description\"\n        className={cn(\n          \"fixed bottom-4 left-4 right-4 z-[99999] w-full max-w-md rounded-md border bg-card p-4 text-card-foreground shadow-lg\",\n          \"sm:bottom-6 sm:left-6 sm:right-auto sm:w-auto sm:max-w-lg\",\n          \"animate-in fade-in slide-in-from-bottom\",\n          className,\n        )}\n      >\n        <h2 id=\"away-poster-title\" className=\"sr-only\">\n          Auto-paused session notification\n        </h2>\n\n        <p id=\"away-poster-description\" className=\"sr-only\">\n          Your session was paused due to inactivity. Click continue or move your\n          mouse to resume.\n        </p>\n\n        <div className=\"space-y-5\">\n          <div className=\"flex items-center space-x-2\">\n            <Badge\n              variant=\"outline\"\n              className=\"border-orange-400 bg-orange-100 text-orange-600\"\n            >\n              Auto-paused\n            </Badge>\n            <span className=\"mr-6 text-xs text-muted-foreground\">\n              {formatTime(inactivityThreshold)} idle\n            </span>\n          </div>\n\n          <div className=\"space-y-2\">\n            <h3 className=\"text-lg font-semibold\">\n              We paused to protect your session\n            </h3>\n            <p className=\"text-sm text-muted-foreground\">\n              You were inactive since {formatTime(inactivityThreshold)}, so we\n              paused the document preview to keep session safe.\n            </p>\n          </div>\n\n          <div className=\"pt-2\">\n            <Button onClick={onDismiss} className=\"w-full\">\n              <Play className=\"mr-2 h-4 w-4\" />\n              Continue where you left off\n            </Button>\n          </div>\n\n          <p className=\"text-center text-[11px] text-muted-foreground\">\n            Or just move your mouse or press any key to continue\n          </p>\n        </div>\n      </div>\n    </>\n  );\n}"
  },
  {
    "path": "components/view/viewer/dataroom-viewer.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport { ViewerChatPanel } from \"@/ee/features/ai/components/viewer-chat-panel\";\nimport {\n  ViewerChatLayout,\n  ViewerChatProvider,\n} from \"@/ee/features/ai/components/viewer-chat-provider\";\nimport { ViewerChatToggle } from \"@/ee/features/ai/components/viewer-chat-toggle\";\nimport {\n  DataroomBrand,\n  DataroomFolder,\n  PermissionGroupAccessControls,\n  ViewerGroupAccessControls,\n} from \"@prisma/client\";\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\";\nimport { PanelLeftIcon, UploadIcon, XIcon } from \"lucide-react\";\n\nimport { usePendingUploads } from \"@/context/pending-uploads-context\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  getHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\nimport { sortByIndexThenName } from \"@/lib/utils/sort-items-by-index-name\";\n\nimport { ViewFolderTree } from \"@/components/datarooms/folders\";\nimport { SearchBoxPersisted } from \"@/components/search-box\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport {\n  Sheet,\n  SheetOverlay,\n  SheetPortal,\n  SheetTrigger,\n} from \"@/components/ui/sheet\";\n\nimport { DEFAULT_DATAROOM_VIEW_TYPE } from \"../dataroom/dataroom-view\";\nimport DocumentCard from \"../dataroom/document-card\";\nimport { DocumentUploadModal } from \"../dataroom/document-upload-modal\";\nimport FolderCard from \"../dataroom/folder-card\";\nimport IndexFileDialog from \"../dataroom/index-file-dialog\";\nimport {\n  IntroductionInfoButton,\n  IntroductionProvider,\n} from \"../dataroom/introduction-modal\";\nimport DataroomNav from \"../dataroom/nav-dataroom\";\nimport PendingDocumentCard from \"../dataroom/pending-document-card\";\nimport {\n  ViewerSurfaceThemeProvider,\n  createViewerSurfaceTheme,\n} from \"./viewer-surface-theme\";\n\nconst ViewerBreadcrumbItem = ({\n  folder,\n  setFolderId,\n  isLast,\n  dataroomIndexEnabled,\n}: {\n  folder: any;\n  setFolderId: (id: string | null) => void;\n  isLast: boolean;\n  dataroomIndexEnabled?: boolean;\n}) => {\n  const displayName = getHierarchicalDisplayName(\n    folder.name,\n    folder.hierarchicalIndex,\n    dataroomIndexEnabled || false,\n  );\n\n  if (isLast) {\n    return (\n      <BreadcrumbPage\n        className=\"capitalize text-[var(--viewer-text)]\"\n        style={HIERARCHICAL_DISPLAY_STYLE}\n      >\n        {displayName}\n      </BreadcrumbPage>\n    );\n  }\n\n  return (\n    <BreadcrumbLink\n      onClick={() => setFolderId(folder.id)}\n      className=\"cursor-pointer capitalize text-[var(--viewer-muted-text)] hover:text-[var(--viewer-text)]\"\n      style={HIERARCHICAL_DISPLAY_STYLE}\n    >\n      {displayName}\n    </BreadcrumbLink>\n  );\n};\n\ntype FolderOrDocument =\n  | (DataroomFolder & { allowDownload: boolean })\n  | DataroomDocument;\n\nexport type DocumentVersion = {\n  id: string;\n  type: string;\n  versionNumber: number;\n  hasPages: boolean;\n  isVertical: boolean;\n  updatedAt: Date;\n};\n\ntype DataroomDocument = {\n  dataroomDocumentId: string;\n  folderId: string | null;\n  id: string;\n  name: string;\n  orderIndex: number | null;\n  downloadOnly: boolean;\n  versions: DocumentVersion[];\n  canDownload: boolean;\n  canView: boolean;\n  hierarchicalIndex: string | null;\n};\n\nconst getParentFolders = (\n  folderId: string | null,\n  folders: DataroomFolder[],\n): DataroomFolder[] => {\n  const breadcrumbFolders: DataroomFolder[] = [];\n  let currentFolder = folders.find((folder) => folder.id === folderId);\n\n  while (currentFolder) {\n    breadcrumbFolders.unshift(currentFolder);\n    currentFolder = folders.find(\n      (folder) => folder.id === currentFolder!.parentId,\n    );\n  }\n\n  return breadcrumbFolders;\n};\n\nexport default function DataroomViewer({\n  brand,\n  viewId,\n  linkId,\n  dataroom,\n  allowDownload,\n  isPreview,\n  folderId,\n  setFolderId,\n  accessControls,\n  viewerId,\n  viewData,\n  enableIndexFile,\n  isEmbedded,\n  viewerEmail,\n  dataroomIndexEnabled,\n}: {\n  brand: Partial<DataroomBrand>;\n  viewId?: string;\n  linkId: string;\n  dataroom: any;\n  allowDownload: boolean;\n  isPreview?: boolean;\n  folderId: string | null;\n  setFolderId: React.Dispatch<React.SetStateAction<string | null>>;\n  accessControls: ViewerGroupAccessControls[] | PermissionGroupAccessControls[];\n  viewerId?: string;\n  viewData: DEFAULT_DATAROOM_VIEW_TYPE;\n  enableIndexFile?: boolean;\n  isEmbedded?: boolean;\n  viewerEmail?: string;\n  dataroomIndexEnabled?: boolean;\n}) {\n  const { documents, folders, allowBulkDownload } = dataroom as {\n    documents: DataroomDocument[];\n    folders: DataroomFolder[];\n    allowBulkDownload: boolean;\n  };\n\n  const router = useRouter();\n  const searchQuery = (router.query.search as string)?.toLowerCase() || \"\";\n\n  // Tab state: \"documents\" (normal view) or \"my-uploads\" (visitor's uploads)\n  const [activeTab, setActiveTab] = useState<\"documents\" | \"my-uploads\">(\n    \"documents\",\n  );\n\n  // Get pending uploads (in-flight + persisted from server)\n  const {\n    getPendingUploadsForFolder,\n    getAllUploads,\n    hasUploads,\n    updatePendingUpload,\n  } = usePendingUploads();\n  const pendingUploadsForFolder = getPendingUploadsForFolder(folderId);\n  const allUploads = getAllUploads();\n\n  const breadcrumbFolders = useMemo(\n    () => getParentFolders(folderId, folders),\n    [folderId, folders],\n  );\n\n  const allDocumentsCanDownload = useMemo(() => {\n    if (!allowDownload) return false;\n    if (!documents || documents.length === 0) return false;\n\n    return documents.some((doc) => {\n      if (doc.versions[0].type === \"notion\") return false;\n      const accessControl = accessControls.find(\n        (access) => access.itemId === doc.dataroomDocumentId,\n      );\n      return accessControl?.canDownload ?? true;\n    });\n  }, [documents, accessControls, allowDownload]);\n\n  // Efficiently calculate effective updatedAt for all folders in a single pass\n  const folderEffectiveUpdatedAt = useMemo(() => {\n    const effectiveUpdatedAt = new Map<string, Date>();\n\n    // Create maps for fast lookups\n    const folderChildren = new Map<string, string[]>();\n    const folderDocuments = new Map<string, DataroomDocument[]>();\n\n    // Build folder hierarchy map\n    folders.forEach((folder) => {\n      const parentId = folder.parentId || \"root\";\n      if (!folderChildren.has(parentId)) {\n        folderChildren.set(parentId, []);\n      }\n      folderChildren.get(parentId)!.push(folder.id);\n    });\n\n    // Build document map\n    documents.forEach((doc) => {\n      const folderId = doc.folderId || \"root\";\n      if (!folderDocuments.has(folderId)) {\n        folderDocuments.set(folderId, []);\n      }\n      folderDocuments.get(folderId)!.push(doc);\n    });\n\n    // Calculate effective updatedAt bottom-up (post-order traversal)\n    const calculateEffectiveUpdatedAt = (folderId: string): Date => {\n      // Return cached result if already calculated\n      if (effectiveUpdatedAt.has(folderId)) {\n        return effectiveUpdatedAt.get(folderId)!;\n      }\n\n      const folder = folders.find((f) => f.id === folderId);\n      if (!folder) return new Date(0);\n\n      let maxDate = new Date(folder.updatedAt);\n\n      // Check documents in this folder\n      const docsInFolder = folderDocuments.get(folderId) || [];\n      docsInFolder.forEach((doc) => {\n        if (doc.versions && doc.versions.length > 0) {\n          const docDate = new Date(doc.versions[0].updatedAt);\n          if (docDate > maxDate) maxDate = docDate;\n        }\n      });\n\n      // Check child folders recursively\n      const childFolderIds = folderChildren.get(folderId) || [];\n      childFolderIds.forEach((childId) => {\n        const childDate = calculateEffectiveUpdatedAt(childId);\n        if (childDate > maxDate) maxDate = childDate;\n      });\n\n      // Cache and return result\n      effectiveUpdatedAt.set(folderId, maxDate);\n      return maxDate;\n    };\n\n    // Calculate for all folders\n    folders.forEach((folder) => {\n      calculateEffectiveUpdatedAt(folder.id);\n    });\n\n    return effectiveUpdatedAt;\n  }, [folders, documents]);\n\n  // create a mixedItems array with folders and documents of the current folder and memoize it\n  const mixedItems = useMemo(() => {\n    // If there's a search query, filter documents by name across all folders\n    if (searchQuery) {\n      return (documents || [])\n        .filter((doc) => doc.name.toLowerCase().includes(searchQuery))\n        .map((doc) => {\n          const accessControl = accessControls.find(\n            (access) => access.itemId === doc.dataroomDocumentId,\n          );\n\n          return {\n            ...doc,\n            itemType: \"document\",\n            canDownload:\n              (accessControl?.canDownload ?? true) &&\n              doc.versions[0].type !== \"notion\",\n          };\n        })\n        .sort((a, b) => (a.orderIndex || 0) - (b.orderIndex || 0));\n    }\n\n    const mixedItems: FolderOrDocument[] = [\n      ...(folders || [])\n        .filter((folder) => folder.parentId === folderId)\n        .map((folder) => {\n          const folderDocuments = documents.filter(\n            (doc) => doc.folderId === folder.id,\n          );\n\n          // Get pre-calculated effective updatedAt\n          const effectiveUpdatedAt =\n            folderEffectiveUpdatedAt.get(folder.id) ||\n            new Date(folder.updatedAt);\n\n          const allDocumentsCanDownload =\n            folderDocuments.length === 0 || // Allow download for empty folders\n            folderDocuments.every((doc) => {\n              const accessControl = accessControls.find(\n                (access) => access.itemId === doc.dataroomDocumentId,\n              );\n              return (\n                (accessControl?.canDownload ?? true) &&\n                doc.versions[0].type !== \"notion\"\n              );\n            });\n\n          return {\n            ...folder,\n            updatedAt: effectiveUpdatedAt,\n            itemType: \"folder\",\n            allowDownload: allowDownload && allDocumentsCanDownload,\n          };\n        }),\n      ...(documents || [])\n        .filter((doc) => doc.folderId === folderId)\n        .map((doc) => {\n          const accessControl = accessControls.find(\n            (access) => access.itemId === doc.dataroomDocumentId,\n          );\n\n          return {\n            ...doc,\n            itemType: \"document\",\n            canDownload:\n              (accessControl?.canDownload ?? true) &&\n              doc.versions[0].type !== \"notion\",\n          };\n        }),\n    ];\n\n    return sortByIndexThenName(mixedItems);\n  }, [\n    folders,\n    documents,\n    folderId,\n    accessControls,\n    allowDownload,\n    folderEffectiveUpdatedAt,\n    searchQuery,\n  ]);\n\n  const filteredPendingUploads = useMemo(\n    () =>\n      pendingUploadsForFolder.filter((u) => {\n        if (!u.documentId) return true;\n        return !mixedItems.some(\n          (item) => \"versions\" in item && item.id === u.documentId,\n        );\n      }),\n    [pendingUploadsForFolder, mixedItems],\n  );\n\n  // Fallback reconciliation: if the document is already visible and ready,\n  // mark its pending upload as complete even if realtime status was missed.\n  useEffect(() => {\n    allUploads.forEach((upload) => {\n      if (upload.status !== \"processing\" || !upload.documentId) return;\n\n      const matchingDocument = documents.find((doc) => doc.id === upload.documentId);\n      if (!matchingDocument) return;\n\n      const primaryVersion = matchingDocument.versions[0];\n      if (!primaryVersion) return;\n\n      const needsProcessing = [\"pdf\", \"docs\", \"slides\"].includes(\n        primaryVersion.type,\n      );\n      const isReady = !needsProcessing || primaryVersion.hasPages;\n\n      if (isReady) {\n        updatePendingUpload(upload.id, { status: \"complete\" });\n      }\n    });\n  }, [allUploads, documents, updatePendingUpload]);\n\n  const renderItem = (item: FolderOrDocument) => {\n    if (\"versions\" in item) {\n      const isProcessing =\n        [\"docs\", \"slides\", \"pdf\"].includes(item.versions[0].type) &&\n        !item.versions[0].hasPages;\n\n      return (\n        <DocumentCard\n          key={item.id}\n          document={item}\n          linkId={linkId}\n          viewId={viewId}\n          isPreview={!!isPreview}\n          allowDownload={allowDownload && item.canDownload}\n          isProcessing={isProcessing}\n          dataroomIndexEnabled={dataroomIndexEnabled}\n          showLastUpdated={dataroom?.showLastUpdated ?? true}\n        />\n      );\n    }\n\n    return (\n      <FolderCard\n        key={item.id}\n        folder={item}\n        dataroomId={dataroom?.id}\n        setFolderId={setFolderId}\n        isPreview={!!isPreview}\n        linkId={linkId}\n        viewId={viewId}\n        allowDownload={item.allowDownload}\n        dataroomIndexEnabled={dataroomIndexEnabled}\n        showLastUpdated={dataroom?.showLastUpdated ?? true}\n      />\n    );\n  };\n\n  const viewerSurfaceTheme = useMemo(\n    () =>\n      createViewerSurfaceTheme(\n        (brand as any)?.applyAccentColorToDataroomView\n          ? brand?.accentColor\n          : \"#ffffff\",\n      ),\n    [brand],\n  );\n  const mobileTreeTheme = useMemo(\n    () => ({\n      ...viewerSurfaceTheme,\n      textTone: \"dark\" as const,\n      usesLightText: false,\n    }),\n    [viewerSurfaceTheme],\n  );\n\n  // Prepare documents for chat context\n  const documentsForChat = documents.map((doc) => ({\n    dataroomDocumentId: doc.dataroomDocumentId,\n    id: doc.id,\n    name: doc.name,\n    folderId: doc.folderId,\n  }));\n\n  return (\n    <IntroductionProvider dataroom={dataroom} viewerId={viewerId}>\n      <ViewerChatProvider\n        enabled={viewData.agentsEnabled}\n        dataroomId={dataroom?.id}\n        dataroomName={viewData.dataroomName}\n        linkId={linkId}\n        viewId={viewId}\n        viewerId={viewerId}\n        documents={documentsForChat}\n        folders={folders}\n      >\n      <DataroomNav\n        brand={brand}\n        linkId={linkId}\n        viewId={viewId}\n        dataroom={dataroom}\n        allowDownload={allDocumentsCanDownload}\n        allowBulkDownload={allowBulkDownload}\n        isPreview={isPreview}\n        dataroomId={dataroom?.id}\n        viewerId={viewerId}\n        viewerEmail={viewerEmail}\n        conversationsEnabled={viewData.conversationsEnabled}\n        isTeamMember={viewData.isTeamMember}\n      />\n      <ViewerSurfaceThemeProvider value={viewerSurfaceTheme}>\n        <ViewerChatLayout>\n          <div\n            className=\"relative flex flex-1 items-center overflow-hidden bg-white dark:bg-black\"\n            style={\n              viewerSurfaceTheme.palette.backgroundColor\n                ? { backgroundColor: viewerSurfaceTheme.palette.backgroundColor }\n                : undefined\n            }\n          >\n            <div\n              className=\"relative mx-auto flex h-full w-full items-start justify-center\"\n              style={\n                {\n                  \"--viewer-text\": viewerSurfaceTheme.palette.textColor,\n                  \"--viewer-muted-text\": viewerSurfaceTheme.palette.mutedTextColor,\n                  \"--viewer-subtle-text\":\n                    viewerSurfaceTheme.palette.subtleTextColor,\n                  \"--viewer-panel-bg\": viewerSurfaceTheme.palette.panelBgColor,\n                  \"--viewer-panel-bg-hover\":\n                    viewerSurfaceTheme.palette.panelHoverBgColor,\n                  \"--viewer-panel-border\":\n                    viewerSurfaceTheme.palette.panelBorderColor,\n                  \"--viewer-panel-border-hover\":\n                    viewerSurfaceTheme.palette.panelBorderHoverColor,\n                  \"--viewer-control-bg\": viewerSurfaceTheme.palette.controlBgColor,\n                  \"--viewer-control-border\":\n                    viewerSurfaceTheme.palette.controlBorderColor,\n                  \"--viewer-control-border-strong\":\n                    viewerSurfaceTheme.palette.controlBorderStrongColor,\n                  \"--viewer-control-icon\":\n                    viewerSurfaceTheme.palette.controlIconColor,\n                  \"--viewer-placeholder\":\n                    viewerSurfaceTheme.palette.controlPlaceholderColor,\n                } as React.CSSProperties\n              }\n            >\n            {/* Tree view */}\n            <div\n              className=\"hidden h-full w-1/4 space-y-8 overflow-auto px-3 pb-4 pt-4 md:flex md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14\"\n            >\n              <ScrollArea showScrollbar className=\"w-full\">\n                <ViewFolderTree\n                  folders={folders}\n                  documents={documents}\n                  setFolderId={setFolderId}\n                  folderId={folderId}\n                  dataroomIndexEnabled={dataroomIndexEnabled}\n                />\n                <ScrollBar orientation=\"horizontal\" />\n                <ScrollBar orientation=\"vertical\" />\n              </ScrollArea>\n            </div>\n\n            {/* Detail view */}\n            <ScrollArea\n              showScrollbar\n              className=\"h-full flex-grow overflow-auto\"\n            >\n              <div\n                className=\"h-full px-3 pb-4 pt-4 md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14\"\n              >\n                <div className=\"flex items-center gap-x-2\">\n                  {/* sidebar for mobile */}\n                  <div className=\"flex md:hidden\">\n                    <Sheet>\n                      <SheetTrigger asChild>\n                        <button className={cn(\n                          \"lg:hidden\",\n                          \"text-[var(--viewer-subtle-text)]\",\n                        )}>\n                          <PanelLeftIcon\n                            className=\"h-5 w-5\"\n                            aria-hidden=\"true\"\n                          />\n                        </button>\n                      </SheetTrigger>\n                      <SheetPortal>\n                        <SheetOverlay className=\"fixed top-[35dvh] z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\" />\n                        <SheetPrimitive.Content\n                          className={cn(\n                            \"fixed top-[35dvh] z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out\",\n                            \"left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-lg\",\n                            \"m-0 w-[280px] p-0 sm:w-[300px] lg:hidden\",\n                          )}\n                        >\n                          <div className=\"mt-8 h-full space-y-8 overflow-auto px-2 py-3\">\n                            <ViewerSurfaceThemeProvider value={mobileTreeTheme}>\n                              <ViewFolderTree\n                                folders={folders}\n                                documents={documents}\n                                setFolderId={setFolderId}\n                                folderId={folderId}\n                                dataroomIndexEnabled={dataroomIndexEnabled}\n                              />\n                            </ViewerSurfaceThemeProvider>\n                          </div>\n                          <SheetPrimitive.Close className=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary\">\n                            <XIcon className=\"h-4 w-4\" />\n                            <span className=\"sr-only\">Close</span>\n                          </SheetPrimitive.Close>\n                        </SheetPrimitive.Content>\n                      </SheetPortal>\n                    </Sheet>\n                  </div>\n\n                  <div className=\"flex flex-1 items-center justify-between gap-x-2\">\n                    <Breadcrumb>\n                      <BreadcrumbList className=\"text-[var(--viewer-muted-text)]\">\n                        <BreadcrumbItem key={\"root\"}>\n                          <BreadcrumbLink\n                            onClick={() => setFolderId(null)}\n                            className=\"cursor-pointer text-[var(--viewer-muted-text)] hover:text-[var(--viewer-text)]\"\n                          >\n                            Home\n                          </BreadcrumbLink>\n                        </BreadcrumbItem>\n\n                        {breadcrumbFolders.map((folder, index) => (\n                          <React.Fragment key={folder.id}>\n                            <BreadcrumbSeparator\n                              className=\"text-[var(--viewer-subtle-text)]\"\n                            />\n                            <BreadcrumbItem>\n                              <ViewerBreadcrumbItem\n                                folder={folder}\n                                setFolderId={setFolderId}\n                                isLast={index === breadcrumbFolders.length - 1}\n                                dataroomIndexEnabled={dataroomIndexEnabled}\n                              />\n                            </BreadcrumbItem>\n                          </React.Fragment>\n                        ))}\n                      </BreadcrumbList>\n                    </Breadcrumb>\n\n                    <div className=\"flex items-center gap-x-2\">\n                      <IntroductionInfoButton />\n                      <SearchBoxPersisted\n                        inputClassName=\"h-9 border-[var(--viewer-control-border)] bg-[var(--viewer-control-bg)] text-[var(--viewer-text)] placeholder:text-[var(--viewer-placeholder)] shadow-sm hover:border-[var(--viewer-control-border-strong)] focus:border-[var(--viewer-control-border-strong)]\"\n                        leftIconClassName=\"text-[var(--viewer-control-icon)]\"\n                        clearIconClassName=\"text-[var(--viewer-control-icon)] hover:text-[var(--viewer-text)]\"\n                      />\n                      {enableIndexFile && viewId && viewerId && (\n                        <IndexFileDialog\n                          linkId={linkId}\n                          viewId={viewId}\n                          dataroomId={dataroom?.id}\n                          viewerId={viewerId}\n                          viewerEmail={viewerEmail}\n                        />\n                      )}\n\n                      {viewData?.enableVisitorUpload && viewerId && (\n                        <DocumentUploadModal\n                          linkId={linkId}\n                          dataroomId={dataroom?.id}\n                          viewerId={viewerId}\n                          folderId={folderId ?? undefined}\n                          folderName={\n                            folderId\n                              ? folders.find((f) => f.id === folderId)?.name\n                              : undefined\n                          }\n                        />\n                      )}\n                    </div>\n                  </div>\n                </div>\n\n                {/* Tabs: Documents / My Uploads */}\n                {viewData?.enableVisitorUpload && hasUploads && (\n                  <div\n                    className=\"mt-4 flex items-center gap-1 border-b\"\n                    style={{ borderColor: viewerSurfaceTheme.palette.panelBorderColor }}\n                  >\n                    <button\n                      onClick={() => setActiveTab(\"documents\")}\n                      className={cn(\n                        \"-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors\",\n                        activeTab === \"documents\"\n                          ? \"border-[var(--viewer-text)] text-[var(--viewer-text)]\"\n                          : \"border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]\",\n                      )}\n                    >\n                      Documents\n                    </button>\n                    <button\n                      onClick={() => setActiveTab(\"my-uploads\")}\n                      className={cn(\n                        \"-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors\",\n                        activeTab === \"my-uploads\"\n                          ? \"border-[var(--viewer-text)] text-[var(--viewer-text)]\"\n                          : \"border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]\",\n                      )}\n                    >\n                      <UploadIcon className=\"h-3.5 w-3.5\" />\n                      My Uploads\n                      <span\n                        className=\"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-medium\"\n                        style={{\n                          backgroundColor: viewerSurfaceTheme.palette.controlBgColor,\n                          color: viewerSurfaceTheme.palette.mutedTextColor,\n                        }}\n                      >\n                        {allUploads.length}\n                      </span>\n                    </button>\n                  </div>\n                )}\n\n                {/* Search results banner */}\n                {searchQuery && activeTab === \"documents\" && (\n                  <div className=\"mt-4 rounded-md border border-[var(--viewer-panel-border)] bg-[var(--viewer-control-bg)] px-4 py-3\">\n                    <div className=\"flex items-center gap-2\">\n                      <div className=\"text-sm font-medium text-[var(--viewer-muted-text)]\">\n                        Search results for &quot;{searchQuery}&quot;\n                      </div>\n                      <div className=\"text-xs text-[var(--viewer-subtle-text)]\">\n                        ({mixedItems.length} result\n                        {mixedItems.length !== 1 ? \"s\" : \"\"} across all folders)\n                      </div>\n                    </div>\n                  </div>\n                )}\n\n                {activeTab === \"my-uploads\" ? (\n                  /* My Uploads tab - show all uploads across all folders */\n                  <ul\n                    role=\"list\"\n                    className=\"-mx-4 space-y-4 overflow-auto p-4\"\n                  >\n                    {allUploads.length === 0 ? (\n                      <li className=\"py-6 text-center text-[var(--viewer-subtle-text)]\">\n                        No uploads yet. Upload documents using the &quot;Add\n                        Document&quot; button.\n                      </li>\n                    ) : (\n                      allUploads.map((pendingUpload) => (\n                        <li key={pendingUpload.id}>\n                          <PendingDocumentCard\n                            pendingUpload={pendingUpload}\n                            folders={folders}\n                            linkId={linkId}\n                            showFolderPath\n                            onNavigateToFolder={(id) => {\n                              setFolderId(id);\n                              setActiveTab(\"documents\");\n                            }}\n                          />\n                        </li>\n                      ))\n                    )}\n                  </ul>\n                ) : (\n                  /* Documents tab - normal folder view */\n                  <ul\n                    role=\"list\"\n                    className=\"-mx-4 space-y-4 overflow-auto p-4\"\n                  >\n                    {!searchQuery &&\n                      filteredPendingUploads.map((pendingUpload) => (\n                        <li key={pendingUpload.id}>\n                          <PendingDocumentCard\n                            pendingUpload={pendingUpload}\n                            linkId={linkId}\n                          />\n                        </li>\n                      ))}\n\n                    {mixedItems.length === 0 &&\n                    filteredPendingUploads.length === 0 ? (\n                      <li className=\"py-6 text-center text-[var(--viewer-subtle-text)]\">\n                        {searchQuery\n                          ? \"No documents match your search.\"\n                          : \"No items available.\"}\n                      </li>\n                    ) : (\n                      mixedItems.map((item) => (\n                        <li key={item.id}>{renderItem(item)}</li>\n                      ))\n                    )}\n                  </ul>\n                )}\n              </div>\n              <ScrollBar orientation=\"vertical\" />\n              <ScrollBar orientation=\"horizontal\" />\n            </ScrollArea>\n          </div>\n          </div>\n        </ViewerChatLayout>\n      </ViewerSurfaceThemeProvider>\n\n      {/* AI Chat Components */}\n      <ViewerChatPanel />\n      <ViewerChatToggle />\n      </ViewerChatProvider>\n    </IntroductionProvider>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/download-only-viewer.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useRef } from \"react\";\n\nimport { Download } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { ScreenProtector } from \"../ScreenProtection\";\nimport Nav, { TNavData } from \"../nav\";\nimport { PoweredBy } from \"../powered-by\";\nimport { AwayPoster } from \"./away-poster\";\n\nimport \"@/styles/custom-viewer-styles.css\";\n\nexport default function DownloadOnlyViewer({\n  versionNumber,\n  documentName,\n  navData,\n}: {\n  versionNumber: number;\n  documentName?: string;\n  navData: TNavData;\n}) {\n  const router = useRouter();\n  const startTimeRef = useRef(Date.now());\n  const visibilityRef = useRef<boolean>(true);\n\n  const trackingOptions = getTrackingOptions();\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...trackingOptions,\n    externalStartTimeRef: startTimeRef,\n  });\n\n  const { linkId, documentId, viewId, isPreview, allowDownload, dataroomId } =\n    navData;\n\n  useEffect(() => {\n    // Remove token and email query parameters on component mount\n    const removeQueryParams = (queries: string[]) => {\n      const currentQuery = { ...router.query };\n      const currentPath = router.asPath.split(\"?\")[0];\n      queries.map((query) => delete currentQuery[query]);\n\n      router.replace(\n        {\n          pathname: currentPath,\n          query: currentQuery,\n        },\n        undefined,\n        { shallow: true },\n      );\n    };\n\n    if (router.query.token) {\n      removeQueryParams([\"token\", \"email\", \"domain\", \"slug\", \"linkId\"]);\n    }\n  }, []);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n\n        // Restart interval tracking\n        const trackingData = {\n          linkId,\n          documentId,\n          viewId,\n          pageNumber: 1,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        };\n        startIntervalTracking(trackingData);\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n\n        // Track final duration using activity-aware calculation\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId,\n            documentId,\n            viewId,\n            duration,\n            pageNumber: 1,\n            versionNumber,\n            dataroomId,\n            isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      const duration = getActiveDuration();\n      trackPageViewSafely(\n        {\n          linkId,\n          documentId,\n          viewId,\n          duration,\n          pageNumber: 1,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        },\n        true,\n      );\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const trackingData = {\n      linkId,\n      documentId,\n      viewId,\n      pageNumber: 1,\n      versionNumber,\n      dataroomId,\n      isPreview,\n    };\n\n    startIntervalTracking(trackingData);\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  const downloadFile = async () => {\n    if (isPreview) {\n      toast.error(\"You cannot download documents in preview mode.\");\n      return;\n    }\n    if (!allowDownload) return;\n\n    const downloadPromise = (async () => {\n      const response = await fetch(`/api/links/download`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ linkId, viewId }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({}));\n        const errorMessage = errorData.error || \"Failed to download file\";\n        throw new Error(errorMessage);\n      }\n\n      // Check if the response is a PDF file (for watermarked PDFs)\n      const contentType = response.headers.get(\"content-type\");\n      if (contentType === \"application/pdf\") {\n        // Handle direct PDF download (watermarked PDFs)\n        const pdfBlob = await response.blob();\n        const url = URL.createObjectURL(pdfBlob);\n\n        const a = document.createElement(\"a\");\n        a.href = url;\n        a.download = `${documentName || \"document\"}.pdf`;\n        a.rel = \"noopener noreferrer\";\n        document.body.appendChild(a);\n        a.click();\n\n        // Clean up\n        setTimeout(() => {\n          URL.revokeObjectURL(url);\n          document.body.removeChild(a);\n        }, 100);\n      } else {\n        // Handle JSON response with downloadUrl (non-watermarked files)\n        const { downloadUrl } = await response.json();\n\n        const iframe = document.createElement(\"iframe\");\n        iframe.style.display = \"none\";\n        document.body.appendChild(iframe);\n        iframe.src = downloadUrl;\n\n        setTimeout(() => {\n          if (iframe.parentNode) {\n            document.body.removeChild(iframe);\n          }\n        }, 5000);\n      }\n\n      return \"File downloaded successfully\";\n    })();\n\n    toast.promise(downloadPromise, {\n      loading: \"Preparing download...\",\n      success: (message) => message,\n      error: (err) => err.message || \"Failed to download file\",\n    });\n  };\n\n  return (\n    <>\n      <Nav pageNumber={1} numPages={1} navData={navData} />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className=\"relative flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-900\"\n      >\n        <div className=\"flex flex-col items-center space-y-6 p-8 text-center\">\n          <div className=\"rounded-full bg-gray-100 p-6 dark:bg-gray-800\">\n            <Download className=\"h-12 w-12 text-gray-600 dark:text-gray-300\" />\n          </div>\n          <h2 className=\"text-xl font-medium text-gray-900 dark:text-gray-100\">\n            {documentName || \"Download Document\"}\n          </h2>\n          <p className=\"text-sm text-gray-500 dark:text-gray-400\">\n            This document is available for download only\n          </p>\n          {allowDownload && (\n            <Button onClick={downloadFile} className=\"w-full space-x-2\">\n              <Download className=\"h-4 w-4\" />\n              <span>Download Now</span>\n            </Button>\n          )}\n        </div>\n      </div>\n      <AwayPoster\n        isVisible={isInactive}\n        inactivityThreshold={trackingOptions.inactivityThreshold}\n        onDismiss={updateActivity}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/excel-viewer.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport React from \"react\";\n\nimport \"@/public/vendor/handsontable/handsontable.full.min.css\";\nimport { Brand, DataroomBrand } from \"@prisma/client\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { ScreenProtector } from \"../ScreenProtection\";\nimport { TDocumentData } from \"../dataroom/dataroom-view\";\nimport Nav, { TNavData } from \"../nav\";\nimport { AwayPoster } from \"./away-poster\";\n\n// Define the type for the JSON data\ntype RowData = { [key: string]: any };\ntype SheetData = {\n  sheetName: string;\n  columnData: string[];\n  rowData: RowData[];\n};\n\nexport default function ExcelViewer({\n  versionNumber,\n  sheetData,\n  screenshotProtectionEnabled,\n  navData,\n}: {\n  versionNumber: number;\n  sheetData: SheetData[];\n  screenshotProtectionEnabled: boolean;\n  navData: TNavData;\n}) {\n  const [availableWidth, setAvailableWidth] = useState<number>(200);\n  const [availableHeight, setAvailableHeight] = useState<number>(200);\n  const [handsontableLoaded, setHandsontableLoaded] = useState<boolean>(false);\n  const [selectedSheetIndex, setSelectedSheetIndex] = useState<number>(0);\n\n  const [isWindowFocused, setIsWindowFocused] = useState(true);\n\n  const { linkId, documentId, viewId, isPreview, dataroomId } = navData;\n\n  const startTimeRef = useRef(Date.now());\n  const visibilityRef = useRef<boolean>(true);\n\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...getTrackingOptions(),\n    externalStartTimeRef: startTimeRef,\n  });\n\n  useEffect(() => {\n    const script = document.createElement(\"script\");\n    script.src =\n      \"https://cdnjs.cloudflare.com/ajax/libs/handsontable/6.2.2/handsontable.full.min.js\";\n    script.async = true;\n    script.onload = () => {\n      setHandsontableLoaded(true);\n    };\n    script.onerror = () => {\n      console.error(\"Failed to load Handsontable script.\");\n    };\n\n    document.body.appendChild(script);\n\n    return () => {\n      document.body.removeChild(script);\n    };\n  }, []);\n\n  const hotRef = useRef<HTMLDivElement>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  // @ts-ignore - Handsontable import has not types\n  const hotInstanceRef = useRef<Handsontable | null>(null);\n\n  const calculateSize = () => {\n    if (containerRef.current) {\n      const offset = containerRef.current.getBoundingClientRect();\n      setAvailableWidth(Math.max(offset.width, 200));\n      setAvailableHeight(Math.max(offset.height - 50, 200));\n    }\n  };\n\n  useEffect(() => {\n    const handleResize = () => {\n      calculateSize();\n    };\n\n    window.addEventListener(\"resize\", handleResize);\n    calculateSize();\n\n    return () => window.removeEventListener(\"resize\", handleResize);\n  }, []);\n\n  // Add this effect near your other useEffect hooks\n  useEffect(() => {\n    if (!screenshotProtectionEnabled) return;\n\n    const handleFocus = () => setIsWindowFocused(true);\n    const handleBlur = () => setIsWindowFocused(false);\n\n    window.addEventListener(\"focus\", handleFocus);\n    window.addEventListener(\"blur\", handleBlur);\n\n    return () => {\n      window.removeEventListener(\"focus\", handleFocus);\n      window.removeEventListener(\"blur\", handleBlur);\n    };\n  }, [screenshotProtectionEnabled]);\n\n  // Start interval tracking when component mounts\n  useEffect(() => {\n    const trackingData = {\n      linkId,\n      documentId,\n      viewId,\n      pageNumber: selectedSheetIndex + 1,\n      versionNumber,\n      dataroomId,\n      isPreview,\n    };\n\n    startIntervalTracking(trackingData);\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n\n        // Restart interval tracking\n        const trackingData = {\n          linkId,\n          documentId,\n          viewId,\n          pageNumber: selectedSheetIndex + 1,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        };\n        startIntervalTracking(trackingData);\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n\n        // Track final duration using activity-aware calculation\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId,\n            documentId,\n            viewId,\n            duration,\n            pageNumber: selectedSheetIndex + 1,\n            versionNumber,\n            dataroomId,\n            isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    selectedSheetIndex,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      const duration = getActiveDuration();\n      trackPageViewSafely(\n        {\n          linkId,\n          documentId,\n          viewId,\n          duration,\n          pageNumber: selectedSheetIndex + 1,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        },\n        true,\n      );\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    selectedSheetIndex,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    if (handsontableLoaded && sheetData.length) {\n      if (hotInstanceRef.current) {\n        hotInstanceRef.current.destroy();\n      }\n\n      const { columnData, rowData } = sheetData[selectedSheetIndex];\n\n      // @ts-ignore - Handsontable import has not types\n      hotInstanceRef.current = new Handsontable(hotRef.current!, {\n        data: rowData,\n        readOnly: true,\n        disableVisualSelection: true,\n        comments: false,\n        contextMenu: false,\n        colHeaders: columnData,\n        rowHeaders: true,\n        manualColumnResize: true,\n        width: availableWidth,\n        height: availableHeight,\n        rowHeights: 23,\n        stretchH: \"none\",\n        viewportRowRenderingOffset: 10,\n        viewportColumnRenderingOffset: 2,\n        readOnlyCellClassName: \"\",\n        // modifyColWidth: (width: number) => {\n        //   if (width > 300) {\n        //     return 300;\n        //   }\n        // },\n      });\n    }\n  }, [\n    handsontableLoaded,\n    sheetData,\n    selectedSheetIndex,\n    availableHeight,\n    availableWidth,\n  ]);\n\n  return (\n    <>\n      <Nav type=\"sheet\" navData={navData} />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className={cn(\n          \"mx-2 flex h-dvh flex-col sm:mx-6 lg:mx-8\",\n          !isWindowFocused &&\n            screenshotProtectionEnabled &&\n            \"blur-xl transition-all duration-300\",\n        )}\n        ref={containerRef}\n      >\n        <div className=\"\" ref={hotRef}></div>\n        <div className=\"flex max-w-fit divide-x divide-gray-200 overflow-x-scroll whitespace-nowrap rounded-b-sm bg-[#f0f0f0] px-1\">\n          {sheetData.map((sheet, index) => (\n            <div className=\"px-1\" key={sheet.sheetName}>\n              <Button\n                onClick={() => setSelectedSheetIndex(index)}\n                className={cn(\n                  \"mb-1 rounded-none rounded-b-sm bg-[#f0f0f0] font-normal text-gray-950 hover:bg-gray-50\",\n                  index === selectedSheetIndex &&\n                    \"bg-white font-medium text-black ring-1 ring-gray-500 hover:bg-white\",\n                )}\n              >\n                {sheet.sheetName}\n              </Button>\n            </div>\n          ))}\n        </div>\n        {screenshotProtectionEnabled ? <ScreenProtector /> : null}\n        <AwayPoster\n          isVisible={isInactive}\n          inactivityThreshold={\n            getTrackingOptions().inactivityThreshold || 20000\n          }\n          onDismiss={updateActivity}\n        />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/image-viewer.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport React from \"react\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\nimport { WatermarkConfig } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport { ScreenProtector } from \"../ScreenProtection\";\nimport Nav, { TNavData } from \"../nav\";\nimport { PoweredBy } from \"../powered-by\";\nimport { SVGWatermark } from \"../watermark-svg\";\nimport { AwayPoster } from \"./away-poster\";\n\nimport \"@/styles/custom-viewer-styles.css\";\n\nexport default function ImageViewer({\n  file,\n  screenshotProtectionEnabled,\n  versionNumber,\n  showPoweredByBanner,\n  viewerEmail,\n  watermarkConfig,\n  ipAddress,\n  linkName,\n  navData,\n}: {\n  file: string;\n  screenshotProtectionEnabled: boolean;\n  versionNumber: number;\n  showPoweredByBanner?: boolean;\n  viewerEmail?: string;\n  watermarkConfig?: WatermarkConfig | null;\n  ipAddress?: string;\n  linkName?: string;\n  navData: TNavData;\n}) {\n  const router = useRouter();\n\n  const { isPreview, linkId, documentId, viewId, dataroomId } = navData;\n\n  const numPages = 1;\n  const pageNumber = 1;\n\n  const [scale, setScale] = useState<number>(1);\n  const [isWindowFocused, setIsWindowFocused] = useState(true);\n\n  const startTimeRef = useRef(Date.now());\n  const visibilityRef = useRef<boolean>(true);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const imageRefs = useRef<HTMLImageElement | null>(null);\n\n  const trackingOptions = getTrackingOptions();\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...trackingOptions,\n    externalStartTimeRef: startTimeRef,\n  });\n\n  const [imageDimensions, setImageDimensions] = useState<{\n    width: number;\n    height: number;\n  } | null>(null);\n\n  // Add zoom handlers\n  const handleZoomIn = () => {\n    setScale((prev) => Math.min(prev + 0.25, 3)); // Max zoom 3x\n  };\n\n  const handleZoomOut = () => {\n    setScale((prev) => Math.max(prev - 0.25, 0.5)); // Min zoom 0.5x\n  };\n\n  // Add fullscreen handler\n  const handleFullscreen = () => {\n    if (!document.fullscreenElement) {\n      document.documentElement.requestFullscreen().catch((err) => {\n        console.error(\"Error attempting to enable fullscreen:\", err);\n      });\n    } else {\n      document.exitFullscreen();\n    }\n  };\n\n  // Add keyboard shortcuts for zooming and fullscreen\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Don't trigger shortcuts when typing in inputs or textareas\n      const target = e.target as HTMLElement;\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.isContentEditable\n      ) {\n        return;\n      }\n\n      if (e.metaKey || e.ctrlKey) {\n        if (e.key === \"=\" || e.key === \"+\") {\n          e.preventDefault();\n          handleZoomIn();\n        } else if (e.key === \"-\") {\n          e.preventDefault();\n          handleZoomOut();\n        } else if (e.key === \"0\") {\n          e.preventDefault();\n          setScale(1);\n        }\n      } else if (e.key === \"f\" || e.key === \"F\") {\n        e.preventDefault();\n        handleFullscreen();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [containerRef.current, imageDimensions]);\n\n  useEffect(() => {\n    const updateImageDimensions = () => {\n      let newDimensions: { width: number; height: number } | null = null;\n\n      if (imageRefs.current) {\n        newDimensions = {\n          width: imageRefs.current.clientWidth,\n          height: imageRefs.current.clientHeight,\n        };\n      }\n      setImageDimensions(newDimensions);\n    };\n\n    updateImageDimensions();\n    window.addEventListener(\"resize\", updateImageDimensions);\n\n    return () => {\n      window.removeEventListener(\"resize\", updateImageDimensions);\n    };\n  }, [scale]);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n        const trackingData = {\n          linkId,\n          documentId,\n          viewId,\n          pageNumber: pageNumber,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        };\n        startIntervalTracking(trackingData);\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId,\n            documentId,\n            viewId,\n            duration,\n            pageNumber: pageNumber,\n            versionNumber,\n            dataroomId,\n            isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      const duration = getActiveDuration();\n      trackPageViewSafely(\n        {\n          linkId,\n          documentId,\n          viewId,\n          duration,\n          pageNumber: pageNumber,\n          versionNumber,\n          dataroomId,\n          isPreview,\n        },\n        true,\n      );\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  // Add this effect near your other useEffect hooks\n  useEffect(() => {\n    if (!screenshotProtectionEnabled) return;\n\n    const handleFocus = () => setIsWindowFocused(true);\n    const handleBlur = () => setIsWindowFocused(false);\n\n    window.addEventListener(\"focus\", handleFocus);\n    window.addEventListener(\"blur\", handleBlur);\n\n    return () => {\n      window.removeEventListener(\"focus\", handleFocus);\n      window.removeEventListener(\"blur\", handleBlur);\n    };\n  }, [screenshotProtectionEnabled]);\n\n  useEffect(() => {\n    // Remove token and email query parameters on component mount\n    const removeQueryParams = (queries: string[]) => {\n      const currentQuery = { ...router.query };\n      const currentPath = router.asPath.split(\"?\")[0];\n      queries.map((query) => delete currentQuery[query]);\n\n      router.replace(\n        {\n          pathname: currentPath,\n          query: currentQuery,\n        },\n        undefined,\n        { shallow: true },\n      );\n    };\n\n    if (!dataroomId && router.query.token) {\n      removeQueryParams([\"token\", \"email\", \"domain\", \"slug\", \"linkId\"]);\n    }\n  }, []); // Run once on mount\n\n  // Start interval tracking when component mounts\n  useEffect(() => {\n    const trackingData = {\n      linkId,\n      documentId,\n      viewId,\n      pageNumber: pageNumber,\n      versionNumber,\n      dataroomId,\n      isPreview,\n    };\n    startIntervalTracking(trackingData);\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  return (\n    <>\n      <Nav\n        pageNumber={pageNumber}\n        numPages={numPages}\n        hasWatermark={!!watermarkConfig}\n        handleZoomIn={handleZoomIn}\n        handleZoomOut={handleZoomOut}\n        handleFullscreen={handleFullscreen}\n        navData={navData}\n      />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className=\"relative flex items-center overflow-hidden\"\n      >\n        <div\n          className={cn(\n            \"relative h-full w-full\",\n            !isWindowFocused &&\n              screenshotProtectionEnabled &&\n              \"blur-xl transition-all duration-300\",\n          )}\n          ref={containerRef}\n        >\n          {/* Scroll Container */}\n          <div className=\"h-full w-full overflow-auto\">\n            {/* Sizer defines scrollable dimensions at current scale */}\n            <div\n              className=\"mx-auto\"\n              style={{\n                width:\n                  imageDimensions && scale > 1\n                    ? `${imageDimensions.width * scale}px`\n                    : \"100%\",\n                height:\n                  imageDimensions && scale > 1\n                    ? `${imageDimensions.height * scale}px`\n                    : \"auto\",\n              }}\n            >\n              {/* Scaled content */}\n              <div\n                style={{\n                  transition: \"transform 0.2s ease-out\",\n                  transformOrigin: \"center top\",\n                  transform: `scale(${scale})`,\n                }}\n                onContextMenu={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                }}\n              >\n                <div className=\"viewer-container relative mx-auto flex w-full justify-center\">\n                  <img\n                    className=\"viewer-image-mobile !pointer-events-auto max-h-[calc(100dvh-64px)] object-contain\"\n                    onContextMenu={(e) => {\n                      e.preventDefault();\n                      e.stopPropagation();\n                    }}\n                    ref={(ref) => {\n                      imageRefs.current = ref;\n                      if (ref) {\n                        ref.onload = () =>\n                          setImageDimensions({\n                            width: ref.clientWidth,\n                            height: ref.clientHeight,\n                          });\n                      }\n                    }}\n                    src={file}\n                    alt=\"Image 1\"\n                  />\n\n                  {watermarkConfig ? (\n                    <SVGWatermark\n                      config={watermarkConfig}\n                      viewerData={{\n                        email: viewerEmail,\n                        date: new Date().toLocaleDateString(),\n                        time: new Date().toLocaleTimeString(),\n                        link: linkName,\n                        ipAddress: ipAddress,\n                      }}\n                      documentDimensions={\n                        imageDimensions ?? { width: 0, height: 0 }\n                      }\n                      pageIndex={0}\n                    />\n                  ) : null}\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {screenshotProtectionEnabled ? <ScreenProtector /> : null}\n        {showPoweredByBanner ? <PoweredBy linkId={linkId} /> : null}\n      </div>\n      <AwayPoster\n        isVisible={isInactive}\n        inactivityThreshold={trackingOptions.inactivityThreshold || 60000}\n        onDismiss={updateActivity}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/notion-page.tsx",
    "content": "import dynamic from \"next/dynamic\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport React from \"react\";\n\nimport { Slash } from \"lucide-react\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { parsePageId } from \"notion-utils\";\nimport { useQueryState } from \"nuqs\";\nimport { NotionRenderer } from \"react-notion-x\";\n// core styles shared by all of react-notion-x (required)\nimport \"react-notion-x/src/styles.css\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\nimport { NotionTheme } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { createAdaptiveSurfacePalette } from \"@/lib/utils/create-adaptive-surface-palette\";\n\nimport {\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Portal } from \"@/components/ui/portal\";\n\nimport { ScreenProtector } from \"../../view/ScreenProtection\";\nimport Nav, { TNavData } from \"../../view/nav\";\nimport { AwayPoster } from \"./away-poster\";\n\n// custom styles for notion\nimport \"@/styles/custom-notion-styles.css\";\n\nconst Collection = dynamic(() =>\n  import(\"react-notion-x/build/third-party/collection\").then(\n    (m) => m.Collection,\n  ),\n);\n\nconst Code = dynamic(() =>\n  import(\"react-notion-x/build/third-party/code\").then((m) => m.Code),\n);\n\n// Obfuscate Notion block IDs in the DOM to hide the original Notion page IDs\nconst obfuscateNotionIds = (container: HTMLElement) => {\n  // Pattern to match Notion-style UUIDs (with or without hyphens)\n  const uuidPattern =\n    /[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}/gi;\n\n  // Create a map to consistently replace the same ID with the same obfuscated value\n  const idMap = new Map<string, string>();\n  let counter = 0;\n\n  const getObfuscatedId = (originalId: string): string => {\n    const normalizedId = originalId.toLowerCase().replace(/-/g, \"\");\n    if (!idMap.has(normalizedId)) {\n      idMap.set(normalizedId, `block-${counter++}`);\n    }\n    return idMap.get(normalizedId)!;\n  };\n\n  // Obfuscate element IDs\n  const elementsWithId = container.querySelectorAll(\"[id]\");\n  elementsWithId.forEach((el) => {\n    const id = el.getAttribute(\"id\");\n    if (id && uuidPattern.test(id)) {\n      const newId = id.replace(uuidPattern, (match) => getObfuscatedId(match));\n      el.setAttribute(\"id\", newId);\n    }\n    // Reset the pattern lastIndex\n    uuidPattern.lastIndex = 0;\n  });\n\n  // Obfuscate data-block-id attributes\n  const elementsWithBlockId = container.querySelectorAll(\"[data-block-id]\");\n  elementsWithBlockId.forEach((el) => {\n    const blockId = el.getAttribute(\"data-block-id\");\n    if (blockId && uuidPattern.test(blockId)) {\n      el.setAttribute(\"data-block-id\", getObfuscatedId(blockId));\n    }\n    uuidPattern.lastIndex = 0;\n  });\n\n  // Obfuscate data-id attributes\n  const elementsWithDataId = container.querySelectorAll(\"[data-id]\");\n  elementsWithDataId.forEach((el) => {\n    const dataId = el.getAttribute(\"data-id\");\n    if (dataId && uuidPattern.test(dataId)) {\n      el.setAttribute(\"data-id\", getObfuscatedId(dataId));\n    }\n    uuidPattern.lastIndex = 0;\n  });\n\n  // Obfuscate class names that contain Notion IDs\n  const allElements = container.querySelectorAll(\"*\");\n  allElements.forEach((el) => {\n    const classList = el.className;\n    if (typeof classList === \"string\" && uuidPattern.test(classList)) {\n      const newClassList = classList.replace(uuidPattern, (match) =>\n        getObfuscatedId(match),\n      );\n      el.className = newClassList;\n    }\n    uuidPattern.lastIndex = 0;\n  });\n\n  // Obfuscate anchor href attributes that contain Notion IDs (skip external links with target)\n  const anchors = container.querySelectorAll(\"a[href]:not([target])\");\n  anchors.forEach((anchor) => {\n    const href = anchor.getAttribute(\"href\");\n    if (href && uuidPattern.test(href)) {\n      const newHref = href.replace(uuidPattern, (match) =>\n        getObfuscatedId(match),\n      );\n      anchor.setAttribute(\"href\", newHref);\n    }\n    uuidPattern.lastIndex = 0;\n  });\n};\n\nexport const NotionPage = ({\n  recordMap,\n  versionNumber,\n  theme,\n  screenshotProtectionEnabled,\n  textSelectionEnabled,\n  navData,\n}: {\n  recordMap: ExtendedRecordMap;\n  versionNumber: number;\n  theme?: NotionTheme | null;\n  screenshotProtectionEnabled: boolean;\n  textSelectionEnabled: boolean;\n  navData: TNavData;\n}) => {\n  const { isPreview, linkId, documentId, viewId, brand } = navData;\n  const navPalette = createAdaptiveSurfacePalette(brand?.brandColor);\n  const [pageNumber, setPageNumber] = useState<number>(1); // start on first page\n  const [loading, setLoading] = useState<boolean>(false);\n  const [subPageId, setSubPageId] = useQueryState(\"pageid\", {\n    history: \"push\",\n    scroll: true,\n  });\n  const [subTitle, setSubTitle] = useState<string>(\"\");\n  const [title, setTitle] = useState<string>(\"\");\n\n  const [isWindowFocused, setIsWindowFocused] = useState(true);\n\n  const [recordMapState, setRecordMapState] =\n    useState<ExtendedRecordMap>(recordMap);\n\n  const notionContainerRef = useRef<HTMLDivElement>(null);\n\n  // Create a cache object to store fetched recordMaps\n  const recordMapCache = useRef<{ [key: string]: ExtendedRecordMap }>({});\n\n  const startTimeRef = useRef(Date.now());\n  const pageNumberRef = useRef<number>(pageNumber);\n  const visibilityRef = useRef<boolean>(true);\n  const trackingOptions = getTrackingOptions();\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...trackingOptions,\n    externalStartTimeRef: startTimeRef,\n  });\n\n  // Start interval tracking when component mounts\n  useEffect(() => {\n    const trackingData = {\n      linkId: linkId,\n      documentId: documentId,\n      viewId: viewId,\n      pageNumber: pageNumberRef.current,\n      versionNumber: versionNumber,\n      isPreview: isPreview,\n      dataroomId: navData?.dataroomId || undefined,\n    };\n\n    startIntervalTracking(trackingData);\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        startTimeRef.current = Date.now(); // Reset start time when page becomes visible\n        resetTrackingState();\n\n        // Restart interval tracking\n        const trackingData = {\n          linkId: linkId,\n          documentId: documentId,\n          viewId: viewId,\n          pageNumber: pageNumberRef.current,\n          versionNumber: versionNumber,\n          isPreview: isPreview,\n          dataroomId: navData?.dataroomId || undefined,\n        };\n        startIntervalTracking(trackingData);\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n\n        // Track final duration using activity-aware calculation\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId: linkId,\n            documentId: documentId,\n            viewId: viewId,\n            duration: duration,\n            pageNumber: pageNumberRef.current,\n            versionNumber: versionNumber,\n            isPreview: isPreview,\n            dataroomId: navData?.dataroomId || undefined,\n          },\n          true,\n        );\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    isPreview,\n    navData,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  // Add this effect near your other useEffect hooks\n  useEffect(() => {\n    if (!screenshotProtectionEnabled) return;\n\n    const handleFocus = () => setIsWindowFocused(true);\n    const handleBlur = () => setIsWindowFocused(false);\n\n    window.addEventListener(\"focus\", handleFocus);\n    window.addEventListener(\"blur\", handleBlur);\n\n    return () => {\n      window.removeEventListener(\"focus\", handleFocus);\n      window.removeEventListener(\"blur\", handleBlur);\n    };\n  }, [screenshotProtectionEnabled]);\n\n  const fetchSubPage = useCallback(\n    async (pageId: string | null) => {\n      if (pageId) {\n        if (recordMapCache.current[pageId]) {\n          const currentRecordMap = recordMapCache.current[pageId];\n          setRecordMapState(currentRecordMap);\n          const firstBlockId = Object.keys(currentRecordMap.block)[0];\n          const firstBlock = currentRecordMap.block[firstBlockId];\n          setSubTitle(\n            firstBlock?.value?.properties?.title?.[0]?.[0] || \"Untitled\",\n          );\n          window.scrollTo({ top: 0, behavior: \"smooth\" });\n          return;\n        }\n\n        setLoading(true);\n        try {\n          const response = await fetch(\"/api/file/notion\", {\n            method: \"POST\",\n            body: JSON.stringify({ pageId }),\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n          });\n          const newRecordMap = await response.json();\n          recordMapCache.current[pageId] = newRecordMap;\n          setRecordMapState(newRecordMap);\n          const firstBlockId = Object.keys(newRecordMap.block)[0];\n          const firstBlock = newRecordMap.block[firstBlockId];\n          setSubTitle(\n            firstBlock?.value?.properties?.title?.[0]?.[0] || \"Untitled\",\n          );\n          window.scrollTo({ top: 0, behavior: \"smooth\" });\n        } catch (error) {\n          console.error(\"Error fetching subpage:\", error);\n        } finally {\n          setLoading(false);\n        }\n      } else {\n        setRecordMapState(recordMap);\n        const firstBlockId = Object.keys(recordMap.block)[0];\n        const firstBlock = recordMap.block[firstBlockId];\n        setTitle(firstBlock?.value?.properties?.title?.[0]?.[0] || \"Untitled\");\n        window.scrollTo({ top: 0, behavior: \"smooth\" });\n      }\n    },\n    [recordMap],\n  );\n\n  useEffect(() => {\n    fetchSubPage(subPageId);\n  }, [subPageId, fetchSubPage]);\n\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      const duration = getActiveDuration();\n      trackPageViewSafely(\n        {\n          linkId: linkId,\n          documentId: documentId,\n          viewId: viewId,\n          duration: duration,\n          pageNumber: pageNumberRef.current,\n          versionNumber: versionNumber,\n          isPreview: isPreview,\n          dataroomId: navData?.dataroomId || undefined,\n        },\n        true,\n      );\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  // // Function to calculate scroll percentage\n  // const calculateScrollPercentage = () => {\n  //   const scrollableHeight =\n  //     document.documentElement.scrollHeight - window.innerHeight;\n  //   const currentScrollPosition = window.scrollY;\n  //   return (currentScrollPosition / scrollableHeight) * 100;\n  // };\n\n  // // Function to handle scroll events\n  // const handleScroll = () => {\n  //   const scrollPercent = calculateScrollPercentage();\n  //   setMaxScrollPercentage((prevMax) => Math.max(prevMax, scrollPercent));\n\n  //   const data = {\n  //     x: window.scrollX,\n  //     y: window.scrollY,\n  //     scrollPercentage: scrollPercent,\n  //     type: \"scroll\",\n  //   };\n\n  //   // TODO: Store data for later use with heatmap.js\n  // };\n\n  // useEffect(() => {\n  //   // Add scroll event listener\n  //   window.addEventListener(\"scroll\", handleScroll);\n\n  //   // Remove event listener on cleanup\n  //   return () => {\n  //     window.removeEventListener(\"scroll\", handleScroll);\n  //   };\n  // }, [maxScrollPercentage]);\n\n  // Add a function to handle smooth scrolling to elements\n  const scrollToHashElement = useCallback(() => {\n    const hash = window.location.hash;\n    if (hash) {\n      // Remove the # from the hash\n      const elementId = hash.slice(1);\n\n      // Create observer to watch for position changes\n      const observer = new MutationObserver((mutations, obs) => {\n        const element = document.getElementById(elementId);\n        if (element) {\n          // Get current position\n          const rect = element.getBoundingClientRect();\n          const absoluteTop = window.scrollY + rect.top; // Account for header\n\n          window.scrollTo({\n            top: absoluteTop,\n            behavior: \"smooth\",\n          });\n        }\n      });\n\n      // Start observing the document with the configured parameters\n      observer.observe(document.body, {\n        childList: true,\n        subtree: true,\n        attributes: true,\n        characterData: true,\n      });\n\n      // Always observe for at least 2 seconds to catch any layout shifts\n      setTimeout(() => {\n        const element = document.getElementById(elementId);\n        if (element) {\n          const rect = element.getBoundingClientRect();\n          const absoluteTop = window.scrollY + rect.top;\n          window.scrollTo({\n            top: absoluteTop,\n            behavior: \"smooth\",\n          });\n        }\n        observer.disconnect();\n      }, 2000);\n    }\n  }, []);\n\n  // Handle initial load and hash changes\n  useEffect(() => {\n    scrollToHashElement();\n\n    window.addEventListener(\"hashchange\", scrollToHashElement);\n    return () => {\n      window.removeEventListener(\"hashchange\", scrollToHashElement);\n    };\n  }, [scrollToHashElement]);\n\n  const PageLinkComponent = useMemo(\n    () =>\n      function PageLink({\n        href,\n        className,\n        children,\n        style,\n      }: {\n        href?: string;\n        className?: string;\n        children?: React.ReactNode;\n        style?: React.CSSProperties;\n        [key: string]: any;\n      }) {\n        const handleClick = (e: React.MouseEvent) => {\n          e.preventDefault();\n          e.stopPropagation();\n          if (!href) return;\n\n          const pageId =\n            parsePageId(href, { uuid: false }) ??\n            href.split(\"/\").pop()?.split(\"?\")[0]?.split(\"#\")[0];\n\n          if (pageId) {\n            setSubPageId(pageId);\n          }\n        };\n\n        return (\n          <a\n            className={className}\n            style={style}\n            href={href}\n            onClick={handleClick}\n          >\n            {children}\n          </a>\n        );\n      },\n    [setSubPageId],\n  );\n\n  const notionComponents = useMemo(\n    () => ({\n      Collection,\n      Code,\n      PageLink: PageLinkComponent,\n    }),\n    [PageLinkComponent],\n  );\n\n  // Obfuscate Notion IDs in the DOM after rendering\n  useEffect(() => {\n    if (!notionContainerRef.current) return;\n\n    const timeoutId = setTimeout(() => {\n      if (notionContainerRef.current) {\n        obfuscateNotionIds(notionContainerRef.current);\n      }\n    }, 100);\n\n    let debounceTimer: ReturnType<typeof setTimeout>;\n    const observer = new MutationObserver(() => {\n      clearTimeout(debounceTimer);\n      debounceTimer = setTimeout(() => {\n        if (notionContainerRef.current) {\n          obfuscateNotionIds(notionContainerRef.current);\n        }\n      }, 50);\n    });\n\n    observer.observe(notionContainerRef.current, {\n      childList: true,\n      subtree: true,\n      attributes: false,\n    });\n\n    return () => {\n      clearTimeout(timeoutId);\n      clearTimeout(debounceTimer);\n      observer.disconnect();\n    };\n  }, [recordMapState]);\n\n  if (!recordMap) {\n    return null;\n  }\n\n  return (\n    <div className=\"bg-white\">\n      <Nav type=\"notion\" navData={navData} />\n\n      <Portal\n        containerId=\"view-breadcrump-portal\"\n        className=\"flex items-center gap-1.5\"\n      >\n        <>\n          <BreadcrumbItem>\n            <BreadcrumbLink\n              className=\"cursor-pointer underline underline-offset-4\"\n              onClick={() => setSubPageId(null)}\n              style={{\n                color: navPalette.textColor,\n              }}\n            >\n              {title}\n            </BreadcrumbLink>\n          </BreadcrumbItem>\n          {subPageId ? (\n            <>\n              <BreadcrumbSeparator>\n                <Slash />\n              </BreadcrumbSeparator>\n              <BreadcrumbItem>\n                <BreadcrumbPage\n                  className=\"font-medium\"\n                  style={{\n                    color: navPalette.textColor,\n                  }}\n                >\n                  {subTitle}\n                </BreadcrumbPage>\n              </BreadcrumbItem>\n            </>\n          ) : null}\n        </>\n      </Portal>\n\n      {loading && <div>Loading...</div>}\n\n      <div\n        ref={notionContainerRef}\n        className={cn(\n          !isWindowFocused &&\n            screenshotProtectionEnabled &&\n            \"blur-xl transition-all duration-300\",\n          textSelectionEnabled && \"notion-text-selection-enabled\",\n        )}\n      >\n        <NotionRenderer\n          recordMap={recordMapState}\n          fullPage={true}\n          darkMode={theme ? theme === \"dark\" : false}\n          disableHeader={true}\n          components={notionComponents}\n        />\n      </div>\n      {screenshotProtectionEnabled ? <ScreenProtector /> : null}\n      <AwayPoster\n        isVisible={isInactive}\n        inactivityThreshold={getTrackingOptions().inactivityThreshold}\n        onDismiss={updateActivity}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/view/viewer/pages-horizontal-viewer.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport React from \"react\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport { useSession } from \"next-auth/react\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\nimport { WatermarkConfig } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport { ResizablePanel, ResizablePanelGroup } from \"@/components/ui/resizable\";\n\nimport { ScreenProtector } from \"../ScreenProtection\";\nimport Nav, { TNavData } from \"../nav\";\nimport { PoweredBy } from \"../powered-by\";\nimport Question from \"../question\";\nimport Toolbar from \"../toolbar\";\nimport ViewDurationSummary from \"../visitor-graph\";\nimport { SVGWatermark } from \"../watermark-svg\";\nimport { AwayPoster } from \"./away-poster\";\n\nimport \"@/styles/custom-viewer-styles.css\";\n\nconst scaleCoordinates = (coords: string, scaleFactor: number) => {\n  return coords\n    .split(\",\")\n    .map((coord) => parseFloat(coord) * scaleFactor)\n    .join(\",\");\n};\n\nexport default function PagesHorizontalViewer({\n  pages,\n  feedbackEnabled,\n  screenshotProtectionEnabled,\n  versionNumber,\n  showPoweredByBanner,\n  showAccountCreationSlide,\n  enableQuestion = false,\n  feedback,\n  viewerEmail,\n  watermarkConfig,\n  ipAddress,\n  linkName,\n  navData,\n  ensurePagesLoaded,\n}: {\n  pages: {\n    file: string | null;\n    pageNumber: string;\n    embeddedLinks: string[];\n    pageLinks: {\n      href: string;\n      coords: string;\n      isInternal?: boolean;\n      targetPage?: number;\n    }[];\n    metadata: { width: number; height: number; scaleFactor: number };\n  }[];\n  feedbackEnabled: boolean;\n  screenshotProtectionEnabled: boolean;\n  versionNumber: number;\n  showPoweredByBanner?: boolean;\n  showAccountCreationSlide?: boolean;\n  enableQuestion?: boolean | null;\n  feedback?: {\n    id: string;\n    data: { question: string; type: string };\n  } | null;\n  viewerEmail?: string;\n  watermarkConfig?: WatermarkConfig | null;\n  ipAddress?: string;\n  linkName?: string;\n  navData: TNavData;\n  ensurePagesLoaded?: (currentPage: number) => void;\n}) {\n  const { isMobile, isPreview, linkId, documentId, viewId, dataroomId, brand } =\n    navData;\n\n  const router = useRouter();\n  const { status: sessionStatus } = useSession();\n\n  const showStatsSlideWithAccountCreation =\n    showAccountCreationSlide && // if showAccountCreationSlide is enabled\n    sessionStatus !== \"authenticated\" && // and user is not authenticated\n    !dataroomId; // and it's not a dataroom\n\n  const numPages = pages.length;\n  const numPagesWithFeedback =\n    enableQuestion && feedback ? numPages + 1 : numPages;\n\n  const numPagesWithAccountCreation = showStatsSlideWithAccountCreation\n    ? numPagesWithFeedback + 1\n    : numPagesWithFeedback;\n\n  const pageQuery = router.query.p ? Number(router.query.p) : 1;\n\n  const [pageNumber, setPageNumber] = useState<number>(() =>\n    pageQuery >= 1 && pageQuery <= numPages ? pageQuery : 1,\n  ); // start on first page\n\n  const [submittedFeedback, setSubmittedFeedback] = useState<boolean>(false);\n  const [accountCreated, setAccountCreated] = useState<boolean>(false);\n  const [scale, setScale] = useState<number>(1);\n\n  const [viewedPages, setViewedPages] = useState<\n    { pageNumber: number; duration: number }[]\n  >(() =>\n    Array.from({ length: numPages }, (_, index) => ({\n      pageNumber: index + 1,\n      duration: 0,\n    })),\n  );\n\n  const [isWindowFocused, setIsWindowFocused] = useState(true);\n\n  const startTimeRef = useRef(Date.now());\n  const pageNumberRef = useRef<number>(pageNumber);\n  const visibilityRef = useRef<boolean>(true);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const hasTrackedDownRef = useRef<boolean>(false);\n  const hasTrackedUpRef = useRef<boolean>(false);\n  const imageRefs = useRef<(HTMLImageElement | null)[]>([]);\n\n  const [imageDimensions, setImageDimensions] = useState<\n    Record<number, { width: number; height: number }>\n  >({});\n\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...getTrackingOptions(),\n    externalStartTimeRef: startTimeRef,\n  });\n\n  const getScaleFactor = ({\n    naturalHeight,\n    scaleFactor,\n  }: {\n    naturalHeight: number;\n    scaleFactor: number;\n  }) => {\n    const containerHeight = imageDimensions[pageNumber - 1]\n      ? imageDimensions[pageNumber - 1]!.height\n      : window.innerHeight - 64;\n\n    // Add a safety check to prevent division by zero\n    if (!naturalHeight || naturalHeight === 0) {\n      return scaleFactor;\n    }\n\n    return (scaleFactor * containerHeight) / naturalHeight;\n  };\n\n  useEffect(() => {\n    const updateImageDimensions = () => {\n      const newDimensions: Record<number, { width: number; height: number }> =\n        {};\n      imageRefs.current.forEach((img, index) => {\n        if (img) {\n          newDimensions[index] = {\n            width: img.clientWidth,\n            height: img.clientHeight,\n          };\n        }\n      });\n      setImageDimensions(newDimensions);\n    };\n\n    updateImageDimensions();\n    window.addEventListener(\"resize\", updateImageDimensions);\n\n    return () => {\n      window.removeEventListener(\"resize\", updateImageDimensions);\n    };\n  }, [pageNumber]);\n\n  // Update the previous page number after the effect hook has run\n  useEffect(() => {\n    pageNumberRef.current = pageNumber;\n    hasTrackedDownRef.current = false; // Reset tracking status on page number change\n    hasTrackedUpRef.current = false; // Reset tracking status on page number change\n  }, [pageNumber]);\n\n  // Start interval tracking when component mounts or page changes\n  useEffect(() => {\n    if (pageNumber <= numPages) {\n      const trackingData = {\n        linkId,\n        documentId,\n        viewId,\n        pageNumber: pageNumber,\n        versionNumber,\n        dataroomId,\n        setViewedPages,\n        isPreview,\n      };\n\n      startIntervalTracking(trackingData);\n    }\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    pageNumber,\n    numPages,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (pageNumber > numPages) return;\n\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n\n        // Restart interval tracking\n        if (pageNumber <= numPages) {\n          const trackingData = {\n            linkId,\n            documentId,\n            viewId,\n            pageNumber: pageNumber,\n            versionNumber,\n            dataroomId,\n            setViewedPages,\n            isPreview,\n          };\n          startIntervalTracking(trackingData);\n        }\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n        if (pageNumber <= numPages) {\n          const duration = getActiveDuration();\n          trackPageViewSafely(\n            {\n              linkId,\n              documentId,\n              viewId,\n              duration,\n              pageNumber: pageNumber,\n              versionNumber,\n              dataroomId,\n              setViewedPages,\n              isPreview,\n            },\n            true,\n          );\n        }\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    pageNumber,\n    numPages,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      if (pageNumber <= numPages) {\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId,\n            documentId,\n            viewId,\n            duration,\n            pageNumber: pageNumber,\n            versionNumber,\n            dataroomId,\n            setViewedPages,\n            isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    pageNumber,\n    numPages,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  // Add this effect near your other useEffect hooks\n  useEffect(() => {\n    if (!screenshotProtectionEnabled) return;\n\n    const handleFocus = () => setIsWindowFocused(true);\n    const handleBlur = () => setIsWindowFocused(false);\n\n    window.addEventListener(\"focus\", handleFocus);\n    window.addEventListener(\"blur\", handleBlur);\n\n    return () => {\n      window.removeEventListener(\"focus\", handleFocus);\n      window.removeEventListener(\"blur\", handleBlur);\n    };\n  }, [screenshotProtectionEnabled]);\n\n  useEffect(() => {\n    ensurePagesLoaded?.(pageNumber);\n  }, [pageNumber, ensurePagesLoaded]);\n\n  useEffect(() => {\n    // Remove token and email query parameters on component mount\n    const removeQueryParams = (queries: string[]) => {\n      const currentQuery = { ...router.query };\n      const currentPath = router.asPath.split(\"?\")[0];\n      queries.map((query) => delete currentQuery[query]);\n\n      router.replace(\n        {\n          pathname: currentPath,\n          query: currentQuery,\n        },\n        undefined,\n        { shallow: true },\n      );\n    };\n\n    if (!dataroomId && router.query.token) {\n      removeQueryParams([\"token\", \"email\", \"domain\", \"slug\", \"linkId\"]);\n    }\n  }, []); // Run once on mount\n\n  const goToPreviousPage = () => {\n    if (pageNumber <= 1) return;\n    if (enableQuestion && feedback && pageNumber === numPagesWithFeedback) {\n      setPageNumber(pageNumber - 1);\n      startTimeRef.current = Date.now();\n      return;\n    }\n\n    if (pageNumber === numPagesWithFeedback + 1) {\n      setPageNumber(pageNumber - 1);\n      startTimeRef.current = Date.now();\n      return;\n    }\n\n    const duration = getActiveDuration();\n    trackPageViewSafely({\n      linkId,\n      documentId,\n      viewId,\n      duration,\n      pageNumber: pageNumber,\n      versionNumber,\n      dataroomId,\n      setViewedPages,\n      isPreview,\n    });\n\n    setPageNumber(pageNumber - 1);\n    startTimeRef.current = Date.now();\n  };\n\n  const goToNextPage = () => {\n    if (pageNumber >= numPagesWithAccountCreation) return;\n\n    if (pageNumber > numPages) {\n      setPageNumber(pageNumber + 1);\n      startTimeRef.current = Date.now();\n      return;\n    }\n\n    const duration = getActiveDuration();\n    trackPageViewSafely({\n      linkId,\n      documentId,\n      viewId,\n      duration,\n      pageNumber: pageNumber,\n      versionNumber,\n      dataroomId,\n      setViewedPages,\n      isPreview,\n    });\n\n    setPageNumber(pageNumber + 1);\n    startTimeRef.current = Date.now();\n  };\n\n  const handleKeyDown = (event: KeyboardEvent) => {\n    switch (event.key) {\n      case \"ArrowRight\":\n        event.preventDefault(); // Prevent default behavior\n        event.stopPropagation(); // Stop propagation\n        goToNextPage();\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault(); // Prevent default behavior\n        event.stopPropagation(); // Stop propagation\n        goToPreviousPage();\n        break;\n      default:\n        break;\n    }\n  };\n\n  const handleLinkClick = (href: string, event: React.MouseEvent) => {\n    // Check if it's an internal page link or external link\n    const pageMatch = href.match(/#page=(\\d+)/);\n    if (pageMatch) {\n      event.preventDefault();\n      const targetPage = parseInt(pageMatch[1]);\n      if (targetPage >= 1 && targetPage <= numPages) {\n        // Track the current page before jumping\n        const duration = getActiveDuration();\n        trackPageViewSafely({\n          linkId,\n          documentId,\n          viewId,\n          duration,\n          pageNumber: pageNumber,\n          versionNumber,\n          dataroomId,\n          setViewedPages,\n          isPreview,\n        });\n\n        setPageNumber(targetPage);\n        pageNumberRef.current = targetPage;\n\n        // Reset the start time for the new page\n        startTimeRef.current = Date.now();\n      }\n    } else {\n      // Track external link clicks\n      if (!isPreview && viewId) {\n        fetch(\"/api/record_click\", {\n          method: \"POST\",\n          body: JSON.stringify({\n            timestamp: new Date().toISOString(),\n            sessionId: viewId,\n            linkId,\n            documentId,\n            viewId,\n            pageNumber: pageNumber.toString(),\n            href,\n            versionNumber,\n            dataroomId,\n          }),\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        }).catch(console.error); // Non-blocking\n      }\n    }\n  };\n\n  useEffect(() => {\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [handleKeyDown, goToNextPage, goToPreviousPage]);\n\n  // Add zoom handlers\n  const handleZoomIn = () => {\n    setScale((prev) => Math.min(prev + 0.25, 3)); // Max zoom 3x\n  };\n\n  const handleZoomOut = () => {\n    setScale((prev) => Math.max(prev - 0.25, 0.5)); // Min zoom 0.5x\n  };\n\n  // Add fullscreen handler\n  const handleFullscreen = () => {\n    if (!document.fullscreenElement) {\n      document.documentElement.requestFullscreen().catch((err) => {\n        console.error(\"Error attempting to enable fullscreen:\", err);\n      });\n    } else {\n      document.exitFullscreen();\n    }\n  };\n\n  // Add keyboard shortcuts for zooming and fullscreen\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Don't trigger shortcuts when typing in inputs or textareas\n      const target = e.target as HTMLElement;\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.isContentEditable\n      ) {\n        return;\n      }\n\n      if (e.metaKey || e.ctrlKey) {\n        if (e.key === \"=\" || e.key === \"+\") {\n          e.preventDefault();\n          handleZoomIn();\n        } else if (e.key === \"-\") {\n          e.preventDefault();\n          handleZoomOut();\n        } else if (e.key === \"0\") {\n          e.preventDefault();\n          setScale(1);\n        }\n      } else if (e.key === \"f\" || e.key === \"F\") {\n        e.preventDefault();\n        handleFullscreen();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, [containerRef.current, pageNumber, imageDimensions]);\n\n  // Compute scaled sizer dimensions for accurate scroll area\n  const currentDims = imageDimensions[pageNumber - 1];\n  const scaledWidthPx = currentDims ? currentDims.width * scale : undefined;\n  const scaledHeightPx = currentDims ? currentDims.height * scale : undefined;\n\n  return (\n    <>\n      <Nav\n        pageNumber={pageNumber}\n        numPages={numPagesWithAccountCreation}\n        embeddedLinks={pages[pageNumber - 1]?.embeddedLinks}\n        hasWatermark={!!watermarkConfig}\n        handleZoomIn={handleZoomIn}\n        handleZoomOut={handleZoomOut}\n        handleFullscreen={handleFullscreen}\n        navData={navData}\n      />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className=\"relative overflow-hidden\"\n      >\n        <ResizablePanelGroup direction=\"horizontal\">\n          {/* Document Content */}\n          <ResizablePanel defaultSize={100}>\n            <div className=\"flex h-full w-full items-center\">\n              <div\n                className={cn(\n                  \"relative h-full w-full\",\n                  !isWindowFocused &&\n                    screenshotProtectionEnabled &&\n                    \"blur-xl transition-all duration-300\",\n                )}\n                ref={containerRef}\n              >\n                <div className=\"h-full w-full overflow-auto\">\n                  {/* Sizer defines the scrollable layout size at current scale */}\n                  <div\n                    className=\"mx-auto\"\n                    style={{\n                      // Keep default zoom responsive to viewport changes.\n                      // Only lock dimensions when zoomed in to preserve a stable scroll area.\n                      width:\n                        scale > 1 && scaledWidthPx\n                          ? `${scaledWidthPx}px`\n                          : \"100%\",\n                      height:\n                        scale > 1 && scaledHeightPx\n                          ? `${scaledHeightPx}px`\n                          : \"auto\",\n                    }}\n                  >\n                    {/* Content is scaled; origin set to top-left so it grows into the sizer */}\n                    <div\n                      style={{\n                        transition: \"transform 0.2s ease-out\",\n                        transformOrigin: \"center top\",\n                        transform: `scale(${scale})`,\n                      }}\n                      onContextMenu={(e) => {\n                        e.preventDefault();\n                        e.stopPropagation();\n                      }}\n                    >\n                      {pageNumber <= numPagesWithAccountCreation && pages\n                        ? pages.map((page, index) => (\n                            <div\n                              key={index}\n                              className={cn(\n                                \"viewer-container relative mx-auto w-full\",\n                                pageNumber - 1 === index\n                                  ? \"flex justify-center\"\n                                  : \"hidden\",\n                              )}\n                            >\n                              <img\n                                className={cn(\n                                  \"viewer-image-mobile !pointer-events-auto max-h-[calc(100dvh-64px)] object-contain\",\n                                )}\n                                onContextMenu={(e) => {\n                                  e.preventDefault();\n                                  e.stopPropagation();\n                                }}\n                                ref={(ref) => {\n                                  imageRefs.current[index] = ref;\n                                  if (ref) {\n                                    ref.onload = () =>\n                                      setImageDimensions((prev) => ({\n                                        ...prev,\n                                        [index]: {\n                                          width: ref.clientWidth,\n                                          height: ref.clientHeight,\n                                        },\n                                      }));\n                                  }\n                                }}\n                                useMap={`#page-map-${index + 1}`}\n                                src={\n                                  page.file ||\n                                  \"https://www.papermark.com/_static/blank.gif\"\n                                }\n                                alt={`Page ${index + 1}`}\n                              />\n\n                              {/* Add Watermark Component */}\n                              {watermarkConfig ? (\n                                <SVGWatermark\n                                  config={watermarkConfig}\n                                  viewerData={{\n                                    email: viewerEmail,\n                                    date: new Date().toLocaleDateString(),\n                                    time: new Date().toLocaleTimeString(),\n                                    link: linkName,\n                                    ipAddress: ipAddress,\n                                  }}\n                                  documentDimensions={\n                                    imageDimensions[index] || {\n                                      width: 0,\n                                      height: 0,\n                                    }\n                                  }\n                                  pageIndex={index}\n                                />\n                              ) : null}\n\n                              {page.pageLinks ? (\n                                <map name={`page-map-${index + 1}`}>\n                                  {page.pageLinks\n                                    .filter(\n                                      (link) => !link.href.endsWith(\".gif\"),\n                                    )\n                                    .map((link, index) => (\n                                      <area\n                                        key={index}\n                                        shape=\"rect\"\n                                        coords={scaleCoordinates(\n                                          link.coords,\n                                          getScaleFactor({\n                                            naturalHeight: page.metadata.height,\n                                            scaleFactor:\n                                              page.metadata.scaleFactor,\n                                          }),\n                                        )}\n                                        href={link.href}\n                                        onClick={(e) =>\n                                          handleLinkClick(link.href, e)\n                                        }\n                                        target={\n                                          link.href.startsWith(\"#\")\n                                            ? \"_self\"\n                                            : \"_blank\"\n                                        }\n                                        rel={\n                                          link.href.startsWith(\"#\")\n                                            ? undefined\n                                            : \"noopener noreferrer\"\n                                        }\n                                      />\n                                    ))}\n                                </map>\n                              ) : null}\n\n                              {/** Automatically Render Overlays **/}\n                              {page.pageLinks && imageDimensions[index]\n                                ? page.pageLinks\n                                    .filter((link) =>\n                                      link.href.endsWith(\".gif\"),\n                                    )\n                                    .map((link, linkIndex) => {\n                                      const [x1, y1, x2, y2] = scaleCoordinates(\n                                        link.coords,\n                                        getScaleFactor({\n                                          naturalHeight: page.metadata.height,\n                                          scaleFactor:\n                                            page.metadata.scaleFactor,\n                                        }),\n                                      )\n                                        .split(\",\")\n                                        .map(Number);\n\n                                      const overlayWidth = x2 - x1;\n                                      const overlayHeight = y2 - y1;\n\n                                      // Calculate the offset to center-align with the image\n                                      const containerWidth =\n                                        imageRefs.current[index]?.parentElement\n                                          ?.clientWidth || 0;\n                                      const imageWidth =\n                                        imageDimensions[index].width;\n                                      const leftOffset =\n                                        (containerWidth - imageWidth) / 2;\n\n                                      return (\n                                        <img\n                                          key={`overlay-${index}-${linkIndex}`}\n                                          src={link.href}\n                                          alt={`Overlay ${index + 1}`}\n                                          style={{\n                                            position: \"absolute\",\n                                            top: y1,\n                                            left: x1 + leftOffset,\n                                            width: `${overlayWidth}px`,\n                                            height: `${overlayHeight}px`,\n                                            pointerEvents: \"none\",\n                                          }}\n                                        />\n                                      );\n                                    })\n                                : null}\n                            </div>\n                          ))\n                        : null}\n\n                      {enableQuestion &&\n                      feedback &&\n                      pageNumber === numPagesWithFeedback ? (\n                        <div\n                          className={cn(\"relative block h-dvh w-full\")}\n                          style={{ height: \"calc(100dvh - 64px)\" }}\n                        >\n                          <Question\n                            accentColor={brand?.accentColor}\n                            feedback={feedback}\n                            viewId={viewId}\n                            submittedFeedback={submittedFeedback}\n                            setSubmittedFeedback={setSubmittedFeedback}\n                            isPreview={isPreview}\n                          />\n                        </div>\n                      ) : null}\n\n                      {showStatsSlideWithAccountCreation &&\n                      pageNumber === numPagesWithAccountCreation ? (\n                        <div\n                          className={cn(\"relative block h-dvh w-full\")}\n                          style={{ height: \"calc(100dvh - 64px)\" }}\n                        >\n                          <ViewDurationSummary\n                            linkId={linkId}\n                            viewedPages={viewedPages}\n                            viewerEmail={viewerEmail}\n                            accountCreated={accountCreated}\n                            setAccountCreated={setAccountCreated}\n                          />\n                        </div>\n                      ) : null}\n                    </div>\n                  </div>\n                </div>\n              </div>\n\n              {/* Navigation arrows with hover zones */}\n              {pageNumber > 1 && (\n                <div\n                  className={cn(\n                    \"group absolute left-0 top-0 z-[1] flex h-full items-center\",\n                    isMobile ? \"w-1/6\" : \"w-32\",\n                    isMobile ? \"justify-start pl-1\" : \"justify-start pl-4\",\n                  )}\n                  onClick={isMobile ? goToPreviousPage : undefined}\n                >\n                  <button\n                    onClick={!isMobile ? goToPreviousPage : undefined}\n                    className={cn(\n                      \"rounded-full bg-gray-950/50 p-1 transition-opacity duration-200 hover:bg-gray-950/75\",\n                      \"opacity-50 group-hover:opacity-100\",\n                    )}\n                  >\n                    <ChevronLeftIcon\n                      className={cn(\"size-10 text-white\", isMobile && \"size-8\")}\n                    />\n                  </button>\n                </div>\n              )}\n\n              {pageNumber < numPagesWithAccountCreation && (\n                <div\n                  className={cn(\n                    \"group absolute right-0 top-0 z-[1] flex h-full items-center\",\n                    isMobile ? \"w-1/6\" : \"w-32\",\n                    isMobile ? \"justify-end pr-1\" : \"justify-end pr-4\",\n                  )}\n                  onClick={isMobile ? goToNextPage : undefined}\n                >\n                  <button\n                    onClick={!isMobile ? goToNextPage : undefined}\n                    className={cn(\n                      \"rounded-full bg-gray-950/50 p-1 transition-opacity duration-200 hover:bg-gray-950/75\",\n                      \"opacity-50 group-hover:opacity-100\",\n                    )}\n                  >\n                    <ChevronRightIcon\n                      className={cn(\"size-10 text-white\", isMobile && \"size-8\")}\n                    />\n                  </button>\n                </div>\n              )}\n\n              {feedbackEnabled && pageNumber <= numPages ? (\n                <Toolbar\n                  viewId={viewId}\n                  pageNumber={pageNumber}\n                  isPreview={isPreview}\n                />\n              ) : null}\n\n              {screenshotProtectionEnabled ? <ScreenProtector /> : null}\n              {showPoweredByBanner ? <PoweredBy linkId={linkId} /> : null}\n              <AwayPoster\n                isVisible={isInactive}\n                inactivityThreshold={getTrackingOptions().inactivityThreshold}\n                onDismiss={updateActivity}\n              />\n            </div>\n          </ResizablePanel>\n        </ResizablePanelGroup>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/pages-vertical-viewer.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport React from \"react\";\n\nimport { ChevronDownIcon, ChevronUpIcon } from \"lucide-react\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\nimport { WatermarkConfig } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nimport { ResizablePanel, ResizablePanelGroup } from \"@/components/ui/resizable\";\n\nimport { ScreenProtector } from \"../ScreenProtection\";\nimport Nav, { TNavData } from \"../nav\";\nimport { PoweredBy } from \"../powered-by\";\nimport Question from \"../question\";\nimport Toolbar from \"../toolbar\";\nimport { SVGWatermark } from \"../watermark-svg\";\nimport { AwayPoster } from \"./away-poster\";\n\nimport \"@/styles/custom-viewer-styles.css\";\n\nconst scaleCoordinates = (coords: string, scaleFactor: number) => {\n  return coords\n    .split(\",\")\n    .map((coord) => parseFloat(coord) * scaleFactor)\n    .join(\",\");\n};\n\nconst calculateOptimalWidth = (\n  containerWidth: number,\n  metadata: { width: number; height: number } | null,\n  isMobile: boolean,\n  isTablet: boolean,\n) => {\n  if (!metadata) {\n    // Fallback dimensions if metadata is null\n    return isMobile ? containerWidth : Math.min(800, containerWidth * 0.6);\n  }\n\n  const aspectRatio = metadata.width / metadata.height;\n  const maxWidth = Math.min(1400, containerWidth); // 100% of container width, max 1400px\n  const minWidth = Math.min(\n    800,\n    isTablet ? containerWidth * 0.9 : containerWidth * 0.6,\n  ); // 60% of container width, min 600px\n\n  // For landscape documents (width > height), use more width\n  if (aspectRatio > 1) {\n    return maxWidth;\n  }\n\n  // For portrait documents, use full width on mobile, min width on desktop\n  return isMobile ? containerWidth : minWidth;\n};\n\nexport default function PagesVerticalViewer({\n  pages,\n  feedbackEnabled,\n  screenshotProtectionEnabled,\n  versionNumber,\n  showPoweredByBanner,\n  enableQuestion = false,\n  feedback,\n  viewerEmail,\n  watermarkConfig,\n  ipAddress,\n  linkName,\n  navData,\n  ensurePagesLoaded,\n}: {\n  pages: {\n    file: string | null;\n    pageNumber: string;\n    embeddedLinks: string[];\n    pageLinks: {\n      href: string;\n      coords: string;\n      isInternal?: boolean;\n      targetPage?: number;\n    }[];\n    metadata: { width: number; height: number; scaleFactor: number };\n  }[];\n  feedbackEnabled: boolean;\n  screenshotProtectionEnabled: boolean;\n  versionNumber: number;\n  showPoweredByBanner?: boolean;\n  enableQuestion?: boolean | null;\n  feedback?: {\n    id: string;\n    data: { question: string; type: string };\n  } | null;\n  viewerEmail?: string;\n  watermarkConfig?: WatermarkConfig | null;\n  ipAddress?: string;\n  linkName?: string;\n  navData: TNavData;\n  ensurePagesLoaded?: (currentPage: number) => void;\n}) {\n  const { linkId, documentId, viewId, isPreview, dataroomId, brand } = navData;\n\n  const router = useRouter();\n\n  const numPages = pages.length;\n  const numPagesWithFeedback =\n    enableQuestion && feedback ? numPages + 1 : numPages;\n\n  const numPagesWithAccountCreation = numPagesWithFeedback;\n\n  const pageQuery = router.query.p ? Number(router.query.p) : 1;\n\n  const [pageNumber, setPageNumber] = useState<number>(() =>\n    pageQuery >= 1 && pageQuery <= numPages ? pageQuery : 1,\n  ); // start on first page\n\n  const [submittedFeedback, setSubmittedFeedback] = useState<boolean>(false);\n  const [accountCreated, setAccountCreated] = useState<boolean>(false);\n  const [scale, setScale] = useState<number>(1);\n\n  const [viewedPages, setViewedPages] = useState<\n    { pageNumber: number; duration: number }[]\n  >(() =>\n    Array.from({ length: numPages }, (_, index) => ({\n      pageNumber: index + 1,\n      duration: 0,\n    })),\n  );\n\n  const [isWindowFocused, setIsWindowFocused] = useState(true);\n\n  const startTimeRef = useRef(Date.now());\n  const pageNumberRef = useRef<number>(pageNumber);\n  const visibilityRef = useRef<boolean>(true);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const scrollActionRef = useRef<boolean>(false);\n  const scrollEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(\n    null,\n  );\n  const hasTrackedDownRef = useRef<boolean>(false);\n  const hasTrackedUpRef = useRef<boolean>(false);\n  const imageRefs = useRef<(HTMLImageElement | null)[]>([]);\n\n  const [imageDimensions, setImageDimensions] = useState<\n    Record<number, { width: number; height: number }>\n  >({});\n\n  const { isMobile, isTablet } = useMediaQuery();\n\n  const getScaleFactor = ({\n    naturalHeight,\n    scaleFactor,\n  }: {\n    naturalHeight: number;\n    scaleFactor: number;\n  }) => {\n    const containerHeight = imageDimensions[pageNumber - 1]\n      ? imageDimensions[pageNumber - 1]!.height\n      : window.innerHeight - 64;\n\n    // Add a safety check to prevent division by zero\n    if (!naturalHeight || naturalHeight === 0) {\n      return scaleFactor;\n    }\n\n    return (scaleFactor * containerHeight) / naturalHeight;\n  };\n\n  useEffect(() => {\n    const updateImageDimensions = () => {\n      const newDimensions: Record<number, { width: number; height: number }> =\n        {};\n      imageRefs.current.forEach((img, index) => {\n        if (img) {\n          newDimensions[index] = {\n            width: img.clientWidth,\n            height: img.clientHeight,\n          };\n        }\n      });\n      setImageDimensions(newDimensions);\n    };\n\n    updateImageDimensions();\n    window.addEventListener(\"resize\", updateImageDimensions);\n\n    return () => {\n      window.removeEventListener(\"resize\", updateImageDimensions);\n    };\n  }, [pageNumber]);\n\n  // Update the previous page number after the effect hook has run\n  useEffect(() => {\n    pageNumberRef.current = pageNumber;\n    hasTrackedDownRef.current = false; // Reset tracking status on page number change\n    hasTrackedUpRef.current = false; // Reset tracking status on page number change\n  }, [pageNumber]);\n\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...getTrackingOptions(),\n    externalStartTimeRef: startTimeRef,\n  });\n\n  useEffect(() => {\n    if (pageNumber <= numPages) {\n      const trackingData = {\n        linkId,\n        documentId,\n        viewId,\n        pageNumber: pageNumber,\n        versionNumber,\n        dataroomId,\n        setViewedPages,\n        isPreview,\n      };\n\n      startIntervalTracking(trackingData);\n    }\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    pageNumber,\n    numPages,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (pageNumber > numPages) return;\n\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n\n        if (pageNumber <= numPages) {\n          const trackingData = {\n            linkId,\n            documentId,\n            viewId,\n            pageNumber: pageNumber,\n            versionNumber,\n            dataroomId,\n            setViewedPages,\n            isPreview,\n          };\n          startIntervalTracking(trackingData);\n        }\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n\n        // Track final duration using activity-aware calculation\n        if (pageNumber <= numPages) {\n          const duration = getActiveDuration();\n          trackPageViewSafely(\n            {\n              linkId,\n              documentId,\n              viewId,\n              duration,\n              pageNumber: pageNumber,\n              versionNumber,\n              dataroomId,\n              setViewedPages,\n              isPreview,\n            },\n            true,\n          );\n        }\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    pageNumber,\n    numPages,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      if (pageNumber <= numPages) {\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId,\n            documentId,\n            viewId,\n            duration,\n            pageNumber: pageNumber,\n            versionNumber,\n            dataroomId,\n            setViewedPages,\n            isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    pageNumber,\n    numPages,\n    linkId,\n    documentId,\n    viewId,\n    versionNumber,\n    dataroomId,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  // Add this effect near your other useEffect hooks\n  useEffect(() => {\n    if (!screenshotProtectionEnabled) return;\n\n    const handleFocus = () => setIsWindowFocused(true);\n    const handleBlur = () => setIsWindowFocused(false);\n\n    window.addEventListener(\"focus\", handleFocus);\n    window.addEventListener(\"blur\", handleBlur);\n\n    return () => {\n      window.removeEventListener(\"focus\", handleFocus);\n      window.removeEventListener(\"blur\", handleBlur);\n    };\n  }, [screenshotProtectionEnabled]);\n\n  useEffect(() => {\n    ensurePagesLoaded?.(pageNumber);\n  }, [pageNumber, ensurePagesLoaded]);\n\n  useEffect(() => {\n    // Remove token and email query parameters on component mount\n    const removeQueryParams = (queries: string[]) => {\n      const currentQuery = { ...router.query };\n      const currentPath = router.asPath.split(\"?\")[0];\n      queries.map((query) => delete currentQuery[query]);\n\n      router.replace(\n        {\n          pathname: currentPath,\n          query: currentQuery,\n        },\n        undefined,\n        { shallow: true },\n      );\n    };\n\n    if (!dataroomId && router.query.token) {\n      removeQueryParams([\"token\", \"email\", \"domain\", \"slug\", \"linkId\"]);\n    }\n  }, []); // Run once on mount\n\n  const handleScroll = () => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    if (scrollActionRef.current) return;\n\n    const containerRect = container.getBoundingClientRect();\n\n    // Find which page is most visible in the viewport\n    let maxVisiblePage = pageNumber;\n    let maxVisibleArea = 0;\n\n    imageRefs.current.forEach((img, index) => {\n      if (!img) return;\n\n      const rect = img.getBoundingClientRect();\n      const visibleHeight =\n        Math.min(rect.bottom, containerRect.bottom) -\n        Math.max(rect.top, containerRect.top);\n      const visibleArea = Math.max(0, visibleHeight);\n\n      if (visibleArea > maxVisibleArea) {\n        maxVisibleArea = visibleArea;\n        maxVisiblePage = index + 1;\n      }\n    });\n\n    const feedbackElement = document.getElementById(\"feedback-question\");\n    if (feedbackElement) {\n      const feedbackRect = feedbackElement.getBoundingClientRect();\n      const isFeedbackVisible =\n        feedbackRect.top < containerRect.bottom &&\n        feedbackRect.bottom > containerRect.top;\n\n      if (isFeedbackVisible) {\n        setPageNumber(numPagesWithFeedback);\n        pageNumberRef.current = numPagesWithFeedback;\n        startTimeRef.current = Date.now();\n        return;\n      }\n    }\n\n    if (maxVisiblePage !== pageNumber) {\n      if (pageNumber <= numPages) {\n        const duration = getActiveDuration();\n        trackPageViewSafely({\n          linkId,\n          documentId,\n          viewId,\n          duration,\n          pageNumber: pageNumber,\n          versionNumber,\n          dataroomId,\n          setViewedPages,\n          isPreview,\n        });\n      }\n\n      setPageNumber(maxVisiblePage);\n      pageNumberRef.current = maxVisiblePage;\n      startTimeRef.current = Date.now();\n    }\n  };\n\n  const goToPreviousPage = () => {\n    if (pageNumber <= 1) return;\n    if (enableQuestion && feedback && pageNumber === numPagesWithFeedback) {\n      const targetImg = imageRefs.current[pageNumber - 2];\n      if (targetImg) {\n        targetImg.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n        setPageNumber(pageNumber - 1);\n        startTimeRef.current = Date.now();\n      }\n      return;\n    }\n\n    if (pageNumber === numPagesWithFeedback + 1) {\n      const targetImg = imageRefs.current[pageNumber - 2];\n      if (targetImg) {\n        targetImg.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n        setPageNumber(pageNumber - 1);\n        startTimeRef.current = Date.now();\n      }\n      return;\n    }\n\n    const duration = getActiveDuration();\n    trackPageViewSafely({\n      linkId,\n      documentId,\n      viewId,\n      duration,\n      pageNumber: pageNumber,\n      versionNumber,\n      dataroomId,\n      setViewedPages,\n      isPreview,\n    });\n\n    const targetImg = imageRefs.current[pageNumber - 2];\n    if (targetImg) {\n      targetImg.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n      setPageNumber(pageNumber - 1);\n      startTimeRef.current = Date.now();\n    }\n  };\n\n  const goToNextPage = () => {\n    if (pageNumber >= numPagesWithAccountCreation) return;\n\n    if (pageNumber === numPages && enableQuestion && feedback) {\n      const feedbackElement = document.getElementById(\"feedback-question\");\n      if (feedbackElement) {\n        feedbackElement.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n        setPageNumber(numPagesWithFeedback);\n        startTimeRef.current = Date.now();\n      }\n      return;\n    }\n\n    if (pageNumber > numPages) {\n      const targetImg = imageRefs.current[pageNumber];\n      if (targetImg) {\n        targetImg.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n        setPageNumber(pageNumber + 1);\n        startTimeRef.current = Date.now();\n      }\n      return;\n    }\n\n    const duration = getActiveDuration();\n    trackPageViewSafely({\n      linkId,\n      documentId,\n      viewId,\n      duration,\n      pageNumber: pageNumber,\n      versionNumber,\n      dataroomId,\n      setViewedPages,\n      isPreview,\n    });\n\n    const targetImg = imageRefs.current[pageNumber];\n    if (targetImg) {\n      targetImg.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n      setPageNumber(pageNumber + 1);\n      startTimeRef.current = Date.now();\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent) => {\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault(); // Prevent default behavior\n        event.stopPropagation(); // Stop propagation\n        goToNextPage();\n        break;\n      case \"ArrowUp\":\n        event.preventDefault(); // Prevent default behavior\n        event.stopPropagation(); // Stop propagation\n        goToPreviousPage();\n        break;\n      case \"ArrowRight\":\n        event.preventDefault(); // Prevent default behavior\n        event.stopPropagation(); // Stop propagation\n        goToNextPage();\n        break;\n      case \"ArrowLeft\":\n        event.preventDefault(); // Prevent default behavior\n        event.stopPropagation(); // Stop propagation\n        goToPreviousPage();\n        break;\n      default:\n        break;\n    }\n  };\n\n  const handleLinkClick = (href: string, event: React.MouseEvent) => {\n    // Check if it's an internal page link or external link\n    const pageMatch = href.match(/#page=(\\d+)/);\n    if (pageMatch) {\n      event.preventDefault();\n      const targetPage = parseInt(pageMatch[1]);\n      if (targetPage >= 1 && targetPage <= numPages) {\n        // Track the current page before jumping\n        const duration = getActiveDuration();\n        trackPageViewSafely({\n          linkId,\n          documentId,\n          viewId,\n          duration,\n          pageNumber: pageNumber,\n          versionNumber,\n          dataroomId,\n          setViewedPages,\n          isPreview,\n        });\n\n        setPageNumber(targetPage);\n        pageNumberRef.current = targetPage;\n\n        // Wait for images to load before scrolling\n        const waitForImageAndScroll = () => {\n          const targetImg = imageRefs.current[targetPage - 1];\n\n          // Check if target image exists and is loaded\n          if (targetImg && targetImg.complete && targetImg.naturalHeight > 0) {\n            scrollActionRef.current = true;\n            targetImg.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n            return;\n          }\n\n          // If image element exists but not loaded, wait for it\n          if (targetImg) {\n            const handleLoad = () => {\n              scrollActionRef.current = true;\n              targetImg.scrollIntoView({ behavior: \"smooth\", block: \"start\" });\n              targetImg.removeEventListener(\"load\", handleLoad);\n            };\n            targetImg.addEventListener(\"load\", handleLoad);\n\n            // Timeout fallback in case image is already cached but complete wasn't set\n            setTimeout(() => {\n              targetImg.removeEventListener(\"load\", handleLoad);\n              if (targetImg) {\n                scrollActionRef.current = true;\n                targetImg.scrollIntoView({\n                  behavior: \"smooth\",\n                  block: \"start\",\n                });\n              }\n            }, 500);\n            return;\n          }\n\n          // Image ref not available yet, wait for React to render\n          requestAnimationFrame(waitForImageAndScroll);\n        };\n\n        // Start checking after React processes the state update\n        requestAnimationFrame(() => {\n          requestAnimationFrame(waitForImageAndScroll);\n        });\n\n        // Reset the start time for the new page\n        startTimeRef.current = Date.now();\n      }\n    } else {\n      // Track external link clicks\n      if (!isPreview && viewId) {\n        fetch(\"/api/record_click\", {\n          method: \"POST\",\n          body: JSON.stringify({\n            timestamp: new Date().toISOString(),\n            sessionId: viewId,\n            linkId,\n            documentId,\n            viewId,\n            pageNumber: pageNumber.toString(),\n            href,\n            versionNumber,\n            dataroomId,\n          }),\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        }).catch(console.error); // Non-blocking\n      }\n    }\n  };\n\n  useEffect(() => {\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [handleKeyDown, goToNextPage, goToPreviousPage]);\n\n  const handleScrollRef = useRef(handleScroll);\n  handleScrollRef.current = handleScroll;\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const handler = () => {\n      handleScrollRef.current();\n\n      if (scrollActionRef.current) {\n        if (scrollEndTimeoutRef.current)\n          clearTimeout(scrollEndTimeoutRef.current);\n        scrollEndTimeoutRef.current = setTimeout(() => {\n          scrollActionRef.current = false;\n        }, 150);\n      }\n    };\n    container.addEventListener(\"scroll\", handler, { passive: true });\n\n    return () => {\n      container.removeEventListener(\"scroll\", handler);\n      if (scrollEndTimeoutRef.current)\n        clearTimeout(scrollEndTimeoutRef.current);\n    };\n  }, []);\n\n  const [containerWidth, setContainerWidth] = useState<number>(0);\n\n  // Add resize observer to track container width\n  useEffect(() => {\n    if (!containerRef.current) return;\n\n    const resizeObserver = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        setContainerWidth(entry.contentRect.width);\n      }\n    });\n\n    resizeObserver.observe(containerRef.current);\n\n    return () => {\n      resizeObserver.disconnect();\n    };\n  }, []);\n\n  const handleZoomIn = () => {\n    setScale((prev) => Math.min(prev + 0.25, 3)); // Max zoom 3x\n  };\n\n  const handleZoomOut = () => {\n    setScale((prev) => Math.max(prev - 0.25, 0.5)); // Min zoom 0.5x\n  };\n\n  // Add fullscreen handler\n  const handleFullscreen = () => {\n    if (!document.fullscreenElement) {\n      document.documentElement.requestFullscreen().catch((err) => {\n        console.error(\"Error attempting to enable fullscreen:\", err);\n      });\n    } else {\n      document.exitFullscreen();\n    }\n  };\n\n  // Add keyboard shortcuts for zooming and fullscreen\n  useEffect(() => {\n    const handleKeyDown = (e: KeyboardEvent) => {\n      // Don't trigger shortcuts when typing in inputs or textareas\n      const target = e.target as HTMLElement;\n      if (\n        target.tagName === \"INPUT\" ||\n        target.tagName === \"TEXTAREA\" ||\n        target.isContentEditable\n      ) {\n        return;\n      }\n\n      if (e.metaKey || e.ctrlKey) {\n        if (e.key === \"=\" || e.key === \"+\") {\n          e.preventDefault();\n          handleZoomIn();\n        } else if (e.key === \"-\") {\n          e.preventDefault();\n          handleZoomOut();\n        } else if (e.key === \"0\") {\n          e.preventDefault();\n          setScale(1);\n        }\n      } else if (e.key === \"f\" || e.key === \"F\") {\n        e.preventDefault();\n        handleFullscreen();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    return () => window.removeEventListener(\"keydown\", handleKeyDown);\n  }, []);\n\n  return (\n    <>\n      <Nav\n        pageNumber={pageNumber}\n        numPages={numPagesWithAccountCreation}\n        embeddedLinks={pages[pageNumber - 1]?.embeddedLinks}\n        hasWatermark={watermarkConfig ? true : false}\n        handleZoomIn={handleZoomIn}\n        handleZoomOut={handleZoomOut}\n        handleFullscreen={handleFullscreen}\n        navData={navData}\n      />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className=\"relative overflow-hidden\"\n      >\n        <ResizablePanelGroup direction=\"horizontal\">\n          {/* Document Content */}\n          <ResizablePanel defaultSize={100}>\n            <div\n              className={cn(\n                \"h-full w-full\",\n                \"overflow-auto scroll-smooth\",\n                !isWindowFocused &&\n                  screenshotProtectionEnabled &&\n                  \"blur-xl transition-all duration-300\",\n              )}\n              ref={containerRef}\n            >\n              <div className=\"flex min-h-full min-w-full justify-center\">\n                <div\n                  className=\"flex w-full max-w-[1400px] justify-center\"\n                  style={{\n                    minWidth: scale > 1 ? `${100 * scale}%` : \"100%\",\n                  }}\n                >\n                  <div\n                    className=\"transform-container w-full\"\n                    style={{\n                      transform: `scale(${scale})`,\n                      transition: \"transform 0.2s ease-out\",\n                      transformOrigin: \"center top\",\n                    }}\n                  >\n                    <div\n                      className=\"flex flex-col items-center gap-2\"\n                      onContextMenu={(e) => {\n                        e.preventDefault();\n                        e.stopPropagation();\n                      }}\n                    >\n                      {pages.map((page, index) => {\n                        const optimalWidth = containerWidth\n                          ? calculateOptimalWidth(\n                              containerWidth,\n                              page.metadata,\n                              isMobile,\n                              isTablet,\n                            )\n                          : 800;\n\n                        // Calculate placeholder height from metadata aspect ratio\n                        const placeholderHeight =\n                          page.metadata && page.metadata.width > 0\n                            ? (optimalWidth * page.metadata.height) /\n                              page.metadata.width\n                            : 600; // fallback height\n\n                        if (!page.file) {\n                          // Render a placeholder div with correct dimensions to preserve scroll height\n                          return (\n                            <div\n                              key={index}\n                              className=\"relative w-full px-4 md:px-8\"\n                              style={{\n                                width: `${optimalWidth}px`,\n                              }}\n                            >\n                              <div\n                                className=\"viewer-container relative border-b border-t border-gray-100 bg-gray-50\"\n                                style={{\n                                  height: `${placeholderHeight}px`,\n                                }}\n                              />\n                            </div>\n                          );\n                        }\n\n                        return (\n                          <div\n                            key={index}\n                            className=\"relative w-full px-4 md:px-8\"\n                            style={{\n                              width: `${optimalWidth}px`,\n                            }}\n                          >\n                            <div className=\"viewer-container relative border-b border-t border-gray-100\">\n                              <div\n                                className=\"pointer-events-none absolute bottom-0 left-0 w-px\"\n                                style={{\n                                  height: \"10%\",\n                                  background:\n                                    \"linear-gradient(to top, #f3f4f6, transparent)\",\n                                }}\n                              />\n                              <div\n                                className=\"pointer-events-none absolute bottom-0 right-0 w-px\"\n                                style={{\n                                  height: \"10%\",\n                                  background:\n                                    \"linear-gradient(to top, #f3f4f6, transparent)\",\n                                }}\n                              />\n                              <div\n                                className=\"pointer-events-none absolute left-0 top-0 w-px\"\n                                style={{\n                                  height: \"10%\",\n                                  background:\n                                    \"linear-gradient(to bottom, #f3f4f6, transparent)\",\n                                }}\n                              />\n                              <div\n                                className=\"pointer-events-none absolute right-0 top-0 w-px\"\n                                style={{\n                                  height: \"10%\",\n                                  background:\n                                    \"linear-gradient(to bottom, #f3f4f6, transparent)\",\n                                }}\n                              />\n                              <img\n                                className=\"viewer-image-mobile h-auto w-full object-contain\"\n                                onContextMenu={(e) => {\n                                  e.preventDefault();\n                                  e.stopPropagation();\n                                }}\n                                ref={(ref) => {\n                                  imageRefs.current[index] = ref;\n                                  if (ref) {\n                                    ref.onload = () =>\n                                      setImageDimensions((prev) => ({\n                                        ...prev,\n                                        [index]: {\n                                          width: ref.clientWidth,\n                                          height: ref.clientHeight,\n                                        },\n                                      }));\n                                  }\n                                }}\n                                useMap={`#page-map-${index + 1}`}\n                                src={page.file}\n                                alt={`Page ${index + 1}`}\n                              />\n\n                              {watermarkConfig && imageDimensions[index] ? (\n                                <div className=\"absolute left-0 top-0\">\n                                  <SVGWatermark\n                                    config={watermarkConfig}\n                                    viewerData={{\n                                      email: viewerEmail,\n                                      date: new Date().toLocaleDateString(),\n                                      time: new Date().toLocaleTimeString(),\n                                      link: linkName,\n                                      ipAddress: ipAddress,\n                                    }}\n                                    documentDimensions={imageDimensions[index]}\n                                    pageIndex={index}\n                                  />\n                                </div>\n                              ) : null}\n                            </div>\n\n                            {page.pageLinks ? (\n                              <map name={`page-map-${index + 1}`}>\n                                {page.pageLinks\n                                  .filter((link) => !link.href.endsWith(\".gif\"))\n                                  .map((link, linkIndex) => (\n                                    <area\n                                      key={linkIndex}\n                                      shape=\"rect\"\n                                      coords={scaleCoordinates(\n                                        link.coords,\n                                        getScaleFactor({\n                                          naturalHeight: page.metadata.height,\n                                          scaleFactor:\n                                            page.metadata.scaleFactor,\n                                        }),\n                                      )}\n                                      href={link.href}\n                                      onClick={(e) =>\n                                        handleLinkClick(link.href, e)\n                                      }\n                                      target={\n                                        link.href.startsWith(\"#\")\n                                          ? \"_self\"\n                                          : \"_blank\"\n                                      }\n                                      rel={\n                                        link.href.startsWith(\"#\")\n                                          ? undefined\n                                          : \"noopener noreferrer\"\n                                      }\n                                    />\n                                  ))}\n                              </map>\n                            ) : null}\n\n                            {page.pageLinks && imageDimensions[index]\n                              ? page.pageLinks\n                                  .filter((link) => link.href.endsWith(\".gif\"))\n                                  .map((link, linkIndex) => {\n                                    const [x1, y1, x2, y2] = scaleCoordinates(\n                                      link.coords,\n                                      getScaleFactor({\n                                        naturalHeight: page.metadata.height,\n                                        scaleFactor: page.metadata.scaleFactor,\n                                      }),\n                                    )\n                                      .split(\",\")\n                                      .map(Number);\n\n                                    const overlayWidth = x2 - x1;\n                                    const overlayHeight = y2 - y1;\n\n                                    // Account for the padding on the outer container (px-4 md:px-8)\n                                    const padding = isMobile ? 16 : 32; // 1rem = 16px (px-4), 2rem = 32px (px-8)\n\n                                    return (\n                                      <img\n                                        key={`overlay-${index}-${linkIndex}`}\n                                        src={link.href}\n                                        alt={`Overlay ${index + 1}`}\n                                        style={{\n                                          position: \"absolute\",\n                                          top: y1,\n                                          left: x1 + padding,\n                                          width: `${overlayWidth}px`,\n                                          height: `${overlayHeight}px`,\n                                          pointerEvents: \"none\",\n                                        }}\n                                      />\n                                    );\n                                  })\n                              : null}\n                          </div>\n                        );\n                      })}\n\n                      {enableQuestion &&\n                        feedback &&\n                        pageNumber >= numPagesWithFeedback - 1 && (\n                          <div\n                            id=\"feedback-question\"\n                            className={cn(\"relative block h-dvh w-full\")}\n                            style={{ height: \"calc(100dvh - 64px)\" }}\n                          >\n                            <Question\n                              accentColor={brand?.accentColor}\n                              feedback={feedback}\n                              viewId={viewId}\n                              submittedFeedback={submittedFeedback}\n                              setSubmittedFeedback={setSubmittedFeedback}\n                              isPreview={isPreview}\n                            />\n                          </div>\n                        )}\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            {/* Up arrow - hide on first page */}\n            <div\n              className={cn(\n                \"absolute left-0 right-0 top-0 flex h-24 items-start justify-center pt-4 transition-opacity duration-200\",\n                pageNumber <= 1 ? \"hidden\" : \"opacity-0 hover:opacity-100\",\n              )}\n              onClick={goToPreviousPage}\n            >\n              <button\n                disabled={pageNumber <= 1}\n                className=\"rounded-full bg-gray-950/50 p-1 hover:bg-gray-950/75\"\n              >\n                <ChevronUpIcon className=\"h-10 w-10 text-white\" />\n              </button>\n            </div>\n\n            {/* Down arrow - hide on last page unless there's an account creation page */}\n            <div\n              className={cn(\n                \"absolute bottom-0 left-0 right-0 flex h-24 items-end justify-center pb-4 transition-opacity duration-200\",\n                pageNumber >= numPagesWithAccountCreation\n                  ? \"hidden\"\n                  : \"opacity-0 hover:opacity-100\",\n              )}\n              onClick={goToNextPage}\n            >\n              <button\n                disabled={pageNumber >= numPagesWithAccountCreation}\n                className=\"rounded-full bg-gray-950/50 p-1 hover:bg-gray-950/75\"\n              >\n                <ChevronDownIcon className=\"h-10 w-10 text-white\" />\n              </button>\n            </div>\n\n            {feedbackEnabled && pageNumber <= numPages ? (\n              <Toolbar\n                viewId={viewId}\n                pageNumber={pageNumber}\n                isPreview={isPreview}\n              />\n            ) : null}\n\n            {screenshotProtectionEnabled ? <ScreenProtector /> : null}\n            {showPoweredByBanner ? <PoweredBy linkId={linkId} /> : null}\n            <AwayPoster\n              isVisible={isInactive}\n              inactivityThreshold={\n                getTrackingOptions().inactivityThreshold || 20000\n              }\n              onDismiss={updateActivity}\n            />\n            {/* </div> */}\n            {/* </div> */}\n          </ResizablePanel>\n        </ResizablePanelGroup>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/pdf-default-viewer.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport { Document, Page, pdfjs } from \"react-pdf\";\n\nimport { useSafePageViewTracker } from \"@/lib/tracking/safe-page-view-tracker\";\nimport { getTrackingOptions } from \"@/lib/tracking/tracking-config\";\n\nimport Nav from \"@/components/view/nav\";\n\nimport { AwayPoster } from \"./away-poster\";\n\npdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;\n\nexport default function PDFViewer(props: any) {\n  const { isPreview, linkId, documentId, viewId } = props.navData;\n\n  const [numPages, setNumPages] = useState<number>(0);\n  const [pageNumber, setPageNumber] = useState<number>(1); // start on first page\n  const [loading, setLoading] = useState(true);\n  const [pageWidth, setPageWidth] = useState(0);\n\n  const startTimeRef = useRef(Date.now());\n  const pageNumberRef = useRef<number>(pageNumber);\n  const visibilityRef = useRef<boolean>(true);\n  const teamInfo = useTeam();\n  const {\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n    isInactive,\n    updateActivity,\n  } = useSafePageViewTracker({\n    ...getTrackingOptions(),\n    externalStartTimeRef: startTimeRef,\n  });\n\n  // Update the previous page number after the effect hook has run\n  useEffect(() => {\n    pageNumberRef.current = pageNumber;\n  }, [pageNumber]);\n\n  // Start interval tracking when component mounts or page changes\n  useEffect(() => {\n    const trackingData = {\n      linkId: linkId,\n      documentId: documentId,\n      viewId: viewId,\n      pageNumber: pageNumberRef.current,\n      versionNumber: props.versionNumber,\n      isPreview: isPreview,\n    };\n\n    startIntervalTracking(trackingData);\n\n    return () => {\n      stopIntervalTracking();\n    };\n  }, [\n    pageNumber,\n    linkId,\n    documentId,\n    viewId,\n    props.versionNumber,\n    isPreview,\n    startIntervalTracking,\n    stopIntervalTracking,\n  ]);\n\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (document.visibilityState === \"visible\") {\n        visibilityRef.current = true;\n        resetTrackingState();\n\n        // Restart interval tracking\n        const trackingData = {\n          linkId: linkId,\n          documentId: documentId,\n          viewId: viewId,\n          pageNumber: pageNumberRef.current,\n          versionNumber: props.versionNumber,\n          isPreview: isPreview,\n        };\n        startIntervalTracking(trackingData);\n      } else {\n        visibilityRef.current = false;\n        stopIntervalTracking();\n\n        // Track final duration using activity-aware calculation\n        const duration = getActiveDuration();\n        trackPageViewSafely(\n          {\n            linkId: linkId,\n            documentId: documentId,\n            viewId: viewId,\n            duration: duration,\n            pageNumber: pageNumberRef.current,\n            versionNumber: props.versionNumber,\n            isPreview: isPreview,\n          },\n          true,\n        );\n      }\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, [\n    pageNumber,\n    linkId,\n    documentId,\n    viewId,\n    props.versionNumber,\n    isPreview,\n    trackPageViewSafely,\n    resetTrackingState,\n    startIntervalTracking,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  useEffect(() => {\n    if (numPages > 0) {\n      updateNumPages(numPages);\n    }\n  }, [numPages]); // monitor numPages for changes\n\n  function onDocumentLoadSuccess({\n    numPages: nextNumPages,\n  }: {\n    numPages: number;\n  }) {\n    setNumPages(nextNumPages);\n  }\n\n  // Send the last page view when the user leaves the page\n  // duration is measured in milliseconds\n  useEffect(() => {\n    const handleBeforeUnload = () => {\n      stopIntervalTracking();\n      const duration = getActiveDuration();\n      trackPageViewSafely(\n        {\n          linkId: linkId,\n          documentId: documentId,\n          viewId: viewId,\n          duration: duration,\n          pageNumber: pageNumberRef.current,\n          versionNumber: props.versionNumber,\n          isPreview: isPreview,\n          dataroomId: props?.navData?.dataroomId || undefined,\n        },\n        true,\n      );\n    };\n\n    window.addEventListener(\"beforeunload\", handleBeforeUnload);\n\n    return () => {\n      window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    props.versionNumber,\n    isPreview,\n    trackPageViewSafely,\n    stopIntervalTracking,\n    getActiveDuration,\n  ]);\n\n  function onPageLoadSuccess() {\n    setPageWidth(window.innerWidth);\n    setLoading(false);\n  }\n\n  const options = {\n    cMapUrl: \"cmaps/\",\n    cMapPacked: true,\n    standardFontDataUrl: \"standard_fonts/\",\n  };\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      switch (event.key) {\n        case \"ArrowRight\":\n          goToNextPage();\n          break;\n        case \"ArrowLeft\":\n          goToPreviousPage();\n          break;\n        default:\n          break;\n      }\n    };\n\n    // when the component mounts, attach the event listener\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    // when the component unmounts, detach the event listener\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [pageNumber]);\n\n  // Go to next page\n  function goToNextPage() {\n    if (pageNumber >= numPages!) return;\n    setPageNumber((prevPageNumber) => prevPageNumber + 1);\n  }\n\n  function goToPreviousPage() {\n    if (pageNumber <= 1) return;\n    setPageNumber((prevPageNumber) => prevPageNumber - 1);\n  }\n\n  async function downloadfile(e: React.MouseEvent<HTMLButtonElement>) {\n    try {\n      //get file data\n      const response = await fetch(props.file);\n      const fileData = await response.blob();\n\n      //create <a/> to download the file\n      const a = document.createElement(\"a\");\n      a.href = window.URL.createObjectURL(fileData);\n      a.download = props.name;\n      document.body.appendChild(a);\n      a.click();\n\n      //clean up used resources\n      document.body.removeChild(a);\n      window.URL.revokeObjectURL(a.href);\n    } catch (error) {\n      console.error(\"Error downloading file:\", error);\n    }\n  }\n\n  async function updateNumPages(numPages: number) {\n    await fetch(`/api/teams/${teamInfo?.currentTeam?.id}/documents/update`, {\n      method: \"POST\",\n      body: JSON.stringify({\n        documentId: documentId,\n        numPages: numPages,\n      }),\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n    });\n  }\n\n  return (\n    <>\n      <Nav\n        pageNumber={pageNumber}\n        numPages={numPages}\n        navData={props.navData}\n      />\n      <div\n        hidden={loading}\n        style={{ height: \"calc(100vh - 64px)\" }}\n        className=\"flex items-center\"\n      >\n        <div\n          className={`absolute z-10 flex w-full items-center justify-between px-2`}\n        >\n          <button\n            onClick={goToPreviousPage}\n            disabled={pageNumber <= 1}\n            className=\"h-[calc(100vh - 64px)] relative px-2 py-24 text-gray-400 hover:text-gray-50 focus:z-20\"\n          >\n            <span className=\"sr-only\">Previous</span>\n            <ChevronLeftIcon className=\"h-10 w-10\" aria-hidden=\"true\" />\n          </button>\n          <button\n            onClick={goToNextPage}\n            disabled={pageNumber >= numPages!}\n            className=\"h-[calc(100vh - 64px)] relative px-2 py-24 text-gray-400 hover:text-gray-50 focus:z-20\"\n          >\n            <span className=\"sr-only\">Next</span>\n            <ChevronRightIcon className=\"h-10 w-10\" aria-hidden=\"true\" />\n          </button>\n        </div>\n\n        <div className=\"mx-auto flex h-full justify-center\">\n          <Document\n            file={props.file}\n            onLoadSuccess={onDocumentLoadSuccess}\n            options={options}\n            renderMode=\"canvas\"\n            className=\"\"\n          >\n            <Page\n              className=\"\"\n              key={pageNumber}\n              pageNumber={pageNumber}\n              renderAnnotationLayer={false}\n              renderTextLayer={false}\n              onLoadSuccess={onPageLoadSuccess}\n              onRenderError={() => setLoading(false)}\n              width={Math.max(pageWidth * 0.8, 390)}\n            />\n          </Document>\n        </div>\n        <AwayPoster\n          isVisible={isInactive}\n          inactivityThreshold={getTrackingOptions().inactivityThreshold}\n          onDismiss={updateActivity}\n        />\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/video-player.tsx",
    "content": "import {\n  forwardRef,\n  memo,\n  useEffect,\n  useImperativeHandle,\n  useRef,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface MediaPlayerProps {\n  mediaSrc: string;\n  onError?: (error: Error) => void;\n  controls?: boolean;\n  className?: string;\n  preventDownload?: boolean;\n}\n\nexport const MediaPlayer = memo(\n  forwardRef<HTMLVideoElement, MediaPlayerProps>(\n    (\n      {\n        mediaSrc,\n        onError,\n        controls = true,\n        className = \"\",\n        preventDownload = true,\n      },\n      ref,\n    ) => {\n      const videoRef = useRef<HTMLVideoElement>(null);\n\n      useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement);\n\n      useEffect(() => {\n        const video = videoRef.current;\n        if (!video) return;\n\n        const handleError = (e: ErrorEvent) => {\n          console.error(\"Media playback error:\", e);\n          onError?.(new Error(\"Failed to play media\"));\n        };\n\n        video.addEventListener(\"error\", handleError);\n\n        return () => {\n          video.removeEventListener(\"error\", handleError);\n        };\n      }, [onError]);\n\n      return (\n        <video\n          ref={videoRef}\n          className={cn(\"max-h-full max-w-full object-contain\", className)}\n          preload=\"metadata\"\n          playsInline\n          controls={controls}\n          controlsList={\n            preventDownload ? \"nodownload noremoteplayback\" : undefined\n          }\n          onContextMenu={\n            preventDownload ? (e) => e.preventDefault() : undefined\n          }\n          src={mediaSrc}\n        >\n          <source src={mediaSrc} />\n          Your browser does not support the media tag.\n        </video>\n      );\n    },\n  ),\n);\n\nMediaPlayer.displayName = \"MediaPlayer\";\n\n// Keep the old VideoPlayer export for backward compatibility\nexport const VideoPlayer = MediaPlayer;\n"
  },
  {
    "path": "components/view/viewer/video-viewer.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nimport { toast } from \"sonner\";\n\nimport { createVideoTracker } from \"@/lib/tracking/video-tracking\";\nimport { cn } from \"@/lib/utils\";\n\nimport { ScreenProtector } from \"../ScreenProtection\";\nimport Nav, { TNavData } from \"../nav\";\nimport { MediaPlayer } from \"./video-player\";\n\nexport default function VideoViewer({\n  file,\n  screenshotProtectionEnabled,\n  versionNumber,\n  navData,\n}: {\n  file: string;\n  screenshotProtectionEnabled: boolean;\n  versionNumber: number;\n  navData: TNavData;\n}) {\n  const { isPreview, linkId, documentId, viewId, dataroomId, allowDownload } =\n    navData;\n\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const [isWindowFocused, setIsWindowFocused] = useState(true);\n  const videoTrackerRef = useRef<ReturnType<typeof createVideoTracker> | null>(\n    null,\n  );\n\n  // Initialize video tracker\n  useEffect(() => {\n    if (!videoRef.current) return;\n\n    videoTrackerRef.current = createVideoTracker(videoRef.current, {\n      linkId,\n      documentId,\n      viewId,\n      dataroomId,\n      versionNumber,\n      isMuted: false,\n      isFocused: isWindowFocused,\n      isFullscreen: false,\n      isPreview,\n      playbackRate: 1,\n      volume: 1,\n    });\n\n    return () => {\n      videoTrackerRef.current?.cleanup();\n    };\n  }, [\n    linkId,\n    documentId,\n    viewId,\n    dataroomId,\n    versionNumber,\n    isPreview,\n    isWindowFocused,\n  ]);\n\n  // Handle visibility change for analytics\n  useEffect(() => {\n    const handleVisibilityChange = () => {\n      if (!videoTrackerRef.current) return;\n      videoTrackerRef.current.trackVisibilityChange(\n        document.visibilityState === \"visible\",\n      );\n    };\n\n    document.addEventListener(\"visibilitychange\", handleVisibilityChange);\n    return () => {\n      document.removeEventListener(\"visibilitychange\", handleVisibilityChange);\n    };\n  }, []);\n\n  // Handle screenshot protection\n  useEffect(() => {\n    if (!screenshotProtectionEnabled) return;\n\n    const handleFocus = () => setIsWindowFocused(true);\n    const handleBlur = () => setIsWindowFocused(false);\n\n    window.addEventListener(\"focus\", handleFocus);\n    window.addEventListener(\"blur\", handleBlur);\n\n    return () => {\n      window.removeEventListener(\"focus\", handleFocus);\n      window.removeEventListener(\"blur\", handleBlur);\n    };\n  }, [screenshotProtectionEnabled]);\n\n  const handleVideoError = (error: Error) => {\n    console.error(\"Video playback error:\", error);\n    toast.error(\"Error playing video. Please try again.\");\n  };\n\n  return (\n    <>\n      <Nav navData={navData} />\n      <div\n        style={{ height: \"calc(100dvh - 64px)\" }}\n        className={cn(\n          \"relative flex items-center justify-center bg-black p-4\",\n          !isWindowFocused &&\n            screenshotProtectionEnabled &&\n            \"blur-xl transition-all duration-300\",\n        )}\n      >\n        <div className=\"relative flex h-full w-full items-center justify-center\">\n          <MediaPlayer\n            ref={videoRef}\n            mediaSrc={file}\n            onError={handleVideoError}\n            preventDownload={!allowDownload}\n          />\n          {screenshotProtectionEnabled && <ScreenProtector />}\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/view/viewer/viewer-surface-theme.tsx",
    "content": "import { createContext, useContext } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport {\n  AdaptiveSurfacePalette,\n  createAdaptiveSurfacePalette,\n} from \"@/lib/utils/create-adaptive-surface-palette\";\n\ntype ViewerSurfaceTextTone = \"light\" | \"dark\";\n\nexport type ViewerSurfaceTheme = {\n  palette: AdaptiveSurfacePalette;\n  textTone: ViewerSurfaceTextTone;\n  usesLightText: boolean;\n};\n\nconst DEFAULT_VIEWER_SURFACE_THEME: ViewerSurfaceTheme =\n  createViewerSurfaceTheme(null);\n\nconst ViewerSurfaceThemeContext = createContext<ViewerSurfaceTheme>(\n  DEFAULT_VIEWER_SURFACE_THEME,\n);\n\nexport function createViewerSurfaceTheme(\n  backgroundColor: string | null | undefined,\n): ViewerSurfaceTheme {\n  const palette = createAdaptiveSurfacePalette(backgroundColor || \"#ffffff\");\n\n  return {\n    palette,\n    textTone: palette.usesLightText ? \"light\" : \"dark\",\n    usesLightText: palette.usesLightText,\n  };\n}\n\nexport function ViewerSurfaceThemeProvider({\n  value,\n  children,\n}: {\n  value: ViewerSurfaceTheme;\n  children: ReactNode;\n}) {\n  return (\n    <ViewerSurfaceThemeContext.Provider value={value}>\n      {children}\n    </ViewerSurfaceThemeContext.Provider>\n  );\n}\n\nexport function useViewerSurfaceTheme() {\n  return useContext(ViewerSurfaceThemeContext);\n}\n"
  },
  {
    "path": "components/view/visitor-graph.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { BarChart } from \"@tremor/react\";\nimport { motion } from \"motion/react\";\nimport { signIn } from \"next-auth/react\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nimport { timeFormatter } from \"../charts/utils\";\nimport { Button } from \"../ui/button\";\nimport { Input } from \"../ui/input\";\nimport { Label } from \"../ui/label\";\n\nfunction formatTotalDuration(totalDuration: number | null | undefined): string {\n  if (totalDuration == null) {\n    return \"0 minutes\"; // Default value if total_duration is null\n  } else if (totalDuration < 60000) {\n    return \"< 1 minute\";\n  } else {\n    const minutes = Math.ceil(totalDuration / 60000);\n    return `${minutes} minutes`;\n  }\n}\n\nfunction sumDurationsAndFormat(\n  pages: {\n    pageNumber: number;\n    duration: number;\n  }[],\n): string {\n  const totalDuration = pages.reduce((sum, page) => sum + page.duration, 0);\n  return formatTotalDuration(totalDuration);\n}\n\nexport default function ViewDurationSummary({\n  linkId,\n  viewedPages,\n  viewerEmail,\n  accountCreated,\n  setAccountCreated,\n}: {\n  linkId: string;\n  viewedPages: { pageNumber: number; duration: number }[];\n  viewerEmail?: string;\n  accountCreated: boolean;\n  setAccountCreated: React.Dispatch<React.SetStateAction<boolean>>;\n}) {\n  const [email, setEmail] = useState<string>(viewerEmail || \"\");\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const analytics = useAnalytics();\n\n  if (accountCreated) {\n    return (\n      <motion.div\n        className=\"mx-5 flex h-full flex-col items-center justify-center space-y-10 text-center sm:mx-auto\"\n        variants={{\n          hidden: { opacity: 0, scale: 0.95 },\n          show: {\n            opacity: 1,\n            scale: 1,\n            transition: {\n              staggerChildren: 0.2,\n            },\n          },\n        }}\n        initial=\"hidden\"\n        animate=\"show\"\n        exit=\"hidden\"\n        transition={{ duration: 0.3, type: \"spring\" }}\n      >\n        <motion.div\n          variants={STAGGER_CHILD_VARIANTS}\n          className=\"flex flex-col items-center space-y-10 text-center\"\n        >\n          <h1 className=\"max-w-lg text-3xl font-semibold text-white transition-colors\">\n            Thanks for creating an account!\n          </h1>\n          <p className=\"max-w-lg text-balance text-white\">\n            We sent you an email confirmation with a link to your Papermark\n            account.\n          </p>\n        </motion.div>\n      </motion.div>\n    );\n  }\n\n  return (\n    <motion.div\n      className=\"flex h-full w-full flex-col items-center justify-center space-y-10 text-center\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"mx-5 h-fit w-full max-w-md overflow-hidden rounded-md bg-white py-6 text-white sm:mx-0\"\n      >\n        <motion.div\n          variants={STAGGER_CHILD_VARIANTS}\n          className=\"flex flex-col items-center space-y-10 text-center\"\n        >\n          <div className=\"flex flex-col items-center justify-center space-y-3 px-4 py-6 pt-8 text-center sm:px-16\">\n            <div className=\"text-balance text-base text-muted-foreground\">\n              You spent {sumDurationsAndFormat(viewedPages)} on this document.\n            </div>\n            <BarChart\n              className=\"h-40\"\n              data={viewedPages}\n              index=\"pageNumber\"\n              categories={[\"duration\"]}\n              colors={[\"emerald\"]}\n              valueFormatter={(v) => timeFormatter(v)}\n              showYAxis={false}\n              showGridLines={true}\n              showLegend={false}\n            />\n          </div>\n        </motion.div>\n        <motion.div variants={STAGGER_CHILD_VARIANTS}>\n          <div className=\"flex flex-col items-center justify-center space-y-3 px-4 py-6 pt-8 text-center sm:px-16\">\n            <div className=\"text-balance text-2xl font-semibold text-gray-800\">\n              Start sharing documents and data rooms securely\n            </div>\n          </div>\n          <form\n            className=\"flex flex-col gap-4 px-4 sm:px-16\"\n            onSubmit={(e) => {\n              e.preventDefault();\n              setLoading(true);\n              signIn(\"email\", {\n                email: email,\n                redirect: false,\n                callbackUrl: `/dashboard`,\n              }).then((res) => {\n                if (res?.ok && !res?.error) {\n                  setEmail(\"\");\n                  setAccountCreated(true);\n                  analytics.capture(\"Account Created\", {\n                    email: email,\n                    linkId: linkId,\n                    timeSpent: viewedPages.reduce(\n                      (sum, page) => sum + page.duration,\n                      0,\n                    ),\n                  });\n                }\n                setLoading(false);\n              });\n            }}\n          >\n            <Label className=\"sr-only\" htmlFor=\"email\">\n              Email\n            </Label>\n            <Input\n              id=\"email\"\n              placeholder=\"name@example.com\"\n              type=\"email\"\n              autoCapitalize=\"none\"\n              autoComplete=\"email\"\n              autoCorrect=\"off\"\n              data-1p-ignore\n              disabled={loading}\n              value={email}\n              onChange={(e) => setEmail(e.target.value)}\n              className=\"flex h-10 w-full rounded-md border-0 bg-background bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n            />\n            <Button\n              type=\"submit\"\n              loading={loading}\n              className={`${\n                loading ? \"bg-black\" : \"bg-gray-800 hover:bg-gray-900\"\n              } focus:shadow-outline transform rounded px-4 py-2 text-white transition-colors duration-300 ease-in-out focus:outline-none`}\n            >\n              Sign up with Email\n            </Button>\n          </form>\n          <p className=\"mt-4 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-16\">\n            By clicking continue, you acknowledge that you have read and agree\n            to Papermark&apos;s{\" \"}\n            <Link\n              href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}\n              target=\"_blank\"\n              className=\"underline hover:text-gray-900\"\n            >\n              Terms of Service\n            </Link>{\" \"}\n            and{\" \"}\n            <Link\n              href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}\n              target=\"_blank\"\n              className=\"underline hover:text-gray-900\"\n            >\n              Privacy Policy\n            </Link>\n            .\n          </p>\n        </motion.div>\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/view/watermark-svg.tsx",
    "content": "import React, { useMemo } from \"react\";\n\nimport { WatermarkConfig } from \"@/lib/types\";\nimport { safeTemplateReplace } from \"@/lib/utils\";\n\nexport const SVGWatermark = ({\n  config,\n  viewerData,\n  documentDimensions,\n  pageIndex,\n}: {\n  config: WatermarkConfig;\n  viewerData: any;\n  documentDimensions: { width: number; height: number };\n  pageIndex: number;\n}) => {\n  const watermarkText = useMemo(() => {\n    return safeTemplateReplace(config.text, viewerData);\n  }, [config.text, viewerData]);\n\n  const { width, height } = documentDimensions;\n\n  // Calculate a responsive font size\n  const calculateFontSize = () => {\n    const baseFontSize = Math.min(width, height) * (config.fontSize / 1000);\n    return Math.max(8, Math.min(baseFontSize, config.fontSize)); // Clamp between 8px and config.fontSize\n  };\n\n  const fontSize = calculateFontSize();\n\n  const createPattern = () => {\n    // Estimate text width (this is an approximation)\n    const textWidth = watermarkText.length * fontSize * 0.6;\n\n    // Make pattern size larger than text to avoid cut-off\n    const patternWidth = textWidth;\n    const patternHeight = fontSize * 10;\n\n    return (\n      <pattern\n        id={`watermarkPattern-${pageIndex}`}\n        patternUnits=\"userSpaceOnUse\"\n        width={patternWidth}\n        height={patternHeight}\n        patternTransform={`rotate(-${config.rotation})`}\n      >\n        <text\n          x={patternWidth / 2}\n          y={patternHeight / 4}\n          fontSize={`${fontSize}px`}\n          fill={config.color}\n          opacity={config.opacity}\n          textAnchor=\"middle\"\n          dominantBaseline=\"middle\"\n        >\n          {watermarkText}\n        </text>\n      </pattern>\n    );\n  };\n\n  const createSingleWatermark = () => {\n    let x, y;\n    switch (config.position) {\n      case \"top-left\":\n        x = fontSize / 2;\n        y = fontSize;\n        break;\n      case \"top-center\":\n        x = width / 2;\n        y = fontSize;\n        break;\n      case \"top-right\":\n        x = width - fontSize / 2;\n        y = fontSize;\n        break;\n      case \"middle-left\":\n        x = fontSize / 2;\n        y = height / 2;\n        break;\n      case \"middle-center\":\n        x = width / 2;\n        y = height / 2;\n        break;\n      case \"middle-right\":\n        x = width - fontSize / 2;\n        y = height / 2;\n        break;\n      case \"bottom-left\":\n        x = fontSize / 2;\n        y = height - fontSize;\n        break;\n      case \"bottom-center\":\n        x = width / 2;\n        y = height - fontSize;\n        break;\n      case \"bottom-right\":\n        x = width - fontSize / 2;\n        y = height - fontSize;\n        break;\n      default:\n        x = width / 2;\n        y = height / 2;\n    }\n\n    return (\n      <text\n        x={x}\n        y={y}\n        fontSize={`${fontSize}px`}\n        fill={config.color}\n        opacity={config.opacity}\n        textAnchor={\n          config.position.includes(\"right\")\n            ? \"end\"\n            : config.position.includes(\"center\")\n              ? \"middle\"\n              : \"start\"\n        }\n        dominantBaseline={\n          config.position.includes(\"top\")\n            ? \"hanging\"\n            : config.position.includes(\"middle\")\n              ? \"middle\"\n              : \"auto\"\n        }\n        transform={`rotate(${-config.rotation} ${x} ${y})`}\n      >\n        {watermarkText}\n      </text>\n    );\n  };\n\n  return (\n    <svg\n      width={width}\n      height={height}\n      style={{\n        position: \"absolute\",\n        top: 0,\n        display: \"flex\",\n        pointerEvents: \"none\",\n      }}\n    >\n      {config.isTiled ? (\n        <>\n          {createPattern()}\n          <rect\n            width={width}\n            height={height}\n            fill={`url(#watermarkPattern-${pageIndex})`}\n          />\n        </>\n      ) : (\n        createSingleWatermark()\n      )}\n    </svg>\n  );\n};\n"
  },
  {
    "path": "components/viewer-upload-component.tsx",
    "content": "import { useRef, useState } from \"react\";\n\nimport { FileUp } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { usePendingUploads } from \"@/context/pending-uploads-context\";\nimport { DocumentData } from \"@/lib/documents/create-document\";\nimport { newId } from \"@/lib/id-helper\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Progress } from \"@/components/ui/progress\";\nimport ViewerUploadZone from \"@/components/viewer-upload-zone\";\n\nexport function ViewerUploadComponent({\n  viewerData,\n  teamId,\n  folderId,\n  onUploadSuccess,\n}: {\n  viewerData: { id: string; linkId: string; dataroomId?: string };\n  teamId: string;\n  folderId?: string;\n  onUploadSuccess?: () => void;\n}) {\n  const [uploads, setUploads] = useState<\n    { uploadId: string; fileName: string; progress: number }[]\n  >([]);\n  const [rejectedFiles, setRejectedFiles] = useState<\n    { fileName: string; message: string }[]\n  >([]);\n\n  const { addPendingUpload, updatePendingUpload } = usePendingUploads();\n\n  // Map each active upload item to its pending upload record\n  const pendingUploadIds = useRef<Map<string, string>>(new Map());\n  const activeUploadIds = useRef<Set<string>>(new Set());\n  const failedCountRef = useRef(0);\n\n  const finalizeSessionIfIdle = () => {\n    if (activeUploadIds.current.size > 0) return;\n    const hasFailures = failedCountRef.current > 0;\n    failedCountRef.current = 0;\n\n    if (!hasFailures) {\n      onUploadSuccess?.();\n    }\n  };\n\n  const handleUploadStart = (\n    newUploads: { uploadId: string; fileName: string; progress: number }[],\n  ) => {\n    const isStartingFreshSession = activeUploadIds.current.size === 0;\n    if (isStartingFreshSession) {\n      failedCountRef.current = 0;\n      setRejectedFiles([]);\n    }\n    setUploads((prev) => [...prev, ...newUploads]);\n\n    newUploads.forEach((upload) => {\n      activeUploadIds.current.add(upload.uploadId);\n      const pendingId = newId(\"pending\");\n      pendingUploadIds.current.set(upload.uploadId, pendingId);\n\n      addPendingUpload({\n        id: pendingId,\n        name: upload.fileName,\n        folderId: folderId ?? null,\n        uploadedAt: new Date(),\n        status: \"uploading\",\n        progress: 0,\n      });\n    });\n  };\n\n  const handleUploadProgress = (uploadId: string, progress: number) => {\n    setUploads((prev) => {\n      const updated = prev.map((upload) =>\n        upload.uploadId === uploadId ? { ...upload, progress } : upload,\n      );\n\n      const pendingId = pendingUploadIds.current.get(uploadId);\n      if (pendingId) {\n        updatePendingUpload(pendingId, { progress });\n      }\n\n      return updated;\n    });\n  };\n\n  const settleUpload = (uploadId: string, failed: boolean) => {\n    if (failed) {\n      failedCountRef.current += 1;\n    }\n\n    activeUploadIds.current.delete(uploadId);\n    pendingUploadIds.current.delete(uploadId);\n    setUploads((prev) => prev.filter((upload) => upload.uploadId !== uploadId));\n    finalizeSessionIfIdle();\n  };\n\n  const handleUploadComplete = async (\n    documentData: DocumentData,\n    uploadId: string,\n  ) => {\n    const pendingId = pendingUploadIds.current.get(uploadId);\n\n    // Update status to processing (file uploaded to S3, now being processed by backend)\n    if (pendingId) {\n      updatePendingUpload(pendingId, {\n        status: \"processing\",\n        progress: 100,\n      });\n    }\n\n    // Call the API to add the document to the dataroom\n    try {\n      const response = await fetch(`/api/links/${viewerData.linkId}/upload`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          documentData,\n          dataroomId: viewerData.dataroomId,\n          folderId: folderId,\n        }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        throw new Error(errorData.message || \"Failed to process upload\");\n      }\n\n      const result = await response.json();\n      if (!result.document) {\n        throw new Error(\"Upload response missing document metadata\");\n      }\n\n      // Determine if the file needs trigger processing (PDF, docs, slides)\n      // Images and Excel/CSV files are ready immediately after upload\n      const FILE_TYPES_NEEDING_PROCESSING = [\"pdf\", \"docs\", \"slides\"];\n      const needsProcessing = FILE_TYPES_NEEDING_PROCESSING.includes(\n        result.document.fileType,\n      );\n\n      // Update pending upload with real document data\n      if (pendingId) {\n        updatePendingUpload(pendingId, {\n          status: needsProcessing ? \"processing\" : \"complete\",\n          documentId: result.document.id,\n          dataroomDocumentId: result.document.dataroomDocumentId,\n          documentVersionId: result.document.documentVersionId,\n          fileType: result.document.fileType,\n        });\n      }\n\n      settleUpload(uploadId, false);\n    } catch (error) {\n      console.error(\"Error processing upload:\", error);\n      toast.error((error as Error).message || \"Failed to upload document\");\n\n      if (pendingId) {\n        updatePendingUpload(pendingId, {\n          status: \"error\",\n          errorMessage: (error as Error).message || \"Failed to upload document\",\n        });\n      }\n\n      settleUpload(uploadId, true);\n    }\n  };\n\n  const isUploading = uploads.length > 0;\n\n  return (\n    <ViewerUploadZone\n      onUploadStart={handleUploadStart}\n      onUploadProgress={handleUploadProgress}\n      onUploadComplete={handleUploadComplete}\n      onUploadRejected={(rejected) => setRejectedFiles(rejected)}\n      viewerData={viewerData}\n      teamId={teamId}\n    >\n      {isUploading ? (\n        <div className=\"space-y-3\">\n          {uploads.map((upload) => (\n            <div\n              key={upload.uploadId}\n              className=\"flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800\"\n            >\n              <FileUp className=\"h-5 w-5 shrink-0 text-muted-foreground\" />\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"truncate text-sm font-medium text-foreground\">\n                  {upload.fileName}\n                </p>\n                <div className=\"mt-1.5 flex items-center gap-2\">\n                  <Progress\n                    value={upload.progress}\n                    className=\"h-1.5 flex-1\"\n                  />\n                  <span className=\"shrink-0 text-xs tabular-nums text-muted-foreground\">\n                    {upload.progress}%\n                  </span>\n                </div>\n              </div>\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div\n          className={cn(\n            \"flex flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-10 text-center transition-colors\",\n            \"border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500\",\n          )}\n        >\n          <div className=\"mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800\">\n            <FileUp className=\"h-5 w-5 text-muted-foreground\" />\n          </div>\n          <p className=\"text-sm font-medium text-foreground\">\n            Drag & drop files here, or click to select files\n          </p>\n          <p className=\"mt-1 text-xs text-muted-foreground\">\n            Supported: PDF, Office, spreadsheets, images, ZIP, and more\n          </p>\n        </div>\n      )}\n\n      {/* Display rejected files */}\n      {rejectedFiles.length > 0 && (\n        <div className=\"mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-950/30\">\n          <p className=\"text-xs font-medium text-red-600 dark:text-red-400\">\n            Some files were rejected:\n          </p>\n          <ul className=\"mt-1.5 space-y-0.5\">\n            {rejectedFiles.map((file, index) => (\n              <li\n                key={index}\n                className=\"text-xs text-red-500 dark:text-red-400\"\n              >\n                {file.fileName}: {file.message}\n              </li>\n            ))}\n          </ul>\n        </div>\n      )}\n    </ViewerUploadZone>\n  );\n}\n"
  },
  {
    "path": "components/viewer-upload-zone.tsx",
    "content": "import { useCallback } from \"react\";\n\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { FileRejection, useDropzone } from \"react-dropzone\";\nimport { toast } from \"sonner\";\n\nimport { VIEWER_ACCEPTED_FILE_TYPES } from \"@/lib/constants\";\nimport { DocumentData } from \"@/lib/documents/create-document\";\nimport { viewerUpload } from \"@/lib/files/viewer-tus-upload\";\nimport { newId } from \"@/lib/id-helper\";\nimport { cn } from \"@/lib/utils\";\nimport { getSupportedContentType } from \"@/lib/utils/get-content-type\";\nimport { getPagesCount } from \"@/lib/utils/get-page-number-count\";\n\n// File types allowed for viewer uploads\nconst acceptableViewerFileTypes = VIEWER_ACCEPTED_FILE_TYPES;\n\nexport default function ViewerUploadZone({\n  children,\n  onUploadStart,\n  onUploadProgress,\n  onUploadComplete,\n  onUploadRejected,\n  viewerData,\n  teamId,\n  maxFileSize = 350, // 350 MB default, matches paid admin document limits\n  disabled = false,\n}: {\n  children: React.ReactNode;\n  onUploadStart: (\n    uploads: { uploadId: string; fileName: string; progress: number }[],\n  ) => void;\n  onUploadProgress: (uploadId: string, progress: number) => void;\n  onUploadComplete: (documentData: DocumentData, uploadId: string) => void;\n  onUploadRejected: (rejected: { fileName: string; message: string }[]) => void;\n  viewerData: {\n    id: string;\n    linkId: string;\n    dataroomId?: string;\n  };\n  teamId: string;\n  maxFileSize?: number;\n  disabled?: boolean;\n}) {\n  const onDropRejected = useCallback(\n    (rejectedFiles: FileRejection[]) => {\n      const rejected = rejectedFiles.map(({ file, errors }) => {\n        let message = \"\";\n        if (errors.find(({ code }) => code === \"file-too-large\")) {\n          message = `File size too big (max. ${maxFileSize} MB).`;\n        } else if (errors.find(({ code }) => code === \"file-invalid-type\")) {\n          message = `File type not supported.`;\n        }\n        return { fileName: file.name, message };\n      });\n      onUploadRejected(rejected);\n    },\n    [onUploadRejected, maxFileSize],\n  );\n\n  const onDrop = useCallback(\n    async (acceptedFiles: File[]) => {\n      const trackedFiles = acceptedFiles.map((file) => ({\n        uploadId: newId(\"pending\"),\n        file,\n      }));\n\n      const newUploads = trackedFiles.map(({ uploadId, file }) => ({\n        uploadId,\n        fileName: file.name,\n        progress: 0,\n      }));\n\n      onUploadStart(newUploads);\n\n      const uploadPromises = trackedFiles.map(async ({ uploadId, file }) => {\n        // count the number of pages in the file\n        let numPages = 1;\n        if (file.type === \"application/pdf\") {\n          const buffer = await file.arrayBuffer();\n          numPages = await getPagesCount(buffer);\n        }\n\n        const { complete } = await viewerUpload({\n          file,\n          onProgress: (bytesUploaded, bytesTotal) => {\n            const progress = Math.min(\n              Math.round((bytesUploaded / bytesTotal) * 100),\n              99,\n            );\n            onUploadProgress(uploadId, progress);\n          },\n          onError: (error) => {\n            console.error(\"Upload error:\", error);\n            toast.error(`Failed to upload ${file.name}`);\n          },\n          viewerData,\n          teamId,\n          numPages,\n        });\n\n        const uploadResult = await complete;\n\n        let contentType = uploadResult.fileType;\n        let supportedFileType = getSupportedContentType(contentType) ?? \"\";\n\n        if (uploadResult.fileName.endsWith(\".xlsm\")) {\n          supportedFileType = \"sheet\";\n          contentType = \"application/vnd.ms-excel.sheet.macroEnabled.12\";\n        }\n\n        const documentData: DocumentData = {\n          key: uploadResult.id,\n          supportedFileType: supportedFileType,\n          name: file.name,\n          storageType: DocumentStorageType.S3_PATH,\n          contentType: contentType,\n          fileSize: file.size,\n          numPages: numPages,\n        };\n\n        onUploadComplete(documentData, uploadId);\n\n        onUploadProgress(uploadId, 100); // Mark upload as complete\n\n        return uploadResult;\n      });\n\n      try {\n        await Promise.all(uploadPromises);\n        toast.success(\"File upload complete!\");\n      } catch (error) {\n        console.error(\"Upload error:\", error);\n        toast.error(\"An error occurred during upload\");\n      }\n    },\n    [onUploadStart, onUploadProgress, onUploadComplete, viewerData, teamId],\n  );\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    accept: acceptableViewerFileTypes,\n    multiple: true,\n    maxSize: maxFileSize * 1024 * 1024,\n    onDrop,\n    onDropRejected,\n    disabled,\n  });\n\n  return (\n    <div {...getRootProps()} className=\"relative min-h-[200px]\">\n      <div\n        className={cn(\n          \"absolute inset-0 z-40 -m-1 rounded-lg border-2 border-dashed\",\n          isDragActive\n            ? \"pointer-events-auto border-primary/50 bg-gray-100/75 backdrop-blur-sm dark:bg-gray-800/75\"\n            : \"pointer-events-none border-none\",\n        )}\n      >\n        <input\n          {...getInputProps()}\n          name=\"file\"\n          id=\"viewer-upload-files-zone\"\n          className=\"sr-only\"\n        />\n\n        {isDragActive && (\n          <div className=\"flex h-full items-center justify-center\">\n            <div className=\"inline-flex flex-col rounded-lg bg-background/95 px-6 py-4 text-center ring-1 ring-gray-900/5 dark:bg-gray-900/95 dark:ring-white/10\">\n              <span className=\"font-medium text-foreground\">\n                Drop your file(s) here\n              </span>\n              <p className=\"mt-1 text-xs leading-5 text-muted-foreground\">\n                PDF, Office, images, ZIP, and more\n              </p>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/contacts-document-table.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport {\n  ColumnDef,\n  ExpandedState,\n  flexRender,\n  getCoreRowModel,\n  getExpandedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport {\n  ChevronDownIcon,\n  ChevronUpIcon,\n  ChevronsUpDownIcon,\n} from \"lucide-react\";\n\nimport { durationFormat, timeAgo } from \"@/lib/utils\";\nimport { fileIcon } from \"@/lib/utils/get-file-icon\";\n\nimport { Pagination } from \"@/components/documents/pagination\";\nimport { Button } from \"@/components/ui/button\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\n\ntype ViewerView = {\n  documentId: string;\n  document: {\n    name: string | null;\n    type: string | null;\n  };\n  lastViewed: Date;\n  totalDuration: number;\n  viewCount: number;\n};\n\nexport function ContactsDocumentsTable({\n  views,\n  durations = {},\n  loadingDurations = false,\n  pagination,\n  sorting,\n  onPageChange,\n  onPageSizeChange,\n  onSortChange,\n}: {\n  views: ViewerView[] | null | undefined;\n  durations?: Record<string, number>;\n  loadingDurations?: boolean;\n  pagination?: {\n    currentPage: number;\n    pageSize: number;\n    totalItems: number;\n    totalPages: number;\n    hasNext: boolean;\n    hasPrev: boolean;\n  };\n  sorting?: {\n    sortBy: string;\n    sortOrder: string;\n  };\n  onPageChange?: (page: number) => void;\n  onPageSizeChange?: (size: number) => void;\n  onSortChange?: (sortBy: string, sortOrder: string) => void;\n}) {\n  const router = useRouter();\n  const [expanded, setExpanded] = useState<ExpandedState>({});\n\n  const data = useMemo(() => views || [], [views]);\n\n  const handlePageChange = (page: number) => {\n    if (onPageChange) {\n      onPageChange(page);\n    }\n  };\n\n  const handlePageSizeChange = (size: number) => {\n    if (onPageSizeChange) {\n      onPageSizeChange(size);\n    }\n  };\n\n  const handleSort = useCallback(\n    (columnId: string) => {\n      if (!onSortChange) return;\n\n      const currentSortBy = sorting?.sortBy;\n      const currentSortOrder = sorting?.sortOrder;\n\n      let newSortOrder = \"desc\";\n      if (currentSortBy === columnId) {\n        if (currentSortOrder === \"desc\") {\n          newSortOrder = \"asc\";\n        } else if (currentSortOrder === \"asc\") {\n          onSortChange(\"lastViewed\", \"desc\");\n          return;\n        }\n      }\n\n      onSortChange(columnId, newSortOrder);\n    },\n    [onSortChange, sorting?.sortBy, sorting?.sortOrder],\n  );\n\n  const getSortIcon = useCallback(\n    (columnId: string) => {\n      const currentSortBy = sorting?.sortBy;\n      const currentSortOrder = sorting?.sortOrder;\n\n      if (currentSortBy !== columnId) {\n        return <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />;\n      }\n\n      return currentSortOrder === \"asc\" ? (\n        <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n      ) : (\n        <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n      );\n    },\n    [sorting?.sortBy, sorting?.sortOrder],\n  );\n\n  const getSortClass = useCallback(\n    (columnId: string) => {\n      const currentSortBy = sorting?.sortBy;\n      return currentSortBy === columnId\n        ? \"text-nowrap font-medium\"\n        : \"text-nowrap font-normal\";\n    },\n    [sorting?.sortBy],\n  );\n\n  const columns: ColumnDef<ViewerView>[] = useMemo(\n    () => [\n      {\n        accessorKey: \"document\",\n        header: \"Document Name\",\n        cell: ({ row }) => {\n          const view = row.original;\n          return (\n            <div className=\"flex items-center overflow-visible sm:space-x-3\">\n              {fileIcon({\n                fileType: view.document.type ?? \"\",\n                className: \"h-7 w-7\",\n                isLight: true,\n              })}\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"focus:outline-none\">\n                  <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                    {view.document.name}\n                  </p>\n                </div>\n              </div>\n            </div>\n          );\n        },\n      },\n      {\n        accessorKey: \"lastViewed\",\n        header: ({ column }) => {\n          return (\n            <Button\n              variant=\"ghost\"\n              onClick={() => handleSort(\"lastViewed\")}\n              className={getSortClass(\"lastViewed\")}\n            >\n              Last Viewed\n              {getSortIcon(\"lastViewed\")}\n            </Button>\n          );\n        },\n        cell: ({ row }) => {\n          const view = row.original;\n          return (\n            <TimestampTooltip\n              timestamp={view.lastViewed}\n              side=\"right\"\n              rows={[\"local\", \"utc\", \"unix\"]}\n            >\n              <time\n                className=\"select-none\"\n                dateTime={new Date(view.lastViewed).toISOString()}\n              >\n                {timeAgo(view.lastViewed)}\n              </time>\n            </TimestampTooltip>\n          );\n        },\n        sortingFn: (rowA, rowB) => {\n          return (\n            new Date(rowB.original.lastViewed).getTime() -\n            new Date(rowA.original.lastViewed).getTime()\n          );\n        },\n      },\n      {\n        accessorKey: \"totalDuration\",\n        header: ({ column }) => {\n          return (\n            <Button\n              variant=\"ghost\"\n              onClick={() => handleSort(\"totalDuration\")}\n              className={getSortClass(\"totalDuration\")}\n            >\n              Time Spent\n              {getSortIcon(\"totalDuration\")}\n            </Button>\n          );\n        },\n        cell: ({ row }) => {\n          const view = row.original;\n          const duration = durations[view.documentId] ?? view.totalDuration;\n          const isLoading = loadingDurations && !(view.documentId in durations);\n\n          if (isLoading) {\n            return <Skeleton className=\"h-4 w-16\" />;\n          }\n\n          return (\n            <div className=\"text-sm text-muted-foreground\">\n              {durationFormat(duration)}\n            </div>\n          );\n        },\n      },\n      {\n        accessorKey: \"viewCount\",\n        header: ({ column }) => {\n          return (\n            <Button\n              variant=\"ghost\"\n              onClick={() => handleSort(\"viewCount\")}\n              className={getSortClass(\"viewCount\")}\n            >\n              Visits\n              {getSortIcon(\"viewCount\")}\n            </Button>\n          );\n        },\n        cell: ({ row }) => {\n          const view = row.original;\n          return <span>{view.viewCount}</span>;\n        },\n      },\n    ],\n    [durations, loadingDurations, handleSort, getSortIcon, getSortClass],\n  );\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getExpandedRowModel: getExpandedRowModel(),\n    onExpandedChange: setExpanded,\n    state: {\n      expanded,\n    },\n    manualPagination: true,\n  });\n\n  if (!views) {\n    return (\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead>Document Name</TableHead>\n              <TableHead>Last Viewed</TableHead>\n              <TableHead>Time Spent</TableHead>\n              <TableHead>Views</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {[...Array(5)].map((_, index) => (\n              <TableRow key={index}>\n                <TableCell>\n                  <div className=\"flex items-center space-x-3\">\n                    <Skeleton className=\"h-7 w-7\" />\n                    <Skeleton className=\"h-4 w-[200px]\" />\n                  </div>\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-4 w-[100px]\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-4 w-[80px]\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-4 w-[50px]\" />\n                </TableCell>\n              </TableRow>\n            ))}\n          </TableBody>\n        </Table>\n      </div>\n    );\n  }\n\n  const handleRowClick = (id: string) => {\n    router.push(`/documents/${id}`);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id} className=\"px-0 first:px-4\">\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext(),\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  key={row.id}\n                  onClick={() => handleRowClick(row.original.documentId)}\n                  className=\"cursor-pointer\"\n                >\n                  {row.getVisibleCells().map((cell) => {\n                    return (\n                      <TableCell key={cell.id}>\n                        {flexRender(\n                          cell.column.columnDef.cell,\n                          cell.getContext(),\n                        )}\n                      </TableCell>\n                    );\n                  })}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  No documents viewed yet.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      {pagination && pagination.totalItems > 0 && (\n        <Pagination\n          currentPage={pagination.currentPage}\n          pageSize={pagination.pageSize}\n          totalItems={pagination.totalItems}\n          totalPages={pagination.totalPages}\n          onPageChange={handlePageChange}\n          onPageSizeChange={handlePageSizeChange}\n          totalShownItems={data.length}\n          itemName=\"documents\"\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/contacts-table.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useMemo } from \"react\";\nimport React from \"react\";\n\nimport {\n  ColumnDef,\n  flexRender,\n  getCoreRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport {\n  ChevronDownIcon,\n  ChevronUpIcon,\n  ChevronsUpDownIcon,\n} from \"lucide-react\";\n\nimport { timeAgo } from \"@/lib/utils\";\n\nimport { Pagination } from \"@/components/documents/pagination\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { VisitorAvatar } from \"@/components/visitors/visitor-avatar\";\n\nimport { Skeleton } from \"../ui/skeleton\";\n\ntype Viewer = {\n  id: string;\n  email: string;\n  createdAt: Date;\n  updatedAt: Date;\n  totalVisits: number;\n  lastViewed: Date | null;\n  viewerName?: string | null;\n};\n\nexport function ContactsTable({\n  viewers,\n  pagination,\n  sorting,\n  onPageChange,\n  onPageSizeChange,\n  onSortChange,\n}: {\n  viewers: Viewer[] | null | undefined;\n  pagination?: {\n    currentPage: number;\n    pageSize: number;\n    totalItems: number;\n    totalPages: number;\n    hasNext: boolean;\n    hasPrev: boolean;\n  };\n  sorting?: {\n    sortBy: string;\n    sortOrder: string;\n  };\n  onPageChange?: (page: number) => void;\n  onPageSizeChange?: (size: number) => void;\n  onSortChange?: (sortBy: string, sortOrder: string) => void;\n}) {\n  const router = useRouter();\n\n  const data = useMemo(() => viewers || [], [viewers]);\n\n  const handlePageChange = (page: number) => {\n    if (onPageChange) {\n      onPageChange(page);\n    }\n  };\n\n  const handlePageSizeChange = (size: number) => {\n    if (onPageSizeChange) {\n      onPageSizeChange(size);\n    }\n  };\n\n  const handleSort = useCallback(\n    (columnId: string) => {\n      if (!onSortChange) return;\n\n      const currentSortBy = sorting?.sortBy;\n      const currentSortOrder = sorting?.sortOrder;\n\n      if (currentSortBy === columnId) {\n        if (columnId === \"lastViewed\") {\n          if (currentSortOrder === \"asc\") {\n            onSortChange(\"lastViewed\", \"desc\");\n          } else {\n            onSortChange(\"lastViewed\", \"asc\");\n          }\n        } else {\n          if (currentSortOrder === \"asc\") {\n            onSortChange(columnId, \"desc\");\n          } else if (currentSortOrder === \"desc\") {\n            onSortChange(\"lastViewed\", \"desc\");\n          }\n        }\n      } else {\n        onSortChange(columnId, \"asc\");\n      }\n    },\n    [onSortChange, sorting?.sortBy, sorting?.sortOrder],\n  );\n\n  const getSortIcon = useCallback(\n    (columnId: string) => {\n      const currentSortBy = sorting?.sortBy;\n      const currentSortOrder = sorting?.sortOrder;\n\n      if (currentSortBy !== columnId) {\n        return <ChevronsUpDownIcon className=\"ml-2 h-4 w-4\" />;\n      }\n\n      return currentSortOrder === \"asc\" ? (\n        <ChevronUpIcon className=\"ml-2 h-4 w-4\" />\n      ) : (\n        <ChevronDownIcon className=\"ml-2 h-4 w-4\" />\n      );\n    },\n    [sorting?.sortBy, sorting?.sortOrder],\n  );\n\n  const getSortClass = useCallback(\n    (columnId: string) => {\n      const currentSortBy = sorting?.sortBy;\n      return currentSortBy === columnId\n        ? \"text-nowrap font-medium\"\n        : \"text-nowrap font-normal\";\n    },\n    [sorting?.sortBy],\n  );\n\n  const columns: ColumnDef<Viewer>[] = useMemo(\n    () => [\n      {\n        accessorKey: \"email\",\n        header: \"Contact\",\n        cell: ({ row }) => (\n          <div className=\"flex items-center overflow-visible sm:space-x-3\">\n            <VisitorAvatar viewerEmail={row.original.email} />\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"focus:outline-none\">\n                <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                  {row.original.viewerName || row.original.email}\n                </p>\n                {row.original.viewerName && row.original.email && (\n                  <p className=\"text-xs text-muted-foreground/60\">\n                    {row.original.email}\n                  </p>\n                )}\n              </div>\n            </div>\n          </div>\n        ),\n      },\n      {\n        accessorKey: \"lastViewed\",\n        header: ({ column }) => {\n          return (\n            <Button\n              variant=\"ghost\"\n              onClick={() => handleSort(\"lastViewed\")}\n              className={getSortClass(\"lastViewed\")}\n            >\n              Last Viewed\n              {getSortIcon(\"lastViewed\")}\n            </Button>\n          );\n        },\n        cell: ({ row }) => {\n          const lastView = row.original.lastViewed;\n          return lastView ? (\n            <TimestampTooltip\n              timestamp={lastView}\n              side=\"right\"\n              rows={[\"local\", \"utc\", \"unix\"]}\n            >\n              <time\n                className=\"select-none text-sm text-muted-foreground\"\n                dateTime={new Date(lastView).toISOString()}\n              >\n                {timeAgo(lastView)}\n              </time>\n            </TimestampTooltip>\n          ) : (\n            <div className=\"text-sm text-muted-foreground\">-</div>\n          );\n        },\n      },\n      {\n        accessorKey: \"totalVisits\",\n        header: ({ column }) => {\n          return (\n            <Button\n              variant=\"ghost\"\n              onClick={() => handleSort(\"totalVisits\")}\n              className={getSortClass(\"totalVisits\")}\n            >\n              Total Views\n              {getSortIcon(\"totalVisits\")}\n            </Button>\n          );\n        },\n        cell: ({ row }) => (\n          <div className=\"text-sm text-muted-foreground\">\n            {row.original.totalVisits}\n          </div>\n        ),\n      },\n    ],\n    [handleSort, getSortIcon, getSortClass],\n  );\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    manualPagination: true,\n  });\n\n  if (!viewers) {\n    return (\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            <TableRow>\n              <TableHead>Name</TableHead>\n              <TableHead>Last Viewed</TableHead>\n              <TableHead>Total Views</TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {[...Array(5)].map((_, index) => (\n              <TableRow key={index}>\n                <TableCell>\n                  <div className=\"flex items-center space-x-3\">\n                    <Skeleton className=\"h-10 w-10 rounded-full\" />\n                    <Skeleton className=\"h-4 w-[200px]\" />\n                  </div>\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-4 w-[100px]\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-4 w-[50px]\" />\n                </TableCell>\n              </TableRow>\n            ))}\n          </TableBody>\n        </Table>\n      </div>\n    );\n  }\n\n  const handleRowClick = (id: string) => {\n    router.push(`/visitors/${id}`);\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            {table.getHeaderGroups().map((headerGroup) => (\n              <TableRow key={headerGroup.id}>\n                {headerGroup.headers.map((header) => (\n                  <TableHead key={header.id} className=\"px-0 first:px-4\">\n                    {header.isPlaceholder\n                      ? null\n                      : flexRender(\n                          header.column.columnDef.header,\n                          header.getContext(),\n                        )}\n                  </TableHead>\n                ))}\n              </TableRow>\n            ))}\n          </TableHeader>\n          <TableBody>\n            {table.getRowModel().rows?.length ? (\n              table.getRowModel().rows.map((row) => (\n                <TableRow\n                  key={row.id}\n                  onClick={() => handleRowClick(row.original.id)}\n                  className=\"cursor-pointer\"\n                >\n                  {row.getVisibleCells().map((cell) => {\n                    return (\n                      <TableCell key={cell.id}>\n                        {flexRender(\n                          cell.column.columnDef.cell,\n                          cell.getContext(),\n                        )}\n                      </TableCell>\n                    );\n                  })}\n                </TableRow>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell\n                  colSpan={columns.length}\n                  className=\"h-24 text-center\"\n                >\n                  No visitors yet.\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      {pagination && pagination.totalItems > 0 && (\n        <Pagination\n          currentPage={pagination.currentPage}\n          pageSize={pagination.pageSize}\n          totalItems={pagination.totalItems}\n          totalPages={pagination.totalPages}\n          onPageChange={handlePageChange}\n          onPageSizeChange={handlePageSizeChange}\n          totalShownItems={data.length}\n          itemName=\"visitors\"\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/data-table-pagination.tsx",
    "content": "import { Table } from \"@tanstack/react-table\";\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  ChevronsLeftIcon,\n  ChevronsRightIcon,\n} from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\ninterface DataTablePaginationProps<TData> {\n  table: Table<TData>;\n  name: string;\n}\n\nexport function DataTablePagination<TData>({\n  table,\n  name,\n}: DataTablePaginationProps<TData>) {\n  const { pageSize, pageIndex } = table.getState().pagination;\n  const totalRows = table.getFilteredRowModel().rows.length;\n  const visibleRows = table.getRowModel().rows.length;\n\n  return (\n    <div className=\"flex items-center justify-between\">\n      <div className=\"flex-1 text-sm text-muted-foreground\">\n        {visibleRows} of {totalRows} {name}s shown\n      </div>\n      <div className=\"flex items-center space-x-6 lg:space-x-8\">\n        <div className=\"hidden items-center space-x-2 sm:flex\">\n          <p className=\"text-sm font-medium\">\n            {name.charAt(0).toUpperCase() + name.slice(1)}s per page\n          </p>\n          <Select\n            value={`${pageSize}`}\n            onValueChange={(value) => {\n              table.setPageSize(Number(value));\n            }}\n          >\n            <SelectTrigger className=\"h-8 w-[70px]\">\n              <SelectValue placeholder={pageSize} />\n            </SelectTrigger>\n            <SelectContent side=\"top\">\n              {[10, 20, 30, 40, 50].map((size) => (\n                <SelectItem key={size} value={`${size}`}>\n                  {size}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n        <div className=\"flex w-[100px] items-center justify-end text-sm font-medium\">\n          Page {pageIndex + 1} of {table.getPageCount()}\n        </div>\n        <div className=\"flex items-center space-x-2\">\n          <Button\n            variant=\"outline\"\n            className=\"hidden h-8 w-8 p-0 lg:flex\"\n            onClick={() => table.setPageIndex(0)}\n            disabled={!table.getCanPreviousPage()}\n          >\n            <span className=\"sr-only\">Go to first page</span>\n            <ChevronsLeftIcon className=\"h-4 w-4\" />\n          </Button>\n          <Button\n            variant=\"outline\"\n            className=\"h-8 w-8 p-0\"\n            onClick={() => table.previousPage()}\n            disabled={!table.getCanPreviousPage()}\n          >\n            <span className=\"sr-only\">Go to previous page</span>\n            <ChevronLeftIcon className=\"h-4 w-4\" />\n          </Button>\n          <Button\n            variant=\"outline\"\n            className=\"h-8 w-8 p-0\"\n            onClick={() => table.nextPage()}\n            disabled={!table.getCanNextPage()}\n          >\n            <span className=\"sr-only\">Go to next page</span>\n            <ChevronRightIcon className=\"h-4 w-4\" />\n          </Button>\n          <Button\n            variant=\"outline\"\n            className=\"hidden h-8 w-8 p-0 lg:flex\"\n            onClick={() => table.setPageIndex(table.getPageCount() - 1)}\n            disabled={!table.getCanNextPage()}\n          >\n            <span className=\"sr-only\">Go to last page</span>\n            <ChevronsRightIcon className=\"h-4 w-4\" />\n          </Button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/dataroom-view-stats.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport {\n  ArrowUpRightIcon,\n  ChevronRightIcon,\n  DownloadCloudIcon,\n  FileCheckIcon,\n  FileIcon,\n  UploadCloudIcon,\n} from \"lucide-react\";\n\nimport { useDataroomVisitHistory } from \"@/lib/swr/use-dataroom\";\nimport {\n  DocumentViewStats,\n  useDataroomViewDocumentStats,\n} from \"@/lib/swr/use-dataroom-view-document-stats\";\nimport { cn, durationFormat, timeAgo } from \"@/lib/utils\";\n\nimport { Gauge } from \"@/components/ui/gauge\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { TableCell, TableRow } from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\n\nimport { DocumentPageChart } from \"./document-view-stats\";\n\nexport function DataroomViewStats({\n  viewId,\n  dataroomId,\n  isExpanded,\n}: {\n  viewId: string;\n  dataroomId: string;\n  isExpanded: boolean;\n}) {\n  const { documentViews, uploadedDocumentViews } = useDataroomVisitHistory({\n    viewId,\n    dataroomId,\n  });\n\n  const { documentStats, loading: statsLoading } = useDataroomViewDocumentStats(\n    {\n      dataroomId,\n      dataroomViewId: viewId,\n      enabled: isExpanded,\n    },\n  );\n\n  const statsMap = new Map<string, DocumentViewStats>();\n  documentStats?.forEach((s) => {\n    statsMap.set(s.viewId, s);\n  });\n\n  const groupedViews = documentViews\n    ? documentViews.reduce(\n        (acc, view) => {\n          if (view.downloadType === \"BULK\" || view.downloadType === \"FOLDER\") {\n            const key = `${view.downloadType}-${new Date(view.viewedAt).toISOString()}`;\n            if (!acc.bulkDownloads[key]) {\n              acc.bulkDownloads[key] = {\n                type: view.downloadType,\n                viewedAt: view.viewedAt,\n                downloadedAt: view.downloadedAt,\n                metadata: view.downloadMetadata,\n                documents: [],\n              };\n            }\n            acc.bulkDownloads[key].documents.push(view);\n          } else {\n            acc.individualViews.push(view);\n          }\n          return acc;\n        },\n        {\n          individualViews: [] as typeof documentViews,\n          bulkDownloads: {} as Record<\n            string,\n            {\n              type: string;\n              viewedAt: string;\n              downloadedAt: string;\n              metadata?: {\n                folderName?: string;\n                folderPath?: string;\n                dataroomName?: string;\n                documentCount?: number;\n                documents?: {\n                  id: string;\n                  name: string;\n                }[];\n              };\n              documents: typeof documentViews;\n            }\n          >,\n        },\n      )\n    : { individualViews: [], bulkDownloads: {} };\n\n  const allEvents: Array<{\n    type: \"upload\" | \"view\" | \"download\" | \"bulk-download\";\n    timestamp: Date;\n    data: any;\n  }> = [];\n\n  uploadedDocumentViews?.forEach((upload) => {\n    allEvents.push({\n      type: \"upload\",\n      timestamp: new Date(upload.uploadedAt),\n      data: upload,\n    });\n  });\n\n  Object.entries(groupedViews.bulkDownloads).forEach(([_key, bulkGroup]) => {\n    allEvents.push({\n      type: \"bulk-download\",\n      timestamp: new Date(bulkGroup.downloadedAt),\n      data: bulkGroup,\n    });\n  });\n\n  groupedViews.individualViews.forEach((view) => {\n    const viewedAtTime = new Date(view.viewedAt).getTime();\n    const downloadedAtTime = view.downloadedAt\n      ? new Date(view.downloadedAt).getTime()\n      : null;\n    const isDownloadOnly =\n      downloadedAtTime && Math.abs(viewedAtTime - downloadedAtTime) < 1000;\n\n    if (!isDownloadOnly) {\n      allEvents.push({\n        type: \"view\",\n        timestamp: new Date(view.viewedAt),\n        data: view,\n      });\n    }\n\n    if (view.downloadedAt) {\n      allEvents.push({\n        type: \"download\",\n        timestamp: new Date(view.downloadedAt),\n        data: view,\n      });\n    }\n  });\n\n  allEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());\n\n  return (\n    <>\n      {allEvents.length > 0 ? (\n        allEvents.map((event, index) => {\n          if (event.type === \"upload\") {\n            return (\n              <UploadRow\n                key={`upload-${event.data.documentId}-${index}`}\n                upload={event.data}\n                timestamp={event.timestamp}\n              />\n            );\n          }\n\n          if (event.type === \"bulk-download\") {\n            return (\n              <BulkDownloadRow\n                key={`bulk-${index}`}\n                bulkGroup={event.data}\n                timestamp={event.timestamp}\n              />\n            );\n          }\n\n          if (event.type === \"view\") {\n            const view = event.data;\n            const stats = statsMap.get(view.id);\n            return (\n              <DocumentViewRow\n                key={`view-${view.id}`}\n                view={view}\n                stats={stats}\n                statsLoading={statsLoading}\n                dataroomId={dataroomId}\n                dataroomViewId={viewId}\n                timestamp={event.timestamp}\n              />\n            );\n          }\n\n          if (event.type === \"download\") {\n            const view = event.data;\n            return (\n              <DownloadRow\n                key={`download-${view.id}`}\n                view={view}\n                timestamp={event.timestamp}\n              />\n            );\n          }\n\n          return null;\n        })\n      ) : documentViews === undefined ? (\n        <TableRow className=\"[&>td]:py-3\">\n          <TableCell>\n            <Skeleton className=\"h-6 w-24\" />\n          </TableCell>\n          <TableCell>\n            <Skeleton className=\"h-6 w-16\" />\n          </TableCell>\n          <TableCell>\n            <Skeleton className=\"h-6 w-6 rounded-full\" />\n          </TableCell>\n          <TableCell>\n            <Skeleton className=\"h-6 w-16\" />\n          </TableCell>\n          <TableCell />\n        </TableRow>\n      ) : null}\n    </>\n  );\n}\n\nfunction DocumentViewRow({\n  view,\n  stats,\n  statsLoading,\n  dataroomId,\n  dataroomViewId,\n  timestamp,\n}: {\n  view: any;\n  stats: DocumentViewStats | undefined;\n  statsLoading: boolean;\n  dataroomId: string;\n  dataroomViewId: string;\n  timestamp: Date;\n}) {\n  const [showPageByPage, setShowPageByPage] = useState(false);\n  const hasPages = stats && stats.totalPages > 0;\n\n  return (\n    <>\n      <TableRow className=\"[&>td]:py-3\">\n        <TableCell>\n          <div className=\"flex items-center gap-x-4 overflow-visible\">\n            <FileCheckIcon className=\"h-5 w-5 shrink-0 text-[#fb7a00]\" />\n\n            <div className=\"flex items-center gap-x-2\">\n              <span className=\"truncate\">Viewed {view.document.name}</span>\n              <Link\n                href={`/documents/${view.document.id}`}\n                className=\"shrink-0 text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                <ArrowUpRightIcon className=\"h-4 w-4\" />\n              </Link>\n            </div>\n            {hasPages && (\n              <button\n                onClick={() => setShowPageByPage((prev) => !prev)}\n                className=\"flex shrink-0 items-center gap-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n              >\n                <ChevronRightIcon\n                  className={cn(\n                    \"h-3 w-3 transition-transform\",\n                    showPageByPage && \"rotate-90\",\n                  )}\n                />\n                <span className=\"hidden sm:inline\">page-by-page</span>\n              </button>\n            )}\n          </div>\n        </TableCell>\n        <TableCell>\n          {statsLoading ? (\n            <Skeleton className=\"h-4 w-14\" />\n          ) : stats ? (\n            <span className=\"text-sm text-muted-foreground\">\n              {durationFormat(stats.totalDuration)}\n            </span>\n          ) : null}\n        </TableCell>\n        <TableCell>\n          {statsLoading ? (\n            <Skeleton className=\"h-6 w-6 rounded-full\" />\n          ) : stats ? (\n            <Gauge value={stats.completionRate} size={\"xs\"} showValue={true} />\n          ) : null}\n        </TableCell>\n        <TableCell>\n          <TimestampTooltip\n            timestamp={timestamp}\n            side=\"right\"\n            rows={[\"local\", \"utc\", \"unix\"]}\n          >\n            <time\n              className=\"select-none truncate text-sm text-muted-foreground\"\n              dateTime={timestamp.toISOString()}\n            >\n              {timeAgo(timestamp)}\n            </time>\n          </TimestampTooltip>\n        </TableCell>\n        <TableCell />\n      </TableRow>\n      {showPageByPage && hasPages && (\n        <TableRow>\n          <TableCell colSpan={5} className=\"p-0 px-4 pb-3 pt-0\">\n            <DocumentPageChart\n              dataroomId={dataroomId}\n              dataroomViewId={dataroomViewId}\n              documentViewId={view.id}\n              documentId={view.document.id}\n              totalPages={stats.totalPages}\n              downloadType={view.downloadType}\n              downloadMetadata={view.downloadMetadata}\n            />\n          </TableCell>\n        </TableRow>\n      )}\n    </>\n  );\n}\n\nfunction UploadRow({ upload, timestamp }: { upload: any; timestamp: Date }) {\n  return (\n    <TableRow className=\"[&>td]:py-3\">\n      <TableCell>\n        <div className=\"flex items-center gap-x-4 overflow-visible\">\n          <UploadCloudIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n          <span className=\"truncate\">Uploaded {upload.originalFilename}</span>\n          <Link\n            href={`/documents/${upload.documentId}`}\n            className=\"shrink-0 text-muted-foreground transition-colors hover:text-foreground\"\n          >\n            <ArrowUpRightIcon className=\"h-4 w-4\" />\n          </Link>\n        </div>\n      </TableCell>\n      <TableCell />\n      <TableCell />\n      <TableCell>\n        <TimestampTooltip\n          timestamp={timestamp}\n          side=\"right\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <time\n            className=\"select-none truncate text-sm text-muted-foreground\"\n            dateTime={timestamp.toISOString()}\n          >\n            {timeAgo(timestamp)}\n          </time>\n        </TimestampTooltip>\n      </TableCell>\n      <TableCell />\n    </TableRow>\n  );\n}\n\nfunction DownloadRow({ view, timestamp }: { view: any; timestamp: Date }) {\n  return (\n    <TableRow className=\"[&>td]:py-3\">\n      <TableCell>\n        <div className=\"flex items-center gap-x-4 overflow-visible\">\n          <DownloadCloudIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n          <span className=\"truncate\">Downloaded {view.document.name}</span>\n          <Link\n            href={`/documents/${view.document.id}`}\n            className=\"shrink-0 text-muted-foreground transition-colors hover:text-foreground\"\n          >\n            <ArrowUpRightIcon className=\"h-4 w-4\" />\n          </Link>\n        </div>\n      </TableCell>\n      <TableCell />\n      <TableCell />\n      <TableCell>\n        <TimestampTooltip\n          timestamp={timestamp}\n          side=\"right\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <time\n            className=\"select-none truncate text-sm text-muted-foreground\"\n            dateTime={timestamp.toISOString()}\n          >\n            {timeAgo(timestamp)}\n          </time>\n        </TimestampTooltip>\n      </TableCell>\n      <TableCell />\n    </TableRow>\n  );\n}\n\nfunction BulkDownloadRow({\n  bulkGroup,\n  timestamp,\n}: {\n  bulkGroup: any;\n  timestamp: Date;\n}) {\n  const documentCount =\n    bulkGroup.metadata?.documentCount || bulkGroup.documents.length;\n  const hasDocumentList =\n    bulkGroup.metadata?.documents && bulkGroup.metadata.documents.length > 0;\n\n  return (\n    <TableRow className=\"[&>td]:py-3\">\n      <TableCell>\n        <div className=\"flex items-center gap-x-4 overflow-visible\">\n          <DownloadCloudIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n          <span>\n            Downloaded{\" \"}\n            {hasDocumentList ? (\n              <Popover>\n                <PopoverTrigger asChild>\n                  <button className=\"underline decoration-dotted hover:text-primary\">\n                    {documentCount} document\n                    {documentCount !== 1 ? \"s\" : \"\"}\n                  </button>\n                </PopoverTrigger>\n                <PopoverContent className=\"w-80\">\n                  <div className=\"space-y-2\">\n                    <h4 className=\"font-medium leading-none\">\n                      {bulkGroup.type === \"FOLDER\" &&\n                      bulkGroup.metadata?.folderName\n                        ? `Files in ${bulkGroup.metadata.folderName}`\n                        : `Files in ${bulkGroup.metadata?.dataroomName || \"dataroom\"}`}\n                    </h4>\n                    <ScrollArea className=\"h-[200px] w-full rounded-md border p-2\">\n                      <div className=\"space-y-1\">\n                        {bulkGroup.metadata.documents!.map(\n                          (doc: { id: string; name: string }) => (\n                            <div\n                              key={doc.id}\n                              className=\"flex items-center gap-2 text-sm\"\n                            >\n                              <FileIcon className=\"h-4 w-4 text-muted-foreground\" />\n                              <span className=\"truncate\">{doc.name}</span>\n                            </div>\n                          ),\n                        )}\n                      </div>\n                    </ScrollArea>\n                  </div>\n                </PopoverContent>\n              </Popover>\n            ) : (\n              <>\n                {documentCount} document\n                {documentCount !== 1 ? \"s\" : \"\"}\n              </>\n            )}{\" \"}\n            {bulkGroup.type === \"FOLDER\" && bulkGroup.metadata?.folderName ? (\n              <>\n                from folder{\" \"}\n                <span className=\"font-medium\">\n                  {bulkGroup.metadata.folderName}\n                </span>\n              </>\n            ) : bulkGroup.type === \"BULK\" &&\n              bulkGroup.metadata?.dataroomName ? (\n              <>\n                from{\" \"}\n                <span className=\"font-medium\">\n                  {bulkGroup.metadata.dataroomName}\n                </span>{\" \"}\n                dataroom\n              </>\n            ) : (\n              \"via bulk download\"\n            )}\n          </span>\n        </div>\n      </TableCell>\n      <TableCell />\n      <TableCell />\n      <TableCell>\n        <TimestampTooltip\n          timestamp={timestamp}\n          side=\"right\"\n          rows={[\"local\", \"utc\", \"unix\"]}\n        >\n          <time\n            className=\"select-none truncate text-sm text-muted-foreground\"\n            dateTime={timestamp.toISOString()}\n          >\n            {timeAgo(timestamp)}\n          </time>\n        </TimestampTooltip>\n      </TableCell>\n      <TableCell />\n    </TableRow>\n  );\n}\n"
  },
  {
    "path": "components/visitors/dataroom-viewers.tsx",
    "content": "import { useState } from \"react\";\n\nimport {\n  BadgeCheckIcon,\n  BadgeInfoIcon,\n  DownloadCloudIcon,\n  MailOpenIcon,\n  SendIcon,\n} from \"lucide-react\";\n\nimport { useDataroomViewers } from \"@/lib/swr/use-dataroom\";\nimport { timeAgo } from \"@/lib/utils\";\n\nimport ChevronDown from \"@/components/shared/icons/chevron-down\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport { DataroomViewStats } from \"./dataroom-view-stats\";\nimport { VisitorAvatar } from \"./visitor-avatar\";\n\nexport default function DataroomViewersTable({\n  dataroomId,\n}: {\n  dataroomId: string;\n}) {\n  const { viewers } = useDataroomViewers({ dataroomId });\n  const [expandedViewerIds, setExpandedViewerIds] = useState<Set<string>>(\n    new Set(),\n  );\n\n  const handleOpenChange = (viewerId: string, open: boolean) => {\n    setExpandedViewerIds((prev) => {\n      const next = new Set(prev);\n      if (open) {\n        next.add(viewerId);\n      } else {\n        next.delete(viewerId);\n      }\n      return next;\n    });\n  };\n\n  return (\n    <div className=\"w-full\">\n      <div>\n        <h2 className=\"mb-2 md:mb-4\">All dataroom visitors</h2>\n      </div>\n      <div className=\"rounded-md border\">\n        <Table className=\"table-fixed\">\n          <TableHeader>\n            <TableRow className=\"*:whitespace-nowrap *:font-medium hover:bg-transparent\">\n              <TableHead>Name</TableHead>\n              <TableHead className=\"w-[120px]\">\n                {expandedViewerIds.size > 0 ? \"View Duration\" : null}\n              </TableHead>\n              <TableHead className=\"w-[140px]\">\n                {expandedViewerIds.size > 0 ? \"View Completion\" : null}\n              </TableHead>\n              <TableHead className=\"w-[120px]\">Last Viewed</TableHead>\n              <TableHead className=\"w-[48px]\" />\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {viewers?.length === 0 && (\n              <TableRow>\n                <TableCell colSpan={5}>\n                  <div className=\"flex h-40 w-full items-center justify-center\">\n                    <p>No Data Available</p>\n                  </div>\n                </TableCell>\n              </TableRow>\n            )}\n            {viewers ? (\n              viewers.map((viewer) => (\n                <Collapsible\n                  key={viewer.id}\n                  asChild\n                  onOpenChange={(open) => handleOpenChange(viewer.id, open)}\n                >\n                  <>\n                    <TableRow key={viewer.id} className=\"group/row\">\n                      {/* Name */}\n                      <TableCell className=\"\">\n                        <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                          <VisitorAvatar viewerEmail={viewer.email} />\n                          <div className=\"min-w-0 flex-1\">\n                            <div className=\"focus:outline-none\">\n                              <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                                {viewer.email ? (\n                                  <>\n                                    {(viewer as any).viewerName || viewer.email}{\" \"}\n                                    {viewer.verified && (\n                                      <BadgeTooltip\n                                        content=\"Verified visitor\"\n                                        key={`verified-${viewer.id}`}\n                                      >\n                                        <BadgeCheckIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                                      </BadgeTooltip>\n                                    )}\n                                    {viewer.internal && (\n                                      <BadgeTooltip\n                                        content=\"Internal visitor\"\n                                        key={`internal-${viewer.id}`}\n                                      >\n                                        <BadgeInfoIcon className=\"h-4 w-4 text-blue-500 hover:text-blue-600\" />\n                                      </BadgeTooltip>\n                                    )}\n                                    {viewer.invitedAt && (\n                                      <BadgeTooltip\n                                        content={`Invited ${timeAgo(viewer.invitedAt)}`}\n                                        key={`invited-${viewer.id}`}\n                                      >\n                                        <SendIcon className=\"h-4 w-4 text-sky-500 hover:text-sky-600\" />\n                                      </BadgeTooltip>\n                                    )}\n                                  </>\n                                ) : (\n                                  \"Anonymous\"\n                                )}\n                              </p>\n                              {(viewer as any).viewerName && viewer.email && (\n                                <p className=\"text-xs text-muted-foreground/60\">\n                                  {viewer.email}\n                                </p>\n                              )}\n                            </div>\n                          </div>\n                        </div>\n                      </TableCell>\n                      <TableCell />\n                      <TableCell />\n                      {/* Last Viewed */}\n                      <TableCell className=\"text-sm text-muted-foreground\">\n                        {viewer.lastViewedAt ? (\n                          <TimestampTooltip\n                            timestamp={viewer.lastViewedAt}\n                            side=\"right\"\n                            rows={[\"local\", \"utc\", \"unix\"]}\n                          >\n                            <time\n                              className=\"select-none\"\n                              dateTime={new Date(\n                                viewer.lastViewedAt,\n                              ).toISOString()}\n                            >\n                              {timeAgo(viewer.lastViewedAt)}\n                            </time>\n                          </TimestampTooltip>\n                        ) : (\n                          \"-\"\n                        )}\n                      </TableCell>\n                      {/* Actions */}\n                      <TableCell className=\"cursor-pointer p-0 text-center sm:text-right\">\n                        {viewer.views.length > 0 ? (\n                          <CollapsibleTrigger asChild>\n                            <div className=\"flex justify-end space-x-1 p-5 [&[data-state=open]>svg.chevron]:rotate-180\">\n                              <ChevronDown className=\"chevron h-4 w-4 shrink-0 transition-transform duration-200\" />\n                            </div>\n                          </CollapsibleTrigger>\n                        ) : null}\n                      </TableCell>\n                    </TableRow>\n\n                    {viewer.views.length > 0\n                      ? viewer.views.map((view: any) => (\n                          <CollapsibleContent asChild key={view.id}>\n                            <>\n                              <TableRow key={view.id} className=\"[&>td]:py-3\">\n                                <TableCell>\n                                  <div className=\"flex items-center gap-x-4 overflow-visible\">\n                                    <MailOpenIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n                                    Accessed {viewer.dataroomName} dataroom\n                                  </div>\n                                </TableCell>\n                                <TableCell />\n                                <TableCell />\n                                <TableCell>\n                                  <TimestampTooltip\n                                    timestamp={view.viewedAt}\n                                    side=\"right\"\n                                    rows={[\"local\", \"utc\", \"unix\"]}\n                                  >\n                                    <time\n                                      className=\"select-none truncate text-sm text-muted-foreground\"\n                                      dateTime={new Date(\n                                        view.viewedAt,\n                                      ).toISOString()}\n                                    >\n                                      {timeAgo(view.viewedAt)}\n                                    </time>\n                                  </TimestampTooltip>\n                                </TableCell>\n                                <TableCell />\n                              </TableRow>\n\n                              {view.downloadedAt ? (\n                                <TableRow\n                                  key={`download-${view.id}`}\n                                  className=\"[&>td]:py-3\"\n                                >\n                                  <TableCell>\n                                    <div className=\"flex items-center gap-x-4 overflow-visible\">\n                                      <DownloadCloudIcon className=\"h-5 w-5 text-cyan-500 hover:text-cyan-600\" />\n                                      Downloaded {viewer.dataroomName} dataroom\n                                    </div>\n                                  </TableCell>\n                                  <TableCell />\n                                  <TableCell />\n                                  <TableCell>\n                                    <TimestampTooltip\n                                      timestamp={view.downloadedAt}\n                                      side=\"right\"\n                                      rows={[\"local\", \"utc\", \"unix\"]}\n                                    >\n                                      <time\n                                        className=\"select-none truncate text-sm text-muted-foreground\"\n                                        dateTime={new Date(\n                                          view.downloadedAt,\n                                        ).toISOString()}\n                                      >\n                                        {timeAgo(view.downloadedAt)}\n                                      </time>\n                                    </TimestampTooltip>\n                                  </TableCell>\n                                  <TableCell />\n                                </TableRow>\n                              ) : null}\n\n                              <DataroomViewStats\n                                viewId={view.id}\n                                dataroomId={dataroomId}\n                                isExpanded={expandedViewerIds.has(viewer.id)}\n                              />\n                            </>\n                          </CollapsibleContent>\n                        ))\n                      : null}\n                  </>\n                </Collapsible>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell className=\"min-w-[100px]\">\n                  <Skeleton className=\"h-6 w-full\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-14\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-6 rounded-full\" />\n                </TableCell>\n                <TableCell className=\"min-w-[100px]\">\n                  <Skeleton className=\"h-6 w-24\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-6\" />\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/dataroom-visitor-custom-fields.tsx",
    "content": "import { Fragment } from \"react\";\n\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ntype CustomFieldResponse = {\n  identifier: string;\n  label: string;\n  response: string;\n};\n\nexport default function VisitorCustomFields({\n  viewId,\n  teamId,\n  dataroomId,\n}: {\n  viewId: string;\n  teamId: string;\n  dataroomId: string;\n}) {\n  const { data: customFieldResponse } = useSWR<CustomFieldResponse[] | null>(\n    `/api/teams/${teamId}/datarooms/${dataroomId}/views/${viewId}/custom-fields`,\n    fetcher,\n  );\n\n  if (!customFieldResponse) return null;\n\n  return (\n    <div className=\"space-y-2 px-1.5 pb-2 md:px-2\">\n      <dl className=\"grid grid-cols-[auto_1fr] gap-x-4 gap-y-2\">\n        {customFieldResponse.map((field, index) => (\n          <Fragment key={index}>\n            <dt className=\"text-sm text-muted-foreground\">{field.label}</dt>\n            <dd className=\"text-sm\">{field.response}</dd>\n          </Fragment>\n        ))}\n      </dl>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/dataroom-visitor-useragent.tsx",
    "content": "import { useDataroomVisitorUserAgent } from \"@/lib/swr/use-dataroom-stats\";\n\nimport VisitorUserAgentBase from \"./visitor-useragent-base\";\n\nexport function DataroomVisitorUserAgent({ viewId }: { viewId: string }) {\n  const { userAgent, error } = useDataroomVisitorUserAgent(viewId);\n\n  if (error) {\n    return null;\n  }\n\n  if (!userAgent) {\n    return <div>Loading...</div>;\n  }\n\n  return <VisitorUserAgentBase userAgent={userAgent} />;\n}\n"
  },
  {
    "path": "components/visitors/dataroom-visitors-history.tsx",
    "content": "import Link from \"next/link\";\n\nimport {\n  DownloadCloudIcon,\n  FileCheckIcon,\n  FileIcon,\n  UploadCloudIcon,\n} from \"lucide-react\";\n\nimport { useDataroomVisitHistory } from \"@/lib/swr/use-dataroom\";\nimport { timeAgo } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { TableCell, TableRow } from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\n\nexport default function DataroomVisitHistory({\n  viewId,\n  dataroomId,\n}: {\n  viewId: string;\n  dataroomId: string;\n}) {\n  const { documentViews, uploadedDocumentViews } = useDataroomVisitHistory({\n    viewId,\n    dataroomId,\n  });\n\n  // Group bulk and folder downloads together\n  const groupedViews = documentViews\n    ? documentViews.reduce(\n        (acc, view) => {\n          if (view.downloadType === \"BULK\" || view.downloadType === \"FOLDER\") {\n            // Group bulk/folder downloads by type and timestamp\n            const key = `${view.downloadType}-${new Date(view.viewedAt).toISOString()}`;\n            if (!acc.bulkDownloads[key]) {\n              acc.bulkDownloads[key] = {\n                type: view.downloadType,\n                viewedAt: view.viewedAt,\n                downloadedAt: view.downloadedAt,\n                metadata: view.downloadMetadata,\n                documents: [],\n              };\n            }\n            acc.bulkDownloads[key].documents.push(view);\n          } else {\n            // Keep individual views\n            acc.individualViews.push(view);\n          }\n          return acc;\n        },\n        {\n          individualViews: [] as typeof documentViews,\n          bulkDownloads: {} as Record<\n            string,\n            {\n              type: string;\n              viewedAt: string;\n              downloadedAt: string;\n              metadata?: {\n                folderName?: string;\n                folderPath?: string;\n                dataroomName?: string;\n                documentCount?: number;\n                documents?: {\n                  id: string;\n                  name: string;\n                }[];\n              };\n              documents: typeof documentViews;\n            }\n          >,\n        },\n      )\n    : { individualViews: [], bulkDownloads: {} };\n\n  // Combine and sort all events chronologically (oldest to newest)\n  const allEvents: Array<{\n    type: \"upload\" | \"view\" | \"download\" | \"bulk-download\";\n    timestamp: Date;\n    data: any;\n  }> = [];\n\n  // Add uploads\n  uploadedDocumentViews?.forEach((upload) => {\n    allEvents.push({\n      type: \"upload\",\n      timestamp: new Date(upload.uploadedAt),\n      data: upload,\n    });\n  });\n\n  // Add bulk/folder downloads\n  Object.entries(groupedViews.bulkDownloads).forEach(([key, bulkGroup]) => {\n    allEvents.push({\n      type: \"bulk-download\",\n      timestamp: new Date(bulkGroup.downloadedAt),\n      data: bulkGroup,\n    });\n  });\n\n  // Add individual views\n  groupedViews.individualViews.forEach((view) => {\n    const viewedAtTime = new Date(view.viewedAt).getTime();\n    const downloadedAtTime = view.downloadedAt\n      ? new Date(view.downloadedAt).getTime()\n      : null;\n    const isDownloadOnly =\n      downloadedAtTime && Math.abs(viewedAtTime - downloadedAtTime) < 1000;\n\n    // Add view event (if not download-only)\n    if (!isDownloadOnly) {\n      allEvents.push({\n        type: \"view\",\n        timestamp: new Date(view.viewedAt),\n        data: view,\n      });\n    }\n\n    // Add download event (if exists)\n    if (view.downloadedAt) {\n      allEvents.push({\n        type: \"download\",\n        timestamp: new Date(view.downloadedAt),\n        data: view,\n      });\n    }\n  });\n\n  // Sort chronologically (oldest to newest)\n  allEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());\n\n  return (\n    <>\n      {allEvents.length > 0 ? (\n        allEvents.map((event, index) => {\n          if (event.type === \"upload\") {\n            const upload = event.data;\n            return (\n              <TableRow key={`upload-${upload.documentId}-${index}`}>\n                <TableCell>\n                  <div className=\"flex items-center gap-x-4 overflow-visible\">\n                    <UploadCloudIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n                    Uploaded {upload.originalFilename}\n                  </div>\n                </TableCell>\n                <TableCell>\n                  <TimestampTooltip\n                    timestamp={event.timestamp}\n                    side=\"right\"\n                    rows={[\"local\", \"utc\", \"unix\"]}\n                  >\n                    <time\n                      className=\"select-none truncate text-sm text-muted-foreground\"\n                      dateTime={event.timestamp.toISOString()}\n                    >\n                      {timeAgo(event.timestamp)}\n                    </time>\n                  </TimestampTooltip>\n                </TableCell>\n                <TableCell className=\"table-cell\">\n                  <div className=\"flex items-center justify-end space-x-4\">\n                    <Button size={\"sm\"} variant={\"link\"}>\n                      <Link href={`/documents/${upload.documentId}`}>\n                        See document\n                      </Link>\n                    </Button>\n                  </div>\n                </TableCell>\n              </TableRow>\n            );\n          }\n\n          if (event.type === \"bulk-download\") {\n            const bulkGroup = event.data;\n            const documentCount =\n              bulkGroup.metadata?.documentCount || bulkGroup.documents.length;\n            const hasDocumentList =\n              bulkGroup.metadata?.documents &&\n              bulkGroup.metadata.documents.length > 0;\n\n            return (\n              <TableRow key={`bulk-${index}`}>\n                <TableCell>\n                  <div className=\"flex items-center gap-x-4 overflow-visible\">\n                    <DownloadCloudIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n                    <span>\n                      Downloaded{\" \"}\n                      {hasDocumentList ? (\n                        <Popover>\n                          <PopoverTrigger asChild>\n                            <button className=\"underline decoration-dotted hover:text-primary\">\n                              {documentCount} document\n                              {documentCount !== 1 ? \"s\" : \"\"}\n                            </button>\n                          </PopoverTrigger>\n                          <PopoverContent className=\"w-80\">\n                            <div className=\"space-y-2\">\n                              <h4 className=\"font-medium leading-none\">\n                                {bulkGroup.type === \"FOLDER\" &&\n                                bulkGroup.metadata?.folderName\n                                  ? `Files in ${bulkGroup.metadata.folderName}`\n                                  : `Files in ${bulkGroup.metadata?.dataroomName || \"dataroom\"}`}\n                              </h4>\n                              <ScrollArea className=\"h-[200px] w-full rounded-md border p-2\">\n                                <div className=\"space-y-1\">\n                                  {bulkGroup.metadata.documents!.map(\n                                    (doc: { id: string; name: string }) => (\n                                      <div\n                                        key={doc.id}\n                                        className=\"flex items-center gap-2 text-sm\"\n                                      >\n                                        <FileIcon className=\"h-4 w-4 text-muted-foreground\" />\n                                        <span className=\"truncate\">\n                                          {doc.name}\n                                        </span>\n                                      </div>\n                                    ),\n                                  )}\n                                </div>\n                              </ScrollArea>\n                            </div>\n                          </PopoverContent>\n                        </Popover>\n                      ) : (\n                        <>\n                          {documentCount} document\n                          {documentCount !== 1 ? \"s\" : \"\"}\n                        </>\n                      )}{\" \"}\n                      {bulkGroup.type === \"FOLDER\" &&\n                      bulkGroup.metadata?.folderName ? (\n                        <>\n                          from folder{\" \"}\n                          <span className=\"font-medium\">\n                            {bulkGroup.metadata.folderName}\n                          </span>\n                        </>\n                      ) : bulkGroup.type === \"BULK\" &&\n                        bulkGroup.metadata?.dataroomName ? (\n                        <>\n                          from{\" \"}\n                          <span className=\"font-medium\">\n                            {bulkGroup.metadata.dataroomName}\n                          </span>{\" \"}\n                          dataroom\n                        </>\n                      ) : (\n                        \"via bulk download\"\n                      )}\n                    </span>\n                  </div>\n                </TableCell>\n                <TableCell>\n                  <TimestampTooltip\n                    timestamp={event.timestamp}\n                    side=\"right\"\n                    rows={[\"local\", \"utc\", \"unix\"]}\n                  >\n                    <time\n                      className=\"select-none truncate text-sm text-muted-foreground\"\n                      dateTime={event.timestamp.toISOString()}\n                    >\n                      {timeAgo(event.timestamp)}\n                    </time>\n                  </TimestampTooltip>\n                </TableCell>\n                <TableCell className=\"table-cell\"></TableCell>\n              </TableRow>\n            );\n          }\n\n          if (event.type === \"view\") {\n            const view = event.data;\n            return (\n              <TableRow key={`view-${view.id}`}>\n                <TableCell>\n                  <div className=\"flex items-center gap-x-4 overflow-visible\">\n                    <FileCheckIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n                    Viewed {view.document.name}\n                  </div>\n                </TableCell>\n                <TableCell>\n                  <TimestampTooltip\n                    timestamp={event.timestamp}\n                    side=\"right\"\n                    rows={[\"local\", \"utc\", \"unix\"]}\n                  >\n                    <time\n                      className=\"select-none truncate text-sm text-muted-foreground\"\n                      dateTime={event.timestamp.toISOString()}\n                    >\n                      {timeAgo(event.timestamp)}\n                    </time>\n                  </TimestampTooltip>\n                </TableCell>\n                <TableCell className=\"table-cell\">\n                  <div className=\"flex items-center justify-end space-x-4\">\n                    <Button size={\"sm\"} variant={\"link\"}>\n                      <Link href={`/documents/${view.document.id}`}>\n                        See document\n                      </Link>\n                    </Button>\n                  </div>\n                </TableCell>\n              </TableRow>\n            );\n          }\n\n          if (event.type === \"download\") {\n            const view = event.data;\n            return (\n              <TableRow key={`download-${view.id}`}>\n                <TableCell>\n                  <div className=\"flex items-center gap-x-4 overflow-visible\">\n                    <DownloadCloudIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n                    Downloaded {view.document.name}\n                  </div>\n                </TableCell>\n                <TableCell>\n                  <TimestampTooltip\n                    timestamp={event.timestamp}\n                    side=\"right\"\n                    rows={[\"local\", \"utc\", \"unix\"]}\n                  >\n                    <time\n                      className=\"select-none truncate text-sm text-muted-foreground\"\n                      dateTime={event.timestamp.toISOString()}\n                    >\n                      {timeAgo(event.timestamp)}\n                    </time>\n                  </TimestampTooltip>\n                </TableCell>\n                <TableCell className=\"table-cell\">\n                  <div className=\"flex items-center justify-end space-x-4\">\n                    <Button size={\"sm\"} variant={\"link\"}>\n                      <Link href={`/documents/${view.document.id}`}>\n                        See document\n                      </Link>\n                    </Button>\n                  </div>\n                </TableCell>\n              </TableRow>\n            );\n          }\n\n          return null;\n        })\n      ) : documentViews === undefined ? (\n        <TableRow>\n          <TableCell>\n            <Skeleton className=\"h-6 w-24\" />\n          </TableCell>\n          <TableCell>\n            <Skeleton className=\"h-6 w-16\" />\n          </TableCell>\n        </TableRow>\n      ) : null}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/visitors/dataroom-visitors-table.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  AlertTriangleIcon,\n  BadgeCheckIcon,\n  BadgeInfoIcon,\n  Download,\n  DownloadCloudIcon,\n  FileBadgeIcon,\n  MailOpenIcon,\n} from \"lucide-react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomVisits } from \"@/lib/swr/use-dataroom\";\nimport { timeAgo } from \"@/lib/utils\";\n\nimport ChevronDown from \"@/components/shared/icons/chevron-down\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport { ExportVisitsModal } from \"../datarooms/export-visits-modal\";\nimport { DataroomViewStats } from \"./dataroom-view-stats\";\nimport DataroomVisitorCustomFields from \"./dataroom-visitor-custom-fields\";\nimport { DataroomVisitorUserAgent } from \"./dataroom-visitor-useragent\";\nimport { VisitorAvatar } from \"./visitor-avatar\";\n\nexport default function DataroomVisitorsTable({\n  dataroomId,\n  groupId,\n  name,\n}: {\n  dataroomId: string;\n  groupId?: string;\n  name?: string;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { views, hiddenFromPause } = useDataroomVisits({\n    dataroomId,\n    groupId,\n  });\n  const { dataroom } = useDataroom();\n  const { isPaused } = usePlan();\n  const [exportModalOpen, setExportModalOpen] = useState(false);\n  const [expandedViewIds, setExpandedViewIds] = useState<Set<string>>(\n    new Set(),\n  );\n\n  const exportVisitCounts = () => {\n    setExportModalOpen(true);\n  };\n\n  const handleOpenChange = (viewId: string, open: boolean) => {\n    setExpandedViewIds((prev) => {\n      const next = new Set(prev);\n      if (open) {\n        next.add(viewId);\n      } else {\n        next.delete(viewId);\n      }\n      return next;\n    });\n  };\n\n  return (\n    <div className=\"w-full\">\n      <div className=\"mb-2 flex items-center justify-between md:mb-4\">\n        <h2>All visitors</h2>\n        <Button variant=\"outline\" size=\"sm\" onClick={exportVisitCounts}>\n          <Download className=\"!size-4\" />\n          Export visits\n        </Button>\n      </div>\n      <div className=\"rounded-md border\">\n        <Table className=\"table-fixed\">\n          <TableHeader>\n            <TableRow className=\"*:whitespace-nowrap *:font-medium hover:bg-transparent\">\n              <TableHead>Name</TableHead>\n              <TableHead className=\"w-[120px]\">\n                {expandedViewIds.size > 0 ? \"View Duration\" : null}\n              </TableHead>\n              <TableHead className=\"w-[140px]\">\n                {expandedViewIds.size > 0 ? \"View Completion\" : null}\n              </TableHead>\n              <TableHead className=\"w-[120px]\">Last Viewed</TableHead>\n              <TableHead className=\"w-[48px]\" />\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {views?.length === 0 && hiddenFromPause === 0 && (\n              <TableRow>\n                <TableCell colSpan={5}>\n                  <div className=\"flex h-40 w-full items-center justify-center\">\n                    <p>No views yet. Try sharing a link.</p>\n                  </div>\n                </TableCell>\n              </TableRow>\n            )}\n            {isPaused && hiddenFromPause > 0 && (\n              <>\n                <TableRow>\n                  <TableCell colSpan={5} className=\"text-left sm:text-center\">\n                    <div className=\"flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center\">\n                      <span className=\"flex items-center gap-x-1\">\n                        <AlertTriangleIcon className=\"inline-block h-4 w-4 text-orange-500\" />\n                        {hiddenFromPause} visit\n                        {hiddenFromPause !== 1 ? \"s\" : \"\"} occurred after your\n                        team was paused and{\" \"}\n                        {hiddenFromPause !== 1 ? \"are\" : \"is\"} hidden.{\" \"}\n                      </span>\n                      <Link\n                        href=\"/settings/billing\"\n                        className=\"font-medium text-orange-600 underline hover:text-orange-700\"\n                      >\n                        Unpause subscription to see all visits\n                      </Link>\n                    </div>\n                  </TableCell>\n                </TableRow>\n                {Array.from({ length: hiddenFromPause }).map((_, i) => (\n                  <VisitorBlurred key={i} />\n                ))}\n              </>\n            )}\n            {views ? (\n              views.map((view) => (\n                <Collapsible\n                  key={view.id}\n                  asChild\n                  onOpenChange={(open) => handleOpenChange(view.id, open)}\n                >\n                  <>\n                    <TableRow key={view.id} className=\"group/row\">\n                      {/* Name */}\n                      <TableCell className=\"\">\n                        <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                          <VisitorAvatar viewerEmail={view.viewerEmail} />\n                          <div className=\"min-w-0 flex-1\">\n                            <div className=\"focus:outline-none\">\n                              <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                                {view.viewerEmail ? (\n                                  <>\n                                    {view.viewerName || view.viewerEmail}{\" \"}\n                                    {view.verified && (\n                                      <BadgeTooltip\n                                        content=\"Verified visitor\"\n                                        key={`verified-${view.id}`}\n                                      >\n                                        <BadgeCheckIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                                      </BadgeTooltip>\n                                    )}\n                                    {view.internal && (\n                                      <BadgeTooltip\n                                        content=\"Internal visitor\"\n                                        key={`internal-${view.id}`}\n                                      >\n                                        <BadgeInfoIcon className=\"h-4 w-4 text-blue-500 hover:text-blue-600\" />\n                                      </BadgeTooltip>\n                                    )}\n                                    {view.downloadedAt && (\n                                      <BadgeTooltip\n                                        content={`Downloaded ${timeAgo(view.downloadedAt)}`}\n                                        key={`download-${view.id}`}\n                                      >\n                                        <DownloadCloudIcon className=\"h-4 w-4 text-cyan-500 hover:text-cyan-600\" />\n                                      </BadgeTooltip>\n                                    )}\n                                    {view.agreementResponse && (\n                                      <BadgeTooltip\n                                        content={`Agreed to ${view.agreementResponse.agreement.name}`}\n                                        key={`agreement-${view.id}`}\n                                      >\n                                        <FileBadgeIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                                      </BadgeTooltip>\n                                    )}\n                                  </>\n                                ) : (\n                                  \"Anonymous\"\n                                )}\n                              </p>\n                              {view.viewerName && view.viewerEmail && (\n                                <p className=\"text-xs text-muted-foreground/60\">\n                                  {view.viewerEmail}\n                                </p>\n                              )}\n                              <p className=\"text-xs text-muted-foreground/60 sm:text-sm\">\n                                {view.link.name ? view.link.name : view.linkId}\n                              </p>\n                            </div>\n                          </div>\n                        </div>\n                      </TableCell>\n                      <TableCell />\n                      <TableCell />\n                      {/* Last Viewed */}\n                      <TableCell className=\"text-sm text-muted-foreground\">\n                        <TimestampTooltip\n                          timestamp={view.viewedAt}\n                          side=\"right\"\n                          rows={[\"local\", \"utc\", \"unix\"]}\n                        >\n                          <time\n                            className=\"select-none\"\n                            dateTime={new Date(view.viewedAt).toISOString()}\n                          >\n                            {timeAgo(view.viewedAt)}\n                          </time>\n                        </TimestampTooltip>\n                      </TableCell>\n                      {/* Actions */}\n                      <TableCell className=\"cursor-pointer p-0 text-center sm:text-right\">\n                        <CollapsibleTrigger asChild>\n                          <div className=\"flex justify-end space-x-1 p-5 [&[data-state=open]>svg.chevron]:rotate-180\">\n                            <ChevronDown className=\"chevron h-4 w-4 shrink-0 transition-transform duration-200\" />\n                          </div>\n                        </CollapsibleTrigger>\n                      </TableCell>\n                    </TableRow>\n\n                    <CollapsibleContent asChild>\n                      <>\n                        <TableRow>\n                          <TableCell colSpan={5}>\n                            <DataroomVisitorCustomFields\n                              viewId={view.id}\n                              teamId={view.teamId!}\n                              dataroomId={dataroomId}\n                            />\n                            <DataroomVisitorUserAgent viewId={view.id} />\n                          </TableCell>\n                        </TableRow>\n                        <TableRow key={view.id} className=\"[&>td]:py-3\">\n                          <TableCell>\n                            <div className=\"flex items-center gap-x-4 overflow-visible\">\n                              <MailOpenIcon className=\"h-5 w-5 text-[#fb7a00]\" />\n                              Accessed {view.dataroomName} dataroom\n                            </div>\n                          </TableCell>\n                          <TableCell />\n                          <TableCell />\n                          <TableCell>\n                            <TimestampTooltip\n                              timestamp={view.viewedAt}\n                              side=\"right\"\n                              rows={[\"local\", \"utc\", \"unix\"]}\n                            >\n                              <time\n                                className=\"select-none truncate text-sm text-muted-foreground\"\n                                dateTime={new Date(view.viewedAt).toISOString()}\n                              >\n                                {timeAgo(view.viewedAt)}\n                              </time>\n                            </TimestampTooltip>\n                          </TableCell>\n                          <TableCell />\n                        </TableRow>\n\n                        {view.downloadedAt ? (\n                          <TableRow\n                            key={`download-item-${view.id}`}\n                            className=\"[&>td]:py-3\"\n                          >\n                            <TableCell>\n                              <div className=\"flex items-center gap-x-4 overflow-visible\">\n                                <DownloadCloudIcon className=\"h-5 w-5 text-cyan-500 hover:text-cyan-600\" />\n                                Downloaded {view.dataroomName} dataroom\n                              </div>\n                            </TableCell>\n                            <TableCell />\n                            <TableCell />\n                            <TableCell>\n                              <TimestampTooltip\n                                timestamp={view.downloadedAt}\n                                side=\"right\"\n                                rows={[\"local\", \"utc\", \"unix\"]}\n                              >\n                                <time\n                                  className=\"select-none truncate text-sm text-muted-foreground\"\n                                  dateTime={new Date(\n                                    view.downloadedAt,\n                                  ).toISOString()}\n                                >\n                                  {timeAgo(view.downloadedAt)}\n                                </time>\n                              </TimestampTooltip>\n                            </TableCell>\n                            <TableCell />\n                          </TableRow>\n                        ) : null}\n\n                        <DataroomViewStats\n                          viewId={view.id}\n                          dataroomId={dataroomId}\n                          isExpanded={expandedViewIds.has(view.id)}\n                        />\n                      </>\n                    </CollapsibleContent>\n                  </>\n                </Collapsible>\n              ))\n            ) : (\n              <TableRow>\n                <TableCell className=\"min-w-[100px]\">\n                  <Skeleton className=\"h-6 w-full\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-14\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-6 rounded-full\" />\n                </TableCell>\n                <TableCell className=\"min-w-[100px]\">\n                  <Skeleton className=\"h-6 w-24\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-6\" />\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n\n      {dataroom && teamId && exportModalOpen && (\n        <ExportVisitsModal\n          dataroomId={dataroomId}\n          dataroomName={dataroom.name}\n          teamId={teamId}\n          groupId={groupId}\n          groupName={name}\n          onClose={() => setExportModalOpen(false)}\n        />\n      )}\n    </div>\n  );\n}\n\nconst VisitorBlurred = () => {\n  return (\n    <TableRow className=\"blur-sm\">\n      <TableCell className=\"\">\n        <div className=\"flex items-center overflow-visible sm:space-x-3\">\n          <VisitorAvatar viewerEmail={\"abc@example.org\"} />\n          <div className=\"min-w-0 flex-1\">\n            <div className=\"focus:outline-none\">\n              <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                Anonymous\n              </p>\n              <p className=\"text-xs text-muted-foreground/60 sm:text-sm\">\n                Demo link\n              </p>\n            </div>\n          </div>\n        </div>\n      </TableCell>\n      <TableCell />\n      <TableCell />\n      {/* Last Viewed */}\n      <TableCell className=\"text-sm text-muted-foreground\">\n        <time\n          dateTime={new Date(\n            new Date().getTime() - 30 * 24 * 60 * 60 * 1000,\n          ).toISOString()}\n        >\n          {timeAgo(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000))}\n        </time>\n      </TableCell>\n      {/* Actions */}\n      <TableCell className=\"cursor-pointer p-0 text-center sm:text-right\">\n        <div className=\"flex justify-end space-x-1 p-5 [&[data-state=open]>svg.chevron]:rotate-180\">\n          <ChevronDown className=\"chevron h-4 w-4 shrink-0 transition-transform duration-200\" />\n        </div>\n      </TableCell>\n    </TableRow>\n  );\n};\n"
  },
  {
    "path": "components/visitors/document-view-stats.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nimport { DownloadCloudIcon } from \"lucide-react\";\n\nimport BarChartComponent from \"@/components/charts/bar-chart\";\nimport StatsChartSkeleton from \"@/components/documents/stats-chart-skeleton\";\n\nimport { useDataroomDocumentPageStats } from \"@/lib/swr/use-dataroom-view-document-stats\";\n\nexport function DocumentPageChart({\n  dataroomId,\n  dataroomViewId,\n  documentViewId,\n  documentId,\n  totalPages,\n  downloadType,\n  downloadMetadata,\n}: {\n  dataroomId: string;\n  dataroomViewId: string;\n  documentViewId: string;\n  documentId: string;\n  totalPages: number;\n  downloadType?: \"SINGLE\" | \"BULK\" | \"FOLDER\" | null;\n  downloadMetadata?: {\n    folderName?: string;\n    folderPath?: string;\n    dataroomName?: string;\n    documentCount?: number;\n    documents?: { id: string; name: string }[];\n  } | null;\n}) {\n  const [fetchEnabled, setFetchEnabled] = useState(false);\n  const timerRef = useRef<ReturnType<typeof setTimeout>>();\n\n  useEffect(() => {\n    timerRef.current = setTimeout(() => {\n      setFetchEnabled(true);\n    }, 150);\n    return () => clearTimeout(timerRef.current);\n  }, []);\n\n  const { duration, loading } = useDataroomDocumentPageStats({\n    dataroomId,\n    dataroomViewId,\n    documentViewId,\n    documentId,\n    enabled: fetchEnabled,\n  });\n\n  if (loading || !duration) {\n    return <StatsChartSkeleton className=\"border-none px-0\" />;\n  }\n\n  const hasViewData = duration.data.some((item) => item.sum_duration > 0);\n\n  if (!hasViewData && downloadType) {\n    let downloadMessage = \"\";\n    if (downloadType === \"FOLDER\" && downloadMetadata?.folderName) {\n      downloadMessage = `Downloaded without viewing via folder \"${downloadMetadata.folderName}\"`;\n    } else if (downloadType === \"BULK\") {\n      downloadMessage = \"Downloaded without viewing via bulk download\";\n    } else {\n      downloadMessage = \"Downloaded without viewing\";\n    }\n\n    return (\n      <div className=\"flex items-center gap-2 py-2 text-sm text-muted-foreground\">\n        <DownloadCloudIcon className=\"h-4 w-4\" />\n        <span>{downloadMessage}</span>\n      </div>\n    );\n  }\n\n  let durationData = Array.from({ length: totalPages }, (_, i) => ({\n    pageNumber: (i + 1).toString(),\n    sum_duration: 0,\n  }));\n\n  durationData = durationData.map((item) => {\n    const match = duration.data.find((d) => d.pageNumber === item.pageNumber);\n    return match || item;\n  });\n\n  return (\n    <div className=\"pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n      <BarChartComponent data={durationData} isSum={true} documentId={documentId} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-avatar.tsx",
    "content": "import { ArchiveIcon } from \"lucide-react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\n\nimport { generateGravatarHash } from \"@/lib/utils\";\nimport { cn } from \"@/lib/utils\";\n\nimport { BadgeTooltip } from \"../ui/tooltip\";\n\nexport const VisitorAvatar = ({\n  viewerEmail,\n  isArchived,\n  className,\n}: {\n  viewerEmail: string | null;\n  isArchived?: boolean;\n  className?: string;\n}) => {\n  // Convert email string to a simple hash\n  const hashString = (str: string) => {\n    let hash = 0;\n\n    for (let i = 0; i < str.length; i++) {\n      const char = str.charCodeAt(i);\n      hash = (hash << 5) - hash + char;\n      hash |= 0; // Convert to 32bit integer\n    }\n    return hash;\n  };\n\n  // Get the background color from the email number hash\n  const getColorFromHash = (hash: number): string => {\n    // An array of colors you want to choose from\n    const colors = [\n      \"to-gray-200/50\",\n      \"to-gray-300/50\",\n      \"to-gray-400/50\",\n      \"to-gray-500/50\",\n      \"to-gray-600/50\",\n    ];\n\n    // Use the hash to get an index for the colors array\n    const index = Math.abs(hash) % colors.length;\n    return colors[index];\n  };\n\n  if (isArchived) {\n    return (\n      <BadgeTooltip\n        key=\"archived\"\n        content=\"Visit is archived and excluded from the document statistics\"\n      >\n        <Avatar\n          className={cn(\"hidden flex-shrink-0 sm:inline-flex\", className)}\n        >\n          <AvatarFallback className=\"bg-gray-200/50 dark:bg-gray-200/50\">\n            <ArchiveIcon className=\"h-4 w-4\" />\n          </AvatarFallback>\n        </Avatar>\n      </BadgeTooltip>\n    );\n  }\n  if (!viewerEmail) {\n    return (\n      <Avatar className={cn(\"hidden flex-shrink-0 sm:inline-flex\", className)}>\n        <AvatarFallback className=\"bg-gray-200/50 dark:bg-gray-200/50\">\n          AN\n        </AvatarFallback>\n      </Avatar>\n    );\n  }\n\n  return (\n    <Avatar\n      className={cn(\n        \"hidden flex-shrink-0 border border-gray-200 dark:border-gray-800 sm:inline-flex\",\n        className,\n      )}\n    >\n      <AvatarImage\n        src={`https://gravatar.com/avatar/${generateGravatarHash(\n          viewerEmail,\n        )}?s=80&d=404`}\n      />\n\n      <AvatarFallback\n        className={`${getColorFromHash(\n          hashString(viewerEmail),\n        )} dark:${getColorFromHash(hashString(viewerEmail))} border border-white bg-gradient-to-t from-gray-100 p-1 dark:border-gray-900 dark:from-gray-900`}\n      >\n        {viewerEmail?.slice(0, 2).toUpperCase()}\n      </AvatarFallback>\n    </Avatar>\n  );\n};\n"
  },
  {
    "path": "components/visitors/visitor-chart.tsx",
    "content": "import ErrorPage from \"next/error\";\n\nimport { DownloadCloudIcon } from \"lucide-react\";\n\nimport BarChartComponent from \"@/components/charts/bar-chart\";\n\nimport { useVisitorStats } from \"@/lib/swr/use-stats\";\n\nimport StatsChartSkeleton from \"../documents/stats-chart-skeleton\";\n\nexport default function VisitorChart({\n  documentId,\n  viewId,\n  totalPages = 0,\n  versionNumber,\n  downloadType,\n  downloadMetadata,\n}: {\n  documentId: string;\n  viewId: string;\n  totalPages?: number;\n  versionNumber?: number;\n  downloadType?: \"SINGLE\" | \"BULK\" | \"FOLDER\" | null;\n  downloadMetadata?: {\n    folderName?: string;\n    folderPath?: string;\n    dataroomName?: string;\n    documentCount?: number;\n    documents?: {\n      id: string;\n      name: string;\n    }[];\n  } | null;\n}) {\n  const { stats, error } = useVisitorStats(viewId);\n\n  if (error && error.status === 404) {\n    return <ErrorPage statusCode={404} />;\n  }\n\n  if (!stats?.duration.data) {\n    return <StatsChartSkeleton />;\n  }\n\n  // Check if this is a download-only view (no pages viewed)\n  const hasViewData = stats.duration.data.some(\n    (item) => item.sum_duration > 0,\n  );\n\n  // If no view data and it's a download (any type), show a message instead of the graph\n  if (!hasViewData && downloadType) {\n    let downloadMessage = \"\";\n    \n    if (downloadType === \"FOLDER\" && downloadMetadata?.folderName) {\n      downloadMessage = `Downloaded without viewing via dataroom folder \"${downloadMetadata.folderName}\"`;\n    } else if (downloadType === \"BULK\" && downloadMetadata?.dataroomName) {\n      downloadMessage = `Downloaded without viewing via bulk download from \"${downloadMetadata.dataroomName}\" dataroom`;\n    } else if (downloadType === \"BULK\") {\n      downloadMessage = \"Downloaded without viewing via bulk dataroom download\";\n    } else if (downloadType === \"SINGLE\") {\n      downloadMessage = \"Downloaded without viewing\";\n    }\n\n    return (\n      <div className=\"rounded-bl-lg border-b border-l p-4\">\n        <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n          <DownloadCloudIcon className=\"h-4 w-4\" />\n          <span>{downloadMessage}</span>\n        </div>\n      </div>\n    );\n  }\n\n  let durationData = Array.from({ length: totalPages }, (_, i) => ({\n    pageNumber: (i + 1).toString(),\n    sum_duration: 0,\n  }));\n\n  const swrData = stats?.duration;\n\n  durationData = durationData.map((item) => {\n    const swrItem = swrData.data.find(\n      (data) => data.pageNumber === item.pageNumber,\n    );\n    return swrItem ? swrItem : item;\n  });\n\n  return (\n    <div className=\"rounded-bl-lg border-b border-l pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n      <BarChartComponent\n        data={durationData}\n        isSum={true}\n        versionNumber={versionNumber}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-clicks.tsx",
    "content": "import { format } from \"date-fns\";\nimport { ExternalLink } from \"lucide-react\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport { Card, CardContent, CardHeader, CardTitle } from \"../ui/card\";\nimport { Skeleton } from \"../ui/skeleton\";\n\ntype ClickEvent = {\n  timestamp: string;\n  document_id: string;\n  dataroom_id: string | null;\n  view_id: string;\n  page_number: string;\n  version_number: number;\n  href: string;\n};\n\ntype ClickEventsResponse = {\n  data: ClickEvent[];\n};\n\nexport default function VisitorClicks({\n  teamId,\n  documentId,\n  viewId,\n}: {\n  teamId: string;\n  documentId: string;\n  viewId: string;\n}) {\n  const { data: clickEvents, error } = useSWR<ClickEventsResponse>(\n    `/api/teams/${teamId}/documents/${documentId}/views/${viewId}/click-events`,\n    fetcher,\n  );\n\n  if (error) {\n    return (\n      <Card>\n        <CardHeader>\n          <CardTitle className=\"text-base font-medium\">Link Clicks</CardTitle>\n        </CardHeader>\n        <CardContent>\n          <div className=\"text-sm text-red-500\">\n            Failed to load click events\n          </div>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  if (!clickEvents || clickEvents.data.length === 0) {\n    return null;\n  }\n\n  return (\n    <Card>\n      <CardHeader>\n        <CardTitle className=\"text-base font-medium\">Link Clicks</CardTitle>\n      </CardHeader>\n      <CardContent>\n        <div className=\"space-y-4\">\n          {clickEvents.data.map((event, index) => (\n            <div key={index} className=\"flex items-start space-x-3\">\n              <ExternalLink className=\"mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400\" />\n              <div className=\"space-y-1\">\n                <div className=\"text-sm\">\n                  <span className=\"font-medium\">Page {event.page_number}</span>:{\" \"}\n                  <a\n                    href={event.href}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"text-blue-600 hover:underline\"\n                  >\n                    {event.href}\n                  </a>\n                </div>\n                <div className=\"text-xs text-gray-500\">\n                  {format(new Date(event.timestamp), \"MMM d, yyyy HH:mm:ss\")}\n                </div>\n              </div>\n            </div>\n          ))}\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-custom-fields.tsx",
    "content": "import { Fragment } from \"react\";\n\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ntype CustomFieldResponse = {\n  identifier: string;\n  label: string;\n  response: string;\n};\n\nexport default function VisitorCustomFields({\n  viewId,\n  teamId,\n  documentId,\n}: {\n  viewId: string;\n  teamId: string;\n  documentId: string;\n}) {\n  const { data: customFieldResponse } = useSWR<CustomFieldResponse[] | null>(\n    `/api/teams/${teamId}/documents/${documentId}/views/${viewId}/custom-fields`,\n    fetcher,\n  );\n\n  if (!customFieldResponse) return null;\n\n  return (\n    <div className=\"space-y-2 px-1.5 pb-2 md:px-2\">\n      <dl className=\"grid grid-cols-[auto_1fr] gap-x-4 gap-y-2\">\n        {customFieldResponse.map((field, index) => (\n          <Fragment key={index}>\n            <dt className=\"text-sm text-muted-foreground\">{field.label}</dt>\n            <dd className=\"text-sm\">{field.response}</dd>\n          </Fragment>\n        ))}\n      </dl>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-group-modal.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport Link from \"next/link\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { LinkType } from \"@prisma/client\";\nimport {\n  ExternalLinkIcon,\n  FileTextIcon,\n  FolderKanbanIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport useSWR from \"swr\";\n\nimport { fetcher, sanitizeList } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { VisitorGroupWithCount } from \"@/lib/swr/use-visitor-groups\";\n\ntype VisitorGroupLink = {\n  id: string;\n  link: {\n    id: string;\n    name: string | null;\n    linkType: LinkType;\n    documentId: string | null;\n    dataroomId: string | null;\n    document: { id: string; name: string } | null;\n    dataroom: { id: string; name: string } | null;\n  };\n};\n\ntype VisitorGroupDetail = VisitorGroupWithCount & {\n  links: VisitorGroupLink[];\n};\n\nexport function VisitorGroupModal({\n  open,\n  setOpen,\n  existingGroup,\n}: {\n  open: boolean;\n  setOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  existingGroup?: VisitorGroupWithCount | null;\n}) {\n  const [groupName, setGroupName] = useState<string>(\"\");\n  const [emailsInput, setEmailsInput] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { data: existingGroupDetails, isLoading: loadingGroupDetails } =\n    useSWR<VisitorGroupDetail>(\n      open && existingGroup && teamId\n        ? `/api/teams/${teamId}/visitor-groups/${existingGroup.id}`\n        : null,\n      fetcher,\n    );\n\n  useEffect(() => {\n    if (existingGroup) {\n      setGroupName(existingGroup.name);\n      setEmailsInput(existingGroup.emails.join(\"\\n\"));\n    } else {\n      setGroupName(\"\");\n      setEmailsInput(\"\");\n    }\n  }, [existingGroup, open]);\n\n  const handleSubmit = async (event: React.FormEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!groupName || groupName.trim().length < 2) {\n      toast.error(\"Please provide a group name with at least 2 characters.\");\n      return;\n    }\n\n    setLoading(true);\n\n    const emails = sanitizeList(emailsInput);\n\n    try {\n      const url = existingGroup\n        ? `/api/teams/${teamId}/visitor-groups/${existingGroup.id}`\n        : `/api/teams/${teamId}/visitor-groups`;\n\n      const response = await fetch(url, {\n        method: existingGroup ? \"PUT\" : \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ name: groupName.trim(), emails }),\n      });\n\n      if (!response.ok) {\n        const data = await response.json();\n        toast.error(data.error || \"Failed to save visitor group.\");\n        return;\n      }\n\n      toast.success(\n        existingGroup\n          ? \"Visitor group updated successfully!\"\n          : \"Visitor group created successfully!\",\n      );\n\n      mutate(\n        `/api/teams/${teamId}/visitor-groups`,\n      );\n      if (existingGroup) {\n        mutate(`/api/teams/${teamId}/visitor-groups/${existingGroup.id}`);\n      }\n      setOpen(false);\n    } catch (error) {\n      toast.error(\"Error saving visitor group. Please try again.\");\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"sm:max-w-[500px]\">\n        <DialogHeader className=\"text-start\">\n          <DialogTitle>\n            {existingGroup ? \"Edit Visitor Group\" : \"Create Visitor Group\"}\n          </DialogTitle>\n          <DialogDescription>\n            {existingGroup\n              ? \"Update the group name and email list. Changes will apply to all links using this group.\"\n              : \"Create a named group of emails and domains. You can then apply this group to document and data room links.\"}\n          </DialogDescription>\n        </DialogHeader>\n        <form onSubmit={handleSubmit}>\n          <div className=\"space-y-4\">\n            <div>\n              <Label htmlFor=\"group-name\" className=\"opacity-80\">\n                Group Name\n              </Label>\n              <Input\n                id=\"group-name\"\n                placeholder=\"e.g., Series A Investors\"\n                className=\"mt-1 w-full\"\n                value={groupName}\n                onChange={(e) => setGroupName(e.target.value)}\n              />\n            </div>\n            <div>\n              <Label htmlFor=\"group-emails\" className=\"opacity-80\">\n                Emails & Domains\n              </Label>\n              <Textarea\n                id=\"group-emails\"\n                className=\"mt-1 w-full focus:ring-inset\"\n                rows={6}\n                placeholder={`Enter emails or domains, one per line, e.g.\ninvestor@fund.com\npartner@vc.com\n@example.org`}\n                value={emailsInput}\n                onChange={(e) => setEmailsInput(e.target.value)}\n              />\n              <p className=\"mt-1 text-xs text-muted-foreground\">\n                Use @domain.com to allow all emails from a domain.\n              </p>\n            </div>\n\n            {existingGroup && (\n              <div>\n                <p className=\"text-[11px] font-semibold uppercase tracking-widest text-muted-foreground\">\n                  Linked Documents & Datarooms\n                </p>\n                {loadingGroupDetails ? (\n                  <p className=\"mt-1.5 text-xs text-muted-foreground\">\n                    Loading...\n                  </p>\n                ) : existingGroupDetails?.links?.length ? (\n                  <div className=\"mt-1.5 space-y-1\">\n                    {existingGroupDetails.links.map((groupLink) => {\n                      const isDataroom =\n                        groupLink.link.linkType === \"DATAROOM_LINK\";\n                      const entity = isDataroom\n                        ? groupLink.link.dataroom\n                        : groupLink.link.document;\n                      const href = isDataroom\n                        ? groupLink.link.dataroomId\n                          ? `/datarooms/${groupLink.link.dataroomId}/documents`\n                          : null\n                        : groupLink.link.documentId\n                          ? `/documents/${groupLink.link.documentId}`\n                          : null;\n\n                      return (\n                        <div\n                          key={groupLink.id}\n                          className=\"flex items-center justify-between rounded-md border border-gray-200 px-2.5 py-1.5 dark:border-gray-800\"\n                        >\n                          <div className=\"flex min-w-0 items-center gap-2\">\n                            {isDataroom ? (\n                              <FolderKanbanIcon className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" />\n                            ) : (\n                              <FileTextIcon className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" />\n                            )}\n                            <span className=\"truncate text-xs font-medium text-foreground\">\n                              {entity?.name ||\n                                (isDataroom\n                                  ? \"Untitled dataroom\"\n                                  : \"Untitled document\")}\n                            </span>\n                            <span className=\"shrink-0 text-[10px] text-muted-foreground\">\n                              via {groupLink.link.name || \"Untitled link\"}\n                            </span>\n                          </div>\n                          {href && (\n                            <Link\n                              href={href}\n                              className=\"ml-2 shrink-0 text-muted-foreground transition-colors hover:text-foreground\"\n                            >\n                              <ExternalLinkIcon className=\"h-3.5 w-3.5\" />\n                            </Link>\n                          )}\n                        </div>\n                      );\n                    })}\n                  </div>\n                ) : (\n                  <p className=\"mt-1.5 text-xs text-muted-foreground\">\n                    Not linked to any documents or datarooms yet.\n                  </p>\n                )}\n              </div>\n            )}\n          </div>\n          <DialogFooter className=\"mt-4\">\n            <Button type=\"submit\" loading={loading} className=\"h-9 w-full\">\n              {existingGroup ? \"Update Group\" : \"Create Group\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-groups-section.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport type { LinkType } from \"@prisma/client\";\nimport {\n  ChevronDownIcon,\n  ExternalLinkIcon,\n  FileIcon,\n  GlobeIcon,\n  LinkIcon,\n  MailIcon,\n  MoreVerticalIcon,\n  PencilIcon,\n  PlusIcon,\n  ServerIcon,\n  TrashIcon,\n  UsersIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport useSWR from \"swr\";\n\nimport useVisitorGroups, {\n  VisitorGroupWithCount,\n} from \"@/lib/swr/use-visitor-groups\";\nimport { cn, fetcher } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\n\nimport { VisitorGroupModal } from \"./visitor-group-modal\";\n\ntype VisitorGroupLink = {\n  id: string;\n  link: {\n    id: string;\n    name: string | null;\n    linkType: LinkType;\n    documentId: string | null;\n    dataroomId: string | null;\n    document: { id: string; name: string } | null;\n    dataroom: { id: string; name: string } | null;\n  };\n};\n\ntype VisitorGroupDetail = VisitorGroupWithCount & {\n  links: VisitorGroupLink[];\n};\n\nconst splitMembers = (members: string[]) => {\n  const domainMembers = members.filter((member) => member.startsWith(\"@\"));\n  const emailMembers = members.filter((member) => !member.startsWith(\"@\"));\n  return { emailMembers, domainMembers };\n};\n\nfunction VisitorGroupCard({\n  group,\n  teamId,\n  onEdit,\n  onDelete,\n}: {\n  group: VisitorGroupWithCount;\n  teamId?: string;\n  onEdit: (group: VisitorGroupWithCount) => void;\n  onDelete: (group: VisitorGroupWithCount) => void;\n}) {\n  const [expanded, setExpanded] = useState(false);\n  const [menuOpen, setMenuOpen] = useState(false);\n\n  const { data: groupDetails, isLoading: loadingDetails } =\n    useSWR<VisitorGroupDetail>(\n      expanded && teamId\n        ? `/api/teams/${teamId}/visitor-groups/${group.id}`\n        : null,\n      fetcher,\n    );\n\n  const members = groupDetails?.emails ?? group.emails ?? [];\n  const memberCount = members.length;\n  const linkCount = groupDetails?._count.links ?? group._count.links;\n  const { emailMembers, domainMembers } = splitMembers(members);\n  const previewMembers = members.slice(0, 5);\n  const extraMembersCount = Math.max(members.length - previewMembers.length, 0);\n\n  const links = groupDetails?.links ?? [];\n  const documentLinks = links.filter(\n    (item) => item.link.linkType !== \"DATAROOM_LINK\",\n  );\n  const dataroomLinks = links.filter(\n    (item) => item.link.linkType === \"DATAROOM_LINK\",\n  );\n\n  const hasLinkedItems =\n    loadingDetails || documentLinks.length > 0 || dataroomLinks.length > 0;\n\n  const toggle = () => setExpanded((prev) => !prev);\n\n  return (\n    <div\n      className={cn(\n        \"overflow-hidden rounded-xl border border-gray-200 transition-colors dark:border-gray-800\",\n        expanded\n          ? \"bg-gray-50/80 dark:bg-gray-900/70\"\n          : \"bg-white hover:bg-gray-50/50 dark:bg-gray-900 dark:hover:bg-gray-800/40\",\n      )}\n    >\n      {/* Clickable header */}\n      <div\n        role=\"button\"\n        tabIndex={0}\n        onClick={toggle}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\" || event.key === \" \") {\n            event.preventDefault();\n            toggle();\n          }\n        }}\n        className=\"cursor-pointer px-4 pt-3.5 sm:px-5 sm:pt-4\"\n      >\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"min-w-0 flex-1\">\n            <div className=\"flex min-w-0 items-center gap-2.5\">\n              <div className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gradient-to-t from-gray-100 dark:border-gray-700 dark:from-gray-800\">\n                <UsersIcon className=\"h-3.5 w-3.5 text-foreground\" />\n              </div>\n              <p className=\"truncate text-sm font-semibold text-foreground\">\n                {group.name}\n              </p>\n            </div>\n\n            <div className=\"mt-2 flex items-center gap-1.5\">\n              <span className=\"inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-muted-foreground dark:bg-gray-800\">\n                <UsersIcon className=\"h-3 w-3\" />\n                {memberCount} {memberCount === 1 ? \"member\" : \"members\"}\n              </span>\n              <span className=\"inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-muted-foreground dark:bg-gray-800\">\n                <LinkIcon className=\"h-3 w-3\" />\n                {linkCount} {linkCount === 1 ? \"link\" : \"links\"}\n              </span>\n            </div>\n\n            {!expanded && previewMembers.length > 0 && (\n              <div className=\"mt-2 flex flex-wrap items-center gap-1.5\">\n                {previewMembers.map((member) => (\n                  <span\n                    key={`${group.id}-${member}`}\n                    className={cn(\n                      \"inline-flex items-center rounded-md px-2 py-0.5 text-xs\",\n                      member.startsWith(\"@\")\n                        ? \"bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300\"\n                        : \"bg-gray-100 text-foreground dark:bg-gray-800\",\n                    )}\n                  >\n                    {member}\n                  </span>\n                ))}\n                {extraMembersCount > 0 && (\n                  <span className=\"text-xs text-muted-foreground\">\n                    +{extraMembersCount} more\n                  </span>\n                )}\n              </div>\n            )}\n          </div>\n\n          <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>\n            <DropdownMenuTrigger asChild>\n              <Button\n                variant=\"outline\"\n                className=\"h-7 w-7 border-gray-200 bg-transparent p-0 hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800\"\n                onClick={(e) => e.stopPropagation()}\n              >\n                <MoreVerticalIcon className=\"h-3.5 w-3.5\" />\n              </Button>\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onEdit(group);\n                }}\n              >\n                <PencilIcon className=\"mr-2 h-4 w-4\" />\n                Edit\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                onClick={(e) => {\n                  e.stopPropagation();\n                  onDelete(group);\n                }}\n                className=\"text-destructive focus:text-destructive\"\n              >\n                <TrashIcon className=\"mr-2 h-4 w-4\" />\n                Delete\n              </DropdownMenuItem>\n            </DropdownMenuContent>\n          </DropdownMenu>\n        </div>\n      </div>\n\n      {/* Expanded content */}\n      {expanded && (\n        <div className=\"px-4 pb-3 pt-3 sm:px-5 sm:pb-4\">\n          <div className=\"space-y-3\">\n            {emailMembers.length > 0 && (\n              <div>\n                <p className=\"text-[11px] font-semibold uppercase tracking-widest text-muted-foreground\">\n                  Email Members ({emailMembers.length})\n                </p>\n                <div className=\"mt-1.5 space-y-0.5\">\n                  {emailMembers.map((email) => (\n                    <div\n                      key={`${group.id}-email-${email}`}\n                      className=\"flex items-center gap-2 py-0.5 text-xs text-foreground\"\n                    >\n                      <MailIcon className=\"h-3 w-3 text-muted-foreground\" />\n                      <span>{email}</span>\n                    </div>\n                  ))}\n                </div>\n              </div>\n            )}\n\n            {domainMembers.length > 0 && (\n              <div>\n                <p className=\"text-[11px] font-semibold uppercase tracking-widest text-muted-foreground\">\n                  Domain Access ({domainMembers.length})\n                </p>\n                <div className=\"mt-1.5 space-y-0.5\">\n                  {domainMembers.map((domain) => (\n                    <div\n                      key={`${group.id}-domain-${domain}`}\n                      className=\"flex items-center gap-2 py-0.5 text-xs text-foreground\"\n                    >\n                      <GlobeIcon className=\"h-3 w-3 text-muted-foreground\" />\n                      <span>{domain}</span>\n                    </div>\n                  ))}\n                </div>\n              </div>\n            )}\n\n            {hasLinkedItems && (\n              <div>\n                <p className=\"text-[11px] font-semibold uppercase tracking-widest text-muted-foreground\">\n                  Links ({documentLinks.length + dataroomLinks.length})\n                </p>\n\n                {loadingDetails ? (\n                  <p className=\"mt-1.5 text-xs text-muted-foreground\">\n                    Loading...\n                  </p>\n                ) : (\n                  <div className=\"mt-1.5 space-y-1\">\n                    {documentLinks.map((groupLink: VisitorGroupLink) => {\n                      const documentId = groupLink.link.documentId;\n                      const content = (\n                        <div className=\"flex min-w-0 items-center gap-2\">\n                          <FileIcon className=\"h-3 w-3 shrink-0 text-muted-foreground\" />\n                          <span className=\"truncate text-xs font-medium text-foreground\">\n                            {groupLink.link.document?.name ||\n                              \"Untitled document\"}\n                          </span>\n                          <span className=\"shrink-0 text-[10px] text-muted-foreground\">\n                            via {groupLink.link.name || \"Untitled link\"}\n                          </span>\n                        </div>\n                      );\n                      return documentId ? (\n                        <Link\n                          key={groupLink.id}\n                          href={`/documents/${documentId}`}\n                          onClick={(e) => e.stopPropagation()}\n                          className=\"group/link flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800\"\n                        >\n                          {content}\n                          <ExternalLinkIcon className=\"ml-2 h-3 w-3 shrink-0 text-muted-foreground transition-colors group-hover/link:text-foreground\" />\n                        </Link>\n                      ) : (\n                        <div\n                          key={groupLink.id}\n                          className=\"flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 dark:border-gray-700 dark:bg-gray-800/50\"\n                        >\n                          {content}\n                        </div>\n                      );\n                    })}\n                    {dataroomLinks.map((groupLink) => {\n                      const dataroomId = groupLink.link.dataroomId;\n                      const content = (\n                        <>\n                          <div className=\"flex min-w-0 items-center gap-2\">\n                            <ServerIcon className=\"h-3 w-3 shrink-0 text-muted-foreground\" />\n                            <span className=\"truncate text-xs font-medium text-foreground\">\n                              {groupLink.link.dataroom?.name ||\n                                \"Untitled dataroom\"}\n                            </span>\n                            <span className=\"shrink-0 text-[10px] text-muted-foreground\">\n                              via {groupLink.link.name || \"Untitled link\"}\n                            </span>\n                          </div>\n                        </>\n                      );\n                      return dataroomId ? (\n                        <Link\n                          key={groupLink.id}\n                          href={`/datarooms/${dataroomId}/permissions`}\n                          onClick={(e) => e.stopPropagation()}\n                          className=\"group/link flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800\"\n                        >\n                          {content}\n                          <ExternalLinkIcon className=\"ml-2 h-3 w-3 shrink-0 text-muted-foreground transition-colors group-hover/link:text-foreground\" />\n                        </Link>\n                      ) : (\n                        <div\n                          key={groupLink.id}\n                          className=\"flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 dark:border-gray-700 dark:bg-gray-800/50\"\n                        >\n                          {content}\n                        </div>\n                      );\n                    })}\n                  </div>\n                )}\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n\n      {/* Chevron toggle at center bottom */}\n      <div\n        role=\"button\"\n        tabIndex={0}\n        onClick={toggle}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\" || event.key === \" \") {\n            event.preventDefault();\n            toggle();\n          }\n        }}\n        className=\"flex cursor-pointer justify-center py-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40\"\n      >\n        <ChevronDownIcon\n          className={cn(\n            \"h-4 w-4 text-muted-foreground transition-transform\",\n            expanded && \"rotate-180\",\n          )}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport function VisitorGroupsSection() {\n  const { visitorGroups, loading } = useVisitorGroups();\n  const [modalOpen, setModalOpen] = useState(false);\n  const [editingGroup, setEditingGroup] =\n    useState<VisitorGroupWithCount | null>(null);\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const handleEdit = (group: VisitorGroupWithCount) => {\n    setEditingGroup(group);\n    setModalOpen(true);\n  };\n\n  const handleCreate = () => {\n    setEditingGroup(null);\n    setModalOpen(true);\n  };\n\n  const handleDelete = async (group: VisitorGroupWithCount) => {\n    if (\n      !confirm(\n        `Are you sure you want to delete \"${group.name}\"? This cannot be undone.`,\n      )\n    ) {\n      return;\n    }\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups/${group.id}`,\n        { method: \"DELETE\" },\n      );\n\n      if (!response.ok) {\n        const data = await response.json();\n        toast.error(data.error || \"Failed to delete visitor group.\");\n        return;\n      }\n\n      toast.success(\"Visitor group deleted successfully.\");\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`);\n    } catch (error) {\n      toast.error(\"Error deleting visitor group. Please try again.\");\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center justify-between\">\n        <div>\n          <h3 className=\"text-lg font-semibold text-foreground\">\n            Visitor Groups\n          </h3>\n          <p className=\"text-sm text-muted-foreground\">\n            Create groups of emails and domains, then apply them to link allow\n            lists.\n          </p>\n        </div>\n        <Button onClick={handleCreate} size=\"sm\" className=\"gap-1.5\">\n          <PlusIcon className=\"h-4 w-4\" />\n          Create Group\n        </Button>\n      </div>\n\n      {loading ? (\n        <div className=\"space-y-3\">\n          {[...Array(3)].map((_, i) => (\n            <Skeleton key={i} className=\"h-20 w-full rounded-lg\" />\n          ))}\n        </div>\n      ) : !visitorGroups || visitorGroups.length === 0 ? (\n        <div className=\"rounded-lg border border-dashed border-gray-300 p-8 text-center dark:border-gray-700\">\n          <UsersIcon className=\"mx-auto h-10 w-10 text-muted-foreground/50\" />\n          <h4 className=\"mt-2 text-sm font-medium text-foreground\">\n            No visitor groups yet\n          </h4>\n          <p className=\"mt-1 text-sm text-muted-foreground\">\n            Create your first visitor group to manage access across multiple\n            links.\n          </p>\n          <Button\n            onClick={handleCreate}\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"mt-4 gap-1.5\"\n          >\n            <PlusIcon className=\"h-4 w-4\" />\n            Create Group\n          </Button>\n        </div>\n      ) : (\n        <div className=\"grid gap-3\">\n          {visitorGroups.map((group) => (\n            <VisitorGroupCard\n              key={group.id}\n              group={group}\n              teamId={teamId}\n              onEdit={handleEdit}\n              onDelete={handleDelete}\n            />\n          ))}\n        </div>\n      )}\n\n      <VisitorGroupModal\n        open={modalOpen}\n        setOpen={setModalOpen}\n        existingGroup={editingGroup}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-useragent-base.tsx",
    "content": "import { COUNTRIES } from \"@/lib/constants\";\n\nimport UAIcon from \"../user-agent-icon\";\n\nfunction decodeCity(city: string) {\n  try {\n    return decodeURIComponent(city);\n  } catch (e) {\n    // If decoding fails, return the original string\n    return city;\n  }\n}\n\nexport default function VisitorUserAgentBase({\n  userAgent,\n}: {\n  userAgent: {\n    device: string;\n    browser: string;\n    os: string;\n    country?: string;\n    city?: string;\n  };\n}) {\n  const { device, browser, os, country, city } = userAgent;\n\n  return (\n    <div>\n      <div className=\"pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n        {city && country ? (\n          <div className=\"flex items-center\">\n            <div className=\"flex items-center gap-x-1 px-1\">\n              <img\n                alt={country}\n                src={`https://flag.vercel.app/m/${country}.svg`}\n                className=\"h-3 w-4\"\n              />\n              <span>{decodeCity(city)},</span>\n              <span>{COUNTRIES[country]}</span>\n            </div>\n          </div>\n        ) : null}\n      </div>\n      <div className=\"pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n        <div className=\"flex items-center\">\n          <div className=\"flex items-center gap-x-1 px-1\">\n            <UAIcon display={device} type=\"devices\" className=\"size-4\" />{\" \"}\n            {device},\n          </div>\n          <div className=\"flex items-center gap-x-1 px-1\">\n            <UAIcon display={browser} type=\"browsers\" className=\"size-4\" />{\" \"}\n            {browser},\n          </div>\n          <div className=\"flex items-center gap-x-1 px-1\">\n            <UAIcon display={os} type=\"os\" className=\"size-4\" /> {os}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-useragent-placeholder.tsx",
    "content": "import { PlanEnum } from \"@/ee/stripe/constants\";\nimport { CrownIcon } from \"lucide-react\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport UAIcon from \"@/components/user-agent-icon\";\n\nexport default function VisitorUserAgentPlaceholder() {\n  return (\n    <div className=\"relative\">\n      <div className=\"blur-[2px]\">\n        <div className=\"inline-flex items-center gap-x-1 pb-0.5 pl-1.5 text-sm text-muted-foreground md:pb-1 md:pl-2\">\n          <div className=\"flex items-center gap-x-1 px-1 opacity-50\">\n            <img\n              alt=\"Preview\"\n              src=\"https://flag.vercel.app/m/US.svg\"\n              className=\"h-3 w-4\"\n            />\n            <span className=\"text-muted-foreground\">\n              San Francisco, United States\n            </span>\n          </div>\n        </div>\n        <div className=\"flex items-center gap-x-1 pb-0.5 pl-1.5 opacity-50\">\n          <div className=\"flex items-center gap-x-1 px-1\">\n            <UAIcon display=\"Mobile\" type=\"devices\" className=\"size-4\" />\n            <span className=\"text-muted-foreground\">iPhone,</span>\n          </div>\n          <div className=\"flex items-center gap-x-1 px-1\">\n            <UAIcon display=\"Chrome\" type=\"browsers\" className=\"size-4\" />\n            <span className=\"text-muted-foreground\">Chrome,</span>\n          </div>\n          <div className=\"flex items-center gap-x-1 px-1\">\n            <UAIcon display=\"iOS\" type=\"os\" className=\"size-4\" />\n            <span className=\"text-muted-foreground\">iOS</span>\n          </div>\n        </div>\n      </div>\n      <div className=\"absolute left-8 top-3\">\n        <UpgradePlanModal\n          clickedPlan={PlanEnum.Pro}\n          trigger=\"visitor-table-user-agent\"\n        >\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"h-6 gap-x-1 px-1.5 py-0.5\"\n          >\n            <CrownIcon className=\"size-4\" />\n            <span>See more visitor info</span>\n          </Button>\n        </UpgradePlanModal>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitor-useragent.tsx",
    "content": "import { useVisitorUserAgent } from \"@/lib/swr/use-stats\";\n\nimport VisitorUserAgentBase from \"./visitor-useragent-base\";\n\nexport default function VisitorUserAgent({ viewId }: { viewId: string }) {\n  const { userAgent, error } = useVisitorUserAgent(viewId);\n\n  if (error) {\n    return null;\n  }\n\n  if (!userAgent) {\n    return <div className=\"pb-0.5 pl-1.5 md:pb-1 md:pl-2\">Loading...</div>;\n  }\n\n  return <VisitorUserAgentBase userAgent={userAgent} />;\n}\n"
  },
  {
    "path": "components/visitors/visitor-video-chart.tsx",
    "content": "import {\n  Area,\n  AreaChart,\n  ResponsiveContainer,\n  Tooltip,\n  XAxis,\n  YAxis,\n} from \"recharts\";\nimport useSWR from \"swr\";\n\nimport { Card } from \"@/components/ui/card\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport default function VisitorVideoChart({\n  documentId,\n  viewId,\n  teamId,\n}: {\n  documentId: string;\n  viewId: string;\n  teamId: string;\n}) {\n  // Fetch video events if this is a video document\n  const { data: videoEvents, error: videoError } = useSWR<{\n    data: Array<{\n      start_time: number;\n      views: number;\n    }>;\n  }>(\n    teamId\n      ? `/api/teams/${teamId}/documents/${documentId}/views/${viewId}/video-stats`\n      : null,\n    fetcher,\n  );\n\n  if (videoError) {\n    console.error(\"Error loading video events:\", videoError);\n    return null;\n  }\n\n  if (!videoEvents) {\n    return (\n      <Card>\n        <div className=\"flex h-[200px] items-center justify-center\">\n          <LoadingSpinner />\n        </div>\n      </Card>\n    );\n  }\n\n  const formatTime = (seconds: number) => {\n    const minutes = Math.floor(seconds / 60);\n    const remainingSeconds = Math.floor(seconds % 60);\n    return `${minutes}:${remainingSeconds.toString().padStart(2, \"0\")}`;\n  };\n\n  return (\n    <div className=\"-ml-10 h-[200px]\">\n      <ResponsiveContainer width=\"100%\" height=\"100%\">\n        <AreaChart\n          data={videoEvents.data}\n          margin={{ top: 10, right: 10, left: 0, bottom: 0 }}\n        >\n          <XAxis\n            dataKey=\"start_time\"\n            tickFormatter={formatTime}\n            stroke=\"#888888\"\n            fontSize={12}\n          />\n          <YAxis\n            stroke=\"#888888\"\n            fontSize={12}\n            tickFormatter={(value) => Math.floor(value).toString()}\n            domain={[0, (dataMax: number) => dataMax + 1]}\n            padding={{ top: 20 }}\n            allowDecimals={false}\n          />\n          <Tooltip\n            content={({ active, payload, label }) => {\n              if (active && payload && payload.length) {\n                return (\n                  <div className=\"space-y-1 rounded-md border bg-background p-2 text-sm\">\n                    <p className=\"font-medium\">{formatTime(label)}</p>\n                    <div className=\"space-y-0.5 text-muted-foreground\">\n                      <p>Playback count: {payload[0].value}</p>\n                    </div>\n                  </div>\n                );\n              }\n              return null;\n            }}\n          />\n          <Area\n            type=\"monotone\"\n            dataKey=\"views\"\n            stroke=\"#10b981\"\n            strokeWidth={2}\n            fill=\"url(#playbackGradient)\"\n            dot={false}\n          />\n          <defs>\n            <linearGradient id=\"playbackGradient\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"5%\" stopColor=\"#10b981\" stopOpacity={0.3} />\n              <stop offset=\"95%\" stopColor=\"#10b981\" stopOpacity={0} />\n            </linearGradient>\n          </defs>\n        </AreaChart>\n      </ResponsiveContainer>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components/visitors/visitors-table.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { DocumentVersion } from \"@prisma/client\";\nimport {\n  AlertTriangleIcon,\n  ArchiveIcon,\n  ArchiveRestoreIcon,\n  BadgeCheckIcon,\n  BadgeInfoIcon,\n  DownloadCloudIcon,\n  FileBadgeIcon,\n  FileDigitIcon,\n  MoreHorizontalIcon,\n  ServerIcon,\n  ThumbsDownIcon,\n  ThumbsUpIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDocumentVisits } from \"@/lib/swr/use-document\";\nimport { durationFormat, timeAgo } from \"@/lib/utils\";\n\nimport ChevronDown from \"@/components/shared/icons/chevron-down\";\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { Gauge } from \"@/components/ui/gauge\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { TimestampTooltip } from \"@/components/ui/timestamp-tooltip\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport { Badge } from \"@/components/ui/badge\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport { Pagination } from \"../documents/pagination\";\nimport { Button } from \"../ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"../ui/dropdown-menu\";\nimport { VisitorAvatar } from \"./visitor-avatar\";\nimport VisitorChart from \"./visitor-chart\";\nimport VisitorClicks from \"./visitor-clicks\";\nimport VisitorCustomFields from \"./visitor-custom-fields\";\nimport VisitorUserAgent from \"./visitor-useragent\";\nimport VisitorUserAgentPlaceholder from \"./visitor-useragent-placeholder\";\nimport VisitorVideoChart from \"./visitor-video-chart\";\n\nexport default function VisitorsTable({\n  primaryVersion,\n  isVideo = false,\n}: {\n  primaryVersion: DocumentVersion;\n  isVideo?: boolean;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const [currentPage, setCurrentPage] = useState<number>(1);\n  const [pageSize, setPageSize] = useState<number>(10);\n\n  const { views, mutate: mutateViews } = useDocumentVisits(\n    currentPage,\n    pageSize,\n  );\n  const { plan, isTrial, isPaused } = usePlan();\n  const isFreePlan = plan === \"free\";\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [isRefreshing, setIsRefreshing] = useState(false);\n\n  const handlePageSizeChange = (newSize: number) => {\n    setPageSize(newSize);\n    setCurrentPage(1);\n  };\n\n  const handleArchiveView = async (\n    viewId: string,\n    targetId: string,\n    isArchived: boolean,\n  ) => {\n    setIsLoading(true);\n\n    const response = await fetch(\n      `/api/teams/${teamId}/views/${viewId}/archive`,\n      {\n        method: \"PUT\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          isArchived: !isArchived,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      toast.error(\"Failed to archive view\");\n      return;\n    }\n\n    // mutate the views on the current page\n    mutateViews();\n    // mutate the stats\n    mutate(\n      `/api/teams/${teamId}/documents/${encodeURIComponent(targetId)}/stats`,\n    );\n\n    toast.success(\n      !isArchived\n        ? \"View successfully archived\"\n        : \"View successfully unarchived\",\n    );\n    setIsLoading(false);\n  };\n\n  return (\n    <div className=\"w-full\">\n      <div className=\"mb-2 flex items-center gap-2 md:mb-4\">\n        <h2>All visitors</h2>\n        {views && views.totalViews > 0 && (\n          <Badge variant=\"outline\" className=\"text-muted-foreground\">\n            {views.totalViews}\n          </Badge>\n        )}\n      </div>\n      <div className=\"rounded-md border\">\n        <Table>\n          <TableHeader>\n            <TableRow className=\"*:whitespace-nowrap *:font-medium hover:bg-transparent\">\n              <TableHead>Name</TableHead>\n              <TableHead>View Duration</TableHead>\n              <TableHead>View Completion</TableHead>\n              <TableHead>Last Viewed</TableHead>\n              <TableHead className=\"text-center sm:text-right\"></TableHead>\n            </TableRow>\n          </TableHeader>\n          <TableBody>\n            {views?.viewsWithDuration.length === 0 &&\n              views?.hiddenViewCount === 0 && (\n                <TableRow>\n                  <TableCell colSpan={5}>\n                    <div className=\"flex h-40 w-full items-center justify-center\">\n                      <p>No views yet. Try sharing a link.</p>\n                    </div>\n                  </TableCell>\n                </TableRow>\n              )}\n            {views?.hiddenViewCount! > 0 && (\n              <>\n                <TableRow className=\"\">\n                  <TableCell colSpan={5} className=\"text-left sm:text-center\">\n                    {isPaused &&\n                    views?.hiddenFromPause &&\n                    views.hiddenFromPause > 0 ? (\n                      // Show pause-specific message if team is paused and has hidden views from pause\n                      <div className=\"flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center\">\n                        <span className=\"flex items-center gap-x-1\">\n                          <AlertTriangleIcon className=\"inline-block h-4 w-4 text-orange-500\" />\n                          {views.hiddenFromPause} visit\n                          {views.hiddenFromPause !== 1 ? \"s\" : \"\"} occurred\n                          after your team was paused and{\" \"}\n                          {views.hiddenFromPause !== 1 ? \"are\" : \"is\"}{\" \"}\n                          hidden.{\" \"}\n                        </span>\n                        <Link\n                          href=\"/settings/billing\"\n                          className=\"font-medium text-orange-600 underline hover:text-orange-700\"\n                        >\n                          Unpause subscription to see all visits\n                        </Link>\n                      </div>\n                    ) : (\n                      // Show regular free plan message\n                      <div className=\"flex flex-col items-start justify-center gap-1 sm:flex-row sm:items-center\">\n                        <span className=\"flex items-center gap-x-1\">\n                          <AlertTriangleIcon className=\"inline-block h-4 w-4 text-yellow-500\" />\n                          Some older visits may not be shown because your\n                          document has more than 20 views.{\" \"}\n                        </span>\n                        <UpgradePlanModal\n                          clickedPlan={\n                            isTrial ? PlanEnum.Business : PlanEnum.Pro\n                          }\n                          trigger=\"\"\n                        >\n                          <button className=\"underline hover:text-gray-800\">\n                            Upgrade to see full history\n                          </button>\n                        </UpgradePlanModal>\n                      </div>\n                    )}\n                  </TableCell>\n                </TableRow>\n                {Array.from({ length: views?.hiddenViewCount! }).map((_, i) => (\n                  <VisitorBlurred key={i} />\n                ))}\n              </>\n            )}\n            {views?.viewsWithDuration ? (\n              views.viewsWithDuration.map((view) => {\n                if (view.isArchived) {\n                  return (\n                    <TableRow\n                      key={view.id}\n                      className=\"group/row opacity-50 grayscale\"\n                    >\n                      {/* Name */}\n                      <TableCell>\n                        <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                          <VisitorAvatar\n                            viewerEmail={view.viewerEmail}\n                            isArchived\n                          />\n                          <div className=\"min-w-0 flex-1\">\n                            <div className=\"focus:outline-none\">\n                              <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                                {view.viewerEmail ? (\n                                  <>{view.viewerName || view.viewerEmail}</>\n                                ) : (\n                                  \"Anonymous\"\n                                )}\n                              </p>\n                              {view.viewerName && view.viewerEmail && (\n                                <p className=\"text-xs text-muted-foreground/60\">\n                                  {view.viewerEmail}\n                                </p>\n                              )}\n                              <p className=\"text-xs text-muted-foreground/60 sm:text-sm\">\n                                {view.link && view.link.name\n                                  ? view.link.name\n                                  : view.linkId}\n                              </p>\n                            </div>\n                          </div>\n                        </div>\n                      </TableCell>\n                      {/* Duration */}\n                      <TableCell className=\"\">\n                        <div className=\"text-sm text-muted-foreground\">\n                          {durationFormat(view.totalDuration)}\n                        </div>\n                      </TableCell>\n                      {/* Completion */}\n                      <TableCell className=\"flex justify-start\">\n                        <div className=\"text-sm text-muted-foreground\">\n                          <Gauge\n                            value={view.completionRate}\n                            size={\"small\"}\n                            showValue={true}\n                          />\n                        </div>\n                      </TableCell>\n                      {/* Last Viewed */}\n                      <TableCell className=\"text-sm text-muted-foreground\">\n                        <TimestampTooltip\n                          timestamp={view.viewedAt}\n                          side=\"right\"\n                          rows={[\"local\", \"utc\", \"unix\"]}\n                        >\n                          <time\n                            className=\"select-none\"\n                            dateTime={new Date(view.viewedAt).toISOString()}\n                          >\n                            {timeAgo(view.viewedAt)}\n                          </time>\n                        </TimestampTooltip>\n                      </TableCell>\n\n                      {/* Actions */}\n                      <TableCell className=\"text-center sm:text-right\">\n                        <DropdownMenu>\n                          <DropdownMenuTrigger asChild>\n                            <Button\n                              variant=\"ghost\"\n                              className=\"h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700\"\n                              onClick={(e) => {\n                                e.stopPropagation();\n                                e.preventDefault();\n                              }}\n                            >\n                              <span className=\"sr-only\">Open menu</span>\n                              <MoreHorizontalIcon className=\"h-4 w-4\" />\n                            </Button>\n                          </DropdownMenuTrigger>\n                          <DropdownMenuContent align=\"end\">\n                            <DropdownMenuLabel>Actions</DropdownMenuLabel>\n\n                            <DropdownMenuSeparator />\n                            <DropdownMenuItem\n                              className=\"text-destructive focus:bg-destructive focus:text-destructive-foreground\"\n                              onClick={(e) => {\n                                e.stopPropagation();\n                                e.preventDefault();\n                                handleArchiveView(\n                                  view.id,\n                                  view.documentId ?? \"\",\n                                  view.isArchived,\n                                );\n                              }}\n                              disabled={isLoading}\n                            >\n                              <ArchiveRestoreIcon className=\"mr-2 h-4 w-4\" />\n                              Restore\n                            </DropdownMenuItem>\n                          </DropdownMenuContent>\n                        </DropdownMenu>\n                      </TableCell>\n                    </TableRow>\n                  );\n                }\n                return (\n                  <Collapsible key={view.id} asChild>\n                    <>\n                      <CollapsibleTrigger asChild>\n                        <TableRow key={view.id} className=\"group/row\">\n                          {/* Name */}\n                          <TableCell>\n                            <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                              <VisitorAvatar viewerEmail={view.viewerEmail} />\n                              <div className=\"min-w-0 flex-1\">\n                                <div className=\"focus:outline-none\">\n                                  <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                                    {view.viewerEmail ? (\n                                      <>\n                                        {view.viewerName || view.viewerEmail}{\" \"}\n                                        {view.verified && (\n                                          <BadgeTooltip\n                                            content=\"Verified visitor\"\n                                            key={`verified-${view.id}`}\n                                          >\n                                            <BadgeCheckIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                                          </BadgeTooltip>\n                                        )}\n                                        {view.internal && (\n                                          <BadgeTooltip\n                                            content=\"Internal visitor\"\n                                            key={`internal-${view.id}`}\n                                          >\n                                            <BadgeInfoIcon className=\"h-4 w-4 text-blue-500 hover:text-blue-600\" />\n                                          </BadgeTooltip>\n                                        )}\n                                        {view.agreementResponse && (\n                                          <BadgeTooltip\n                                            content={`Agreed to ${view.agreementResponse.agreement.name}`}\n                                            key={`agreement-${view.id}`}\n                                          >\n                                            <FileBadgeIcon className=\"h-4 w-4 text-emerald-500 hover:text-emerald-600\" />\n                                          </BadgeTooltip>\n                                        )}\n                                        {view.downloadedAt && (\n                                          <BadgeTooltip\n                                            content={`Downloaded ${timeAgo(view.downloadedAt)}`}\n                                            key={`download-${view.id}`}\n                                          >\n                                            <DownloadCloudIcon className=\"h-4 w-4 text-cyan-500 hover:text-cyan-600\" />\n                                          </BadgeTooltip>\n                                        )}\n                                        {view.dataroomId && (\n                                          <BadgeTooltip\n                                            content={`Dataroom Visitor`}\n                                            key={`dataroom-${view.id}`}\n                                          >\n                                            <ServerIcon className=\"h-4 w-4 text-[#fb7a00] hover:text-[#fb7a00]/90\" />\n                                          </BadgeTooltip>\n                                        )}\n                                        {view.feedbackResponse && (\n                                          <BadgeTooltip\n                                            content={`${view.feedbackResponse.data.question}: ${view.feedbackResponse.data.answer}`}\n                                            key={`feedback-${view.id}`}\n                                          >\n                                            {view.feedbackResponse.data\n                                              .answer === \"yes\" ? (\n                                              <ThumbsUpIcon className=\"h-4 w-4 text-gray-500 hover:text-gray-600\" />\n                                            ) : (\n                                              <ThumbsDownIcon className=\"h-4 w-4 text-gray-500 hover:text-gray-600\" />\n                                            )}\n                                          </BadgeTooltip>\n                                        )}\n                                      </>\n                                    ) : (\n                                      \"Anonymous\"\n                                    )}\n                                  </p>\n                                  {view.viewerName && view.viewerEmail && (\n                                    <p className=\"text-xs text-muted-foreground/60\">\n                                      {view.viewerEmail}\n                                    </p>\n                                  )}\n                                  <p className=\"text-xs text-muted-foreground/60 sm:text-sm\">\n                                    {view.link && view.link.name\n                                      ? view.link.name\n                                      : view.linkId}\n                                  </p>\n                                </div>\n                              </div>\n                            </div>\n                          </TableCell>\n                          {/* Duration */}\n                          <TableCell className=\"\">\n                            <div className=\"text-sm text-muted-foreground\">\n                              {durationFormat(view.totalDuration)}\n                            </div>\n                          </TableCell>\n                          {/* Completion */}\n                          <TableCell className=\"flex justify-start\">\n                            <div className=\"text-sm text-muted-foreground\">\n                              <Gauge\n                                value={view.completionRate}\n                                size={\"small\"}\n                                showValue={true}\n                              />\n                            </div>\n                          </TableCell>\n                          {/* Last Viewed */}\n                          <TableCell className=\"text-sm text-muted-foreground\">\n                            <TimestampTooltip\n                              timestamp={view.viewedAt}\n                              side=\"right\"\n                              rows={[\"local\", \"utc\", \"unix\"]}\n                            >\n                              <time\n                                className=\"select-none\"\n                                dateTime={new Date(view.viewedAt).toISOString()}\n                              >\n                                {timeAgo(view.viewedAt)}\n                              </time>\n                            </TimestampTooltip>\n                          </TableCell>\n\n                          {/* Actions */}\n                          <TableCell className=\"text-center sm:text-right\">\n                            <DropdownMenu>\n                              <DropdownMenuTrigger asChild>\n                                <Button\n                                  variant=\"ghost\"\n                                  className=\"h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700\"\n                                  onClick={(e) => {\n                                    e.stopPropagation();\n                                    e.preventDefault();\n                                  }}\n                                >\n                                  <span className=\"sr-only\">Open menu</span>\n                                  <MoreHorizontalIcon className=\"h-4 w-4\" />\n                                </Button>\n                              </DropdownMenuTrigger>\n                              <DropdownMenuContent align=\"end\">\n                                <DropdownMenuLabel>Actions</DropdownMenuLabel>\n\n                                <DropdownMenuSeparator />\n                                <DropdownMenuItem\n                                  className=\"text-destructive focus:bg-destructive focus:text-destructive-foreground\"\n                                  onClick={(e) => {\n                                    e.stopPropagation();\n                                    e.preventDefault();\n                                    handleArchiveView(\n                                      view.id,\n                                      view.documentId ?? \"\",\n                                      view.isArchived,\n                                    );\n                                  }}\n                                  disabled={isLoading}\n                                >\n                                  <ArchiveIcon className=\"mr-2 h-4 w-4\" />\n                                  Archive\n                                </DropdownMenuItem>\n                              </DropdownMenuContent>\n                            </DropdownMenu>\n                          </TableCell>\n                        </TableRow>\n                      </CollapsibleTrigger>\n\n                      <CollapsibleContent asChild>\n                        <>\n                          <TableRow className=\"hover:bg-transparent\">\n                            <TableCell colSpan={5}>\n                              {!isFreePlan && (\n                                <VisitorCustomFields\n                                  viewId={view.id}\n                                  teamId={view.teamId!}\n                                  documentId={view.documentId!}\n                                />\n                              )}\n                              {!isFreePlan ? (\n                                <VisitorUserAgent viewId={view.id} />\n                              ) : (\n                                <VisitorUserAgentPlaceholder />\n                              )}\n\n                              <div className=\"pb-0.5 pl-0.5 md:pb-1 md:pl-1\">\n                                <div className=\"flex items-center gap-x-1 px-1\">\n                                  <FileDigitIcon className=\"size-4\" /> Document\n                                  Version {view.versionNumber}\n                                </div>\n                              </div>\n\n                              {isVideo ? (\n                                <VisitorVideoChart\n                                  documentId={view.documentId!}\n                                  viewId={view.id}\n                                  teamId={view.teamId!}\n                                />\n                              ) : (\n                                <VisitorChart\n                                  documentId={view.documentId!}\n                                  viewId={view.id}\n                                  totalPages={view.versionNumPages}\n                                  versionNumber={view.versionNumber}\n                                  downloadType={view.downloadType}\n                                  downloadMetadata={\n                                    view.downloadMetadata as any\n                                  }\n                                />\n                              )}\n                              {(!isFreePlan && primaryVersion.type === \"pdf\") ||\n                              primaryVersion.type === \"link\" ? (\n                                <VisitorClicks\n                                  teamId={view.teamId!}\n                                  documentId={view.documentId!}\n                                  viewId={view.id}\n                                />\n                              ) : null}\n                            </TableCell>\n                          </TableRow>\n                        </>\n                      </CollapsibleContent>\n                    </>\n                  </Collapsible>\n                );\n              })\n            ) : (\n              <TableRow>\n                <TableCell className=\"min-w-[100px]\">\n                  <Skeleton className=\"h-6 w-full\" />\n                </TableCell>\n                <TableCell className=\"min-w-[450px]\">\n                  <Skeleton className=\"h-6 w-full\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-24\" />\n                </TableCell>\n                <TableCell>\n                  <Skeleton className=\"h-6 w-24\" />\n                </TableCell>\n              </TableRow>\n            )}\n          </TableBody>\n        </Table>\n      </div>\n      <Pagination\n        itemName=\"visits\"\n        currentPage={currentPage}\n        pageSize={pageSize}\n        totalItems={views?.totalViews || 0}\n        totalPages={\n          views?.totalViews ? Math.ceil(views.totalViews / pageSize) : 0\n        }\n        onPageChange={setCurrentPage}\n        onPageSizeChange={handlePageSizeChange}\n        totalShownItems={\n          views?.totalViews\n            ? Math.min(\n                pageSize,\n                views.totalViews - (currentPage - 1) * pageSize,\n              )\n            : 0\n        }\n      />\n    </div>\n  );\n}\n\n// create a component for a blurred view of the visitor\nconst VisitorBlurred = () => {\n  return (\n    <TableRow className=\"blur-sm\">\n      <TableCell className=\"\">\n        <div className=\"flex items-center overflow-visible sm:space-x-3\">\n          <VisitorAvatar viewerEmail={\"abc@example.org\"} />\n          <div className=\"min-w-0 flex-1\">\n            <div className=\"focus:outline-none\">\n              <p className=\"flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200\">\n                Anonymous\n              </p>\n              <p className=\"text-xs text-muted-foreground/60 sm:text-sm\">\n                Demo link\n              </p>\n            </div>\n          </div>\n        </div>\n      </TableCell>\n      {/* Duration */}\n      <TableCell className=\"\">\n        <div className=\"text-sm text-muted-foreground\">\n          {durationFormat(10000)}\n        </div>\n      </TableCell>\n      {/* Completion */}\n      <TableCell className=\"flex justify-start\">\n        <div className=\"text-sm text-muted-foreground\">\n          <Gauge value={90} size={\"small\"} showValue={true} />\n        </div>\n      </TableCell>\n      {/* Last Viewed */}\n      <TableCell className=\"text-sm text-muted-foreground\">\n        <time\n          dateTime={new Date(\n            new Date().getTime() - 30 * 24 * 60 * 60 * 1000,\n          ).toISOString()}\n        >\n          {timeAgo(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000))}\n        </time>\n      </TableCell>\n      {/* Actions */}\n      <TableCell className=\"cursor-pointer p-0 text-center sm:text-right\">\n        <div className=\"flex justify-end space-x-1 p-5 [&[data-state=open]>svg.chevron]:rotate-180\">\n          <ChevronDown className=\"chevron h-4 w-4 shrink-0 transition-transform duration-200\" />\n        </div>\n      </TableCell>\n    </TableRow>\n  );\n};\n"
  },
  {
    "path": "components/webhooks/webhook-events.tsx",
    "content": "\"use client\";\n\nimport { PropsWithChildren, useState, useEffect } from \"react\";\n\nimport { CircleCheck, CircleXIcon, CopyIcon } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { createHighlighter, type Highlighter } from \"shiki\";\n\nimport { useCopyToClipboard } from \"@/lib/utils/use-copy-to-clipboard\";\nimport { useMediaQuery } from \"@/lib/utils/use-media-query\";\n\nimport { Button } from \"../ui/button\";\nimport { ScrollArea } from \"../ui/scroll-area\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetDescription,\n  SheetHeader,\n  SheetTitle,\n} from \"../ui/sheet\";\nimport { ButtonTooltip } from \"../ui/tooltip\";\n\nexport type EventListProps = PropsWithChildren<{\n  events: any[];\n}>;\n\n// Shiki Code Highlighter Component\nconst CodeHighlighter = ({ \n  code, \n  language = \"json\",\n  isDark = false \n}: { \n  code: string; \n  language?: string;\n  isDark?: boolean;\n}) => {\n  const [highlighter, setHighlighter] = useState<Highlighter | null>(null);\n  const [highlightedCode, setHighlightedCode] = useState<string>(\"\");\n\n  useEffect(() => {\n    const initHighlighter = async () => {\n      const shiki = await createHighlighter({\n        themes: ['material-theme-lighter', 'material-theme-darker'],\n        langs: ['json']\n      });\n      setHighlighter(shiki);\n    };\n\n    initHighlighter();\n  }, []);\n\n  useEffect(() => {\n    if (highlighter && code) {\n      const theme = isDark ? 'material-theme-darker' : 'material-theme-lighter';\n      const html = highlighter.codeToHtml(code, {\n        lang: language,\n        theme: theme,\n        transformers: [\n          {\n            pre(node) {\n              // Add custom styling to the pre element\n              node.properties.style = [\n                node.properties.style,\n                'margin: 0',\n                'padding: 0.5rem',\n                'font-size: 0.875rem',\n                'border-radius: 0.375rem',\n                'overflow-x: auto'\n              ].filter(Boolean).join('; ');\n            },\n            code(node) {\n              // Ensure proper styling for the code element\n              node.properties.style = [\n                node.properties.style,\n                'display: block',\n                'line-height: 1.5'\n              ].filter(Boolean).join('; ');\n            }\n          }\n        ]\n      });\n      setHighlightedCode(html);\n    }\n  }, [highlighter, code, language, isDark]);\n\n  if (!highlighter || !highlightedCode) {\n    return (\n      <pre className=\"rounded-md bg-gray-100 p-2 text-sm dark:bg-gray-800\">\n        <code>{code}</code>\n      </pre>\n    );\n  }\n\n  return (\n    <div \n      className=\"overflow-x-auto rounded-md\"\n      dangerouslySetInnerHTML={{ __html: highlightedCode }}\n    />\n  );\n};\n\nconst WebhookEvent = ({ event }: { event: any }) => {\n  const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 2000 });\n  const isSuccess = event.http_status >= 200 && event.http_status < 300;\n  const { isMobile } = useMediaQuery();\n  const [isOpen, setIsOpen] = useState(false);\n  const { theme, systemTheme } = useTheme();\n  const isDark = theme === \"dark\" || (theme === \"system\" && systemTheme === \"dark\");\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(true)}\n        className=\"flex items-center justify-between gap-5 px-3.5 py-3 hover:bg-gray-50 focus:outline-none dark:hover:bg-gray-800\"\n      >\n        <div className=\"flex items-center gap-5\">\n          <div className=\"flex items-center gap-2.5\">\n            <ButtonTooltip\n              content={\n                isSuccess\n                  ? \"This webhook was successfully delivered.\"\n                  : \"This webhook failed to deliver – it will be retried.\"\n              }\n            >\n              <div>\n                {isSuccess ? (\n                  <CircleCheck className=\"size-4 text-green-500\" />\n                ) : (\n                  <CircleXIcon className=\"size-4 text-destructive\" />\n                )}\n              </div>\n            </ButtonTooltip>\n            <div className=\"text-sm text-foreground\">{event.http_status}</div>\n          </div>\n          <div className=\"text-sm text-foreground\">{event.event}</div>\n        </div>\n\n        <div className=\"text-xs text-muted-foreground\">\n          {(() => {\n            const date = new Date(event.timestamp);\n            const localDate = new Date(\n              date.getTime() - date.getTimezoneOffset() * 60000,\n            );\n            return isMobile\n              ? localDate.toLocaleTimeString()\n              : localDate.toLocaleString();\n          })()}\n        </div>\n      </button>\n      <Sheet open={isOpen} onOpenChange={setIsOpen}>\n        <SheetContent className=\"flex w-[90%] flex-col justify-between border-l border-gray-200 bg-background px-4 text-foreground dark:border-gray-800 dark:bg-gray-900 sm:w-[600px] sm:max-w-2xl md:px-5\">\n          <SheetHeader className=\"text-start\">\n            <SheetTitle>{event.event}</SheetTitle>\n            <SheetDescription className=\"group flex items-center gap-2\">\n              <p className=\"font-mono text-sm text-gray-500\">\n                {event.event_id}\n              </p>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                onClick={() =>\n                  copyToClipboard(event.event_id, \"Copied to clipboard\")\n                }\n              >\n                <CopyIcon className=\"size-4 opacity-0 transition-opacity group-hover:opacity-100\" />\n              </Button>\n            </SheetDescription>\n          </SheetHeader>\n          <ScrollArea className=\"flex-grow\">\n            <div className=\"grid gap-4 border-t border-gray-200 bg-transparent py-4\">\n              <h4 className=\"font-semibold\">Response</h4>\n              <div className=\"flex items-center gap-8\">\n                <p className=\"text-sm text-gray-500\">HTTP status code</p>\n                <p className=\"text-sm text-gray-700\">{event.http_status}</p>\n              </div>\n              <div className=\"overflow-y-scroll\">\n                <CodeHighlighter\n                  code={JSON.stringify(event.response_body, null, 2)}\n                  language=\"json\"\n                  isDark={isDark}\n                />\n              </div>\n            </div>\n            <div className=\"grid gap-4 border-t border-gray-200 bg-transparent py-4\">\n              <h4 className=\"font-semibold\">Request</h4>\n              <div className=\"overflow-y-scroll\">\n                <CodeHighlighter\n                  code={JSON.stringify(event.request_body, null, 2)}\n                  language=\"json\"\n                  isDark={isDark}\n                />\n              </div>\n            </div>\n          </ScrollArea>\n        </SheetContent>\n      </Sheet>\n    </>\n  );\n};\n\nexport const WebhookEventList = ({ events }: EventListProps) => {\n  return (\n    <div className=\"overflow-hidden rounded-md border border-gray-200 dark:border-gray-600\">\n      <div className=\"flex flex-col divide-y divide-gray-200 dark:divide-gray-600\">\n        {events.map((event, index) => (\n          <WebhookEvent key={index} event={event} />\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/welcome/containers/link-option-container.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { Dispatch, SetStateAction, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { LinkType } from \"@prisma/client\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\n\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\nimport {\n  convertDataUrlToFile,\n  copyToClipboard,\n  uploadImage,\n} from \"@/lib/utils\";\n\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { OnboardingDataroomLinkOptions } from \"@/components/welcome/containers/onboarding-dataroom-link-options\";\nimport { OnboardingLinkOptions } from \"@/components/welcome/containers/onboarding-link-options\";\n\nexport function LinkOptionContainer({\n  currentLinkId,\n  currentDocId,\n  currentDataroomId,\n  linkData,\n  setLinkData,\n}: {\n  currentLinkId: string | null;\n  currentDocId?: string | null;\n  currentDataroomId?: string | null;\n  linkData: DEFAULT_LINK_TYPE;\n  setLinkData: Dispatch<SetStateAction<DEFAULT_LINK_TYPE>>;\n}) {\n  const router = useRouter();\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [showLinkSettings, setShowLinkSettings] = useState<boolean>(true);\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n\n    setIsLoading(true);\n\n    let blobUrl: string | null =\n      linkData.metaImage && linkData.metaImage.startsWith(\"data:\")\n        ? null\n        : linkData.metaImage;\n    if (linkData.metaImage && linkData.metaImage.startsWith(\"data:\")) {\n      const blob = convertDataUrlToFile({ dataUrl: linkData.metaImage });\n      blobUrl = await uploadImage(blob);\n      setLinkData({ ...linkData, metaImage: blobUrl });\n    }\n\n    const response = await fetch(`/api/links/${currentLinkId}`, {\n      method: \"PUT\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...linkData,\n        metaImage: blobUrl,\n        targetId: currentDataroomId || currentDocId,\n        linkType: currentDataroomId\n          ? LinkType.DATAROOM_LINK\n          : LinkType.DOCUMENT_LINK,\n        teamId,\n      }),\n    });\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error);\n      setIsLoading(false);\n      return;\n    }\n\n    if (currentDataroomId) {\n      copyToClipboard(\n        `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${currentLinkId}`,\n        `Link copied to clipboard. Redirecting to dataroom page...`,\n      );\n      router.push(`/datarooms/${currentDataroomId}/documents`);\n    } else {\n      copyToClipboard(\n        `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${currentLinkId}`,\n        `Link copied to clipboard. Redirecting to document page...`,\n      );\n      router.push(`/documents/${currentDocId}`);\n    }\n    setIsLoading(false);\n  };\n\n  return (\n    <motion.div\n      className=\"z-10 flex flex-col space-y-10 text-center\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <h1\n          className={\n            `font-display mx-auto text-center text-3xl font-semibold text-foreground transition-colors sm:text-4xl ` +\n            (showLinkSettings ? \"whitespace-nowrap\" : \"max-w-md\")\n          }\n        >\n          {showLinkSettings\n            ? currentDataroomId\n              ? \"Configure your dataroom link\"\n              : \"Configure your document link\"\n            : currentDataroomId\n              ? \"Securely share your unique dataroom link\"\n              : \"Securely share your unique document link\"}\n        </h1>\n      </motion.div>\n\n      <motion.div variants={STAGGER_CHILD_VARIANTS}>\n        {showLinkSettings && (\n          <main className=\"max-h-[calc(100dvh-10rem)] min-h-[300px] overflow-y-scroll scrollbar-hide\">\n            <div className=\"flex flex-col justify-center\">\n              <div className=\"w-full max-w-xs pb-8 sm:max-w-lg\">\n                {currentDataroomId ? (\n                  <OnboardingDataroomLinkOptions\n                    data={linkData}\n                    setData={setLinkData}\n                    currentPreset={undefined}\n                  />\n                ) : (\n                  <OnboardingLinkOptions\n                    data={linkData}\n                    setData={setLinkData}\n                    linkType={LinkType.DOCUMENT_LINK}\n                    currentPreset={undefined}\n                  />\n                )}\n              </div>\n              <div className=\"mb-4 flex items-center justify-center\">\n                <Button onClick={() => setShowLinkSettings(false)}>\n                  Share {currentDataroomId ? `Dataroom` : `Document`}\n                </Button>\n              </div>\n              <div className=\"text-center text-xs text-muted-foreground\">\n                <span>You can always change settings later.</span>\n              </div>\n            </div>\n          </main>\n        )}\n        {!showLinkSettings &&\n          currentLinkId &&\n          (currentDocId || currentDataroomId) && (\n            <main className=\"max-h-[calc(100dvh-10rem)] min-h-[300px] overflow-y-scroll scrollbar-hide\">\n              <div className=\"flex flex-col justify-center\">\n                <div className=\"relative\">\n                  <div className=\"flex py-8\">\n                    <div className=\"flex w-fit focus-within:z-10\">\n                      <p className=\"block rounded-md border-0 bg-secondary px-4 py-1.5 text-left leading-6 text-secondary-foreground md:min-w-[500px]\">\n                        {`${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${currentLinkId}`}\n                      </p>\n                    </div>\n                  </div>\n                </div>\n                <div className=\"mb-4 flex items-center justify-center\">\n                  <Button onClick={handleSubmit} loading={isLoading}>\n                    Copy & Share\n                  </Button>\n                </div>\n                {!currentDataroomId && (\n                  <div className=\"text-center text-xs text-muted-foreground\">\n                    <span>To see page by page analytics</span>\n                  </div>\n                )}\n              </div>\n            </main>\n          )}\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/containers/onboarding-dataroom-link-options.tsx",
    "content": "import { useState } from \"react\";\n\nimport { LinkPreset, LinkType } from \"@prisma/client\";\n\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport AgreementSection from \"@/components/links/link-sheet/agreement-section\";\nimport AllowDownloadSection from \"@/components/links/link-sheet/allow-download-section\";\nimport AllowListSection from \"@/components/links/link-sheet/allow-list-section\";\nimport AllowNotificationSection from \"@/components/links/link-sheet/allow-notification-section\";\nimport CustomFieldsSection from \"@/components/links/link-sheet/custom-fields-section\";\nimport DenyListSection from \"@/components/links/link-sheet/deny-list-section\";\nimport EmailAuthenticationSection from \"@/components/links/link-sheet/email-authentication-section\";\nimport EmailProtectionSection from \"@/components/links/link-sheet/email-protection-section\";\nimport ExpirationSection from \"@/components/links/link-sheet/expiration-section\";\nimport FeedbackSection from \"@/components/links/link-sheet/feedback-section\";\nimport OGSection from \"@/components/links/link-sheet/og-section\";\nimport PasswordSection from \"@/components/links/link-sheet/password-section\";\nimport { ProBannerSection } from \"@/components/links/link-sheet/pro-banner-section\";\nimport QuestionSection from \"@/components/links/link-sheet/question-section\";\nimport ScreenshotProtectionSection from \"@/components/links/link-sheet/screenshot-protection-section\";\nimport WatermarkSection from \"@/components/links/link-sheet/watermark-section\";\nimport ChevronDown from \"@/components/shared/icons/chevron-down\";\n\nexport const OnboardingDataroomLinkOptions = ({\n  data,\n  setData,\n  targetId,\n  currentPreset = null,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  targetId?: string;\n  currentPreset?: LinkPreset | null;\n}) => {\n  const [showOtherSettings, setShowOtherSettings] = useState(false);\n\n  // Always shown (free)\n  const alwaysShown = (\n    <>\n      <EmailProtectionSection {...{ data, setData }} />\n      <EmailAuthenticationSection\n        {...{ data, setData }}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n      />\n      <AllowNotificationSection {...{ data, setData }} />\n      <AllowDownloadSection {...{ data, setData }} />\n      <PasswordSection {...{ data, setData }} />\n      <AllowListSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n        presets={currentPreset}\n      />\n      <DenyListSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n        presets={currentPreset}\n      />\n      <ScreenshotProtectionSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n      />\n      <WatermarkSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n        presets={currentPreset}\n      />\n      <AgreementSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n      />\n      <CustomFieldsSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n        presets={currentPreset}\n      />\n      <div className=\"mb-4 mt-2\">\n        <button\n          type=\"button\"\n          className=\"group flex w-full items-center justify-between text-sm text-muted-foreground transition-colors hover:text-foreground\"\n          onClick={() => setShowOtherSettings((v) => !v)}\n          aria-expanded={showOtherSettings}\n        >\n          <span className=\"text-sm font-semibold text-gray-900\">\n            Other custom settings\n          </span>\n          <span\n            className={`transition-transform ${showOtherSettings ? \"rotate-180\" : \"\"}`}\n          >\n            <ChevronDown className=\"h-4 w-4\" />\n          </span>\n        </button>\n      </div>\n    </>\n  );\n\n  // Under toggle\n  const otherSettings = (\n    <>\n      <ExpirationSection {...{ data, setData }} presets={currentPreset} />\n      <OGSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n        editLink={false}\n        presets={currentPreset}\n      />\n\n      <FeedbackSection data={data} setData={setData} />\n      <QuestionSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n      />\n\n      <ProBannerSection\n        data={data}\n        setData={setData}\n        isAllowed={true}\n        handleUpgradeStateChange={() => {}}\n      />\n    </>\n  );\n\n  return (\n    <div>\n      {alwaysShown}\n      {showOtherSettings && otherSettings}\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/welcome/containers/onboarding-link-options.tsx",
    "content": "import { useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LinkAudienceType, LinkType } from \"@prisma/client\";\nimport { LinkPreset } from \"@prisma/client\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport AgreementSection from \"@/components/links/link-sheet/agreement-section\";\nimport AllowDownloadSection from \"@/components/links/link-sheet/allow-download-section\";\nimport AllowListSection from \"@/components/links/link-sheet/allow-list-section\";\nimport AllowNotificationSection from \"@/components/links/link-sheet/allow-notification-section\";\nimport ConversationSection from \"@/components/links/link-sheet/conversation-section\";\nimport CustomFieldsSection from \"@/components/links/link-sheet/custom-fields-section\";\nimport DenyListSection from \"@/components/links/link-sheet/deny-list-section\";\nimport EmailAuthenticationSection from \"@/components/links/link-sheet/email-authentication-section\";\nimport EmailProtectionSection from \"@/components/links/link-sheet/email-protection-section\";\nimport ExpirationSection from \"@/components/links/link-sheet/expiration-section\";\nimport FeedbackSection from \"@/components/links/link-sheet/feedback-section\";\nimport OGSection from \"@/components/links/link-sheet/og-section\";\nimport PasswordSection from \"@/components/links/link-sheet/password-section\";\nimport { ProBannerSection } from \"@/components/links/link-sheet/pro-banner-section\";\nimport QuestionSection from \"@/components/links/link-sheet/question-section\";\nimport ScreenshotProtectionSection from \"@/components/links/link-sheet/screenshot-protection-section\";\nimport UploadSection from \"@/components/links/link-sheet/upload-section\";\nimport WatermarkSection from \"@/components/links/link-sheet/watermark-section\";\nimport ChevronDown from \"@/components/shared/icons/chevron-down\";\n\nexport type LinkUpgradeOptions = {\n  state: boolean;\n  trigger: string;\n  plan?: \"Pro\" | \"Business\" | \"Data Rooms\" | \"Data Rooms Plus\";\n  highlightItem?: string[];\n};\n\nexport const OnboardingLinkOptions = ({\n  data,\n  setData,\n  targetId,\n  linkType,\n  editLink,\n  currentPreset = null,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  targetId?: string;\n  linkType: LinkType;\n  editLink?: boolean;\n  currentPreset?: LinkPreset | null;\n}) => {\n  const {\n    isStarter,\n    isPro,\n    isBusiness,\n    isDatarooms,\n    isDataroomsPlus,\n    isTrial,\n  } = usePlan();\n  const { limits } = useLimits();\n  const allowAdvancedLinkControls = limits\n    ? limits?.advancedLinkControlsOnPro\n    : false;\n  const allowWatermarkOnBusiness = limits?.watermarkOnBusiness ?? false;\n  const allowAgreementOnBusiness = limits?.agreementOnBusiness ?? false;\n\n  const [openUpgradeModal, setOpenUpgradeModal] = useState<boolean>(false);\n  const [trigger, setTrigger] = useState<string>(\"\");\n  const [upgradePlan, setUpgradePlan] = useState<PlanEnum>(PlanEnum.Business);\n  const [showAdvancedSettings, setShowAdvancedSettings] =\n    useState<boolean>(false);\n  const [highlightItem, setHighlightItem] = useState<string[]>([]);\n\n  const handleUpgradeStateChange = ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => {\n    setOpenUpgradeModal(state);\n    setTrigger(trigger);\n    if (plan) {\n      setUpgradePlan(plan as PlanEnum);\n    }\n    setHighlightItem(highlightItem || []);\n  };\n\n  // Basic settings that are always shown\n  const basicSettings = (\n    <>\n      <EmailProtectionSection {...{ data, setData }} />\n      <AllowNotificationSection {...{ data, setData }} />\n      <AllowDownloadSection {...{ data, setData }} />\n      <ExpirationSection {...{ data, setData }} presets={currentPreset} />\n      <PasswordSection {...{ data, setData }} />\n      {/* Advanced toggle for documents only */}\n      {linkType === LinkType.DOCUMENT_LINK && (\n        <div className=\"mb-4 mt-2\">\n          <button\n            type=\"button\"\n            className=\"group flex w-full items-center justify-between text-sm text-muted-foreground transition-colors hover:text-foreground\"\n            onClick={() => setShowAdvancedSettings((v) => !v)}\n            aria-expanded={showAdvancedSettings}\n          >\n            <span className=\"text-sm font-semibold text-gray-900\">\n              Advanced settings\n            </span>\n            <span\n              className={`transition-transform ${showAdvancedSettings ? \"rotate-180\" : \"\"}`}\n            >\n              <ChevronDown className=\"h-4 w-4\" />\n            </span>\n          </button>\n        </div>\n      )}\n    </>\n  );\n\n  // Advanced settings that are shown only when showAdvancedSettings is true\n  const advancedSettings = (\n    <>\n      {limits?.dataroomUpload &&\n      linkType === LinkType.DATAROOM_LINK &&\n      targetId ? (\n        <UploadSection\n          {...{ data, setData }}\n          isAllowed={isTrial || isDatarooms || isDataroomsPlus}\n          handleUpgradeStateChange={handleUpgradeStateChange}\n          targetId={targetId}\n        />\n      ) : null}\n      <OGSection\n        {...{ data, setData }}\n        isAllowed={\n          isTrial ||\n          (isPro && allowAdvancedLinkControls) ||\n          isBusiness ||\n          isDatarooms ||\n          isDataroomsPlus\n        }\n        handleUpgradeStateChange={handleUpgradeStateChange}\n        editLink={editLink ?? false}\n        presets={currentPreset}\n      />\n      <EmailAuthenticationSection\n        {...{ data, setData }}\n        isAllowed={\n          isTrial ||\n          (isPro && allowAdvancedLinkControls) ||\n          isBusiness ||\n          isDatarooms ||\n          isDataroomsPlus\n        }\n        handleUpgradeStateChange={handleUpgradeStateChange}\n      />\n      {data.audienceType === LinkAudienceType.GENERAL ? (\n        <AllowListSection\n          {...{ data, setData }}\n          isAllowed={\n            isTrial ||\n            (isPro && allowAdvancedLinkControls) ||\n            isBusiness ||\n            isDatarooms ||\n            isDataroomsPlus\n          }\n          handleUpgradeStateChange={handleUpgradeStateChange}\n          presets={currentPreset}\n        />\n      ) : null}\n      {data.audienceType === LinkAudienceType.GENERAL ? (\n        <DenyListSection\n          {...{ data, setData }}\n          isAllowed={\n            isTrial ||\n            (isPro && allowAdvancedLinkControls) ||\n            isBusiness ||\n            isDatarooms ||\n            isDataroomsPlus\n          }\n          handleUpgradeStateChange={handleUpgradeStateChange}\n          presets={currentPreset}\n        />\n      ) : null}\n      <ScreenshotProtectionSection\n        {...{ data, setData }}\n        isAllowed={\n          isTrial ||\n          (isPro && allowAdvancedLinkControls) ||\n          isBusiness ||\n          isDatarooms ||\n          isDataroomsPlus\n        }\n        handleUpgradeStateChange={handleUpgradeStateChange}\n      />\n      <WatermarkSection\n        {...{ data, setData }}\n        isAllowed={\n          isTrial || isDatarooms || isDataroomsPlus || allowWatermarkOnBusiness\n        }\n        handleUpgradeStateChange={handleUpgradeStateChange}\n        presets={currentPreset}\n      />\n      <AgreementSection\n        {...{ data, setData }}\n        isAllowed={\n          isTrial || isDatarooms || isDataroomsPlus || allowAgreementOnBusiness\n        }\n        handleUpgradeStateChange={handleUpgradeStateChange}\n      />\n      {linkType === LinkType.DATAROOM_LINK &&\n      limits?.conversationsInDataroom ? (\n        <ConversationSection\n          {...{ data, setData }}\n          isAllowed={\n            isDataroomsPlus ||\n            ((isBusiness || isDatarooms) && limits?.conversationsInDataroom)\n          }\n          handleUpgradeStateChange={handleUpgradeStateChange}\n        />\n      ) : null}\n      {linkType === LinkType.DOCUMENT_LINK ? (\n        <>\n          <FeedbackSection {...{ data, setData }} />\n          <QuestionSection\n            {...{ data, setData }}\n            isAllowed={\n              isTrial ||\n              (isPro && allowAdvancedLinkControls) ||\n              isBusiness ||\n              isDatarooms ||\n              isDataroomsPlus\n            }\n            handleUpgradeStateChange={handleUpgradeStateChange}\n          />\n        </>\n      ) : null}\n      <CustomFieldsSection\n        {...{ data, setData }}\n        isAllowed={isTrial || isBusiness || isDatarooms || isDataroomsPlus}\n        handleUpgradeStateChange={handleUpgradeStateChange}\n        presets={currentPreset}\n      />\n      {linkType === LinkType.DOCUMENT_LINK ? (\n        <ProBannerSection\n          {...{ data, setData }}\n          isAllowed={\n            isTrial ||\n            isPro ||\n            isBusiness ||\n            isDatarooms ||\n            isDataroomsPlus ||\n            isStarter\n          }\n          handleUpgradeStateChange={handleUpgradeStateChange}\n        />\n      ) : null}\n    </>\n  );\n\n  return (\n    <div>\n      {basicSettings}\n      {showAdvancedSettings && advancedSettings}\n      <UpgradePlanModal\n        clickedPlan={upgradePlan}\n        open={openUpgradeModal}\n        setOpen={setOpenUpgradeModal}\n        trigger={trigger}\n        highlightItem={highlightItem}\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "components/welcome/containers/upload-container.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { Dispatch, SetStateAction, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\nimport z from \"zod\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\nimport { DocumentData, createDocument } from \"@/lib/documents/create-document\";\nimport { putFile } from \"@/lib/files/put-file\";\nimport { getSupportedContentType } from \"@/lib/utils/get-content-type\";\n\nimport DocumentUpload from \"@/components/document-upload\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function UploadContainer({\n  currentFile,\n  setCurrentFile,\n  setCurrentBlob,\n  setCurrentLinkId,\n  setCurrentDocId,\n  dataroomId,\n}: {\n  currentFile: File | null;\n  setCurrentFile: Dispatch<SetStateAction<File | null>>;\n  setCurrentBlob: Dispatch<SetStateAction<boolean>>;\n  setCurrentLinkId: Dispatch<SetStateAction<string | null>>;\n  setCurrentDocId?: Dispatch<SetStateAction<string | null>>;\n  dataroomId?: string;\n}) {\n  const router = useRouter();\n  const analytics = useAnalytics();\n  const [uploading, setUploading] = useState<boolean>(false);\n\n  const { currentTeamId: teamId } = useTeam();\n\n  const handleBrowserUpload = async (event: any) => {\n    event.preventDefault();\n\n    // Check if the file is chosen\n    if (!currentFile) {\n      toast.error(\"Please select a file to upload.\");\n      return; // prevent form from submitting\n    }\n\n    try {\n      setUploading(true);\n\n      const contentType = currentFile.type;\n      const supportedFileType = getSupportedContentType(currentFile.type);\n\n      if (!supportedFileType) {\n        setUploading(false);\n        toast.error(\n          \"Unsupported file format. Please upload a PDF, Powerpoint, Excel, or Word file.\",\n        );\n        return;\n      }\n\n      const { type, data, numPages, fileSize } = await putFile({\n        file: currentFile,\n        teamId: teamId as string,\n      });\n\n      setCurrentFile(null);\n      setCurrentBlob(true);\n\n      const documentData: DocumentData = {\n        name: currentFile.name,\n        key: data!,\n        storageType: type!,\n        contentType: contentType,\n        supportedFileType: supportedFileType,\n        fileSize: fileSize,\n      };\n      // create a document in the database\n      const response = await createDocument({\n        documentData,\n        teamId: teamId as string,\n        numPages,\n        createLink: dataroomId ? false : true, // don't create a link if the document is being added to a dataroom\n      });\n\n      if (response) {\n        const document = await response.json();\n\n        if (dataroomId) {\n          // add document to dataroom\n          try {\n            const dataroomIdParsed = z.string().cuid().parse(dataroomId);\n\n            await fetch(\n              `/api/teams/${teamId}/datarooms/${dataroomIdParsed}/documents`,\n              {\n                method: \"POST\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify({ documentId: document.id }),\n              },\n            );\n\n            analytics.capture(\"Document Added to Dataroom\", {\n              documentId: document.id,\n              name: document.name,\n              numPages: document.numPages,\n              path: router.asPath,\n              type: document.type,\n              teamId: teamId,\n              dataroomId: dataroomId,\n            });\n\n            // create link to dataroom\n            const newLinkResponse = await fetch(`/api/links`, {\n              method: \"POST\",\n              headers: {\n                \"Content-Type\": \"application/json\",\n              },\n              body: JSON.stringify({\n                targetId: dataroomId,\n                linkType: \"DATAROOM_LINK\",\n                teamId,\n              }),\n            });\n\n            if (newLinkResponse.ok) {\n              const link = await newLinkResponse.json();\n              setCurrentLinkId(link.id);\n\n              analytics.capture(\"Link Added\", {\n                linkId: link.id,\n                dataroomId: dataroomId,\n                customDomain: null,\n                teamId: teamId,\n              });\n            }\n\n            setTimeout(() => {\n              setUploading(false);\n            }, 2000);\n          } catch (error) {\n            console.error(\"Error adding document to dataroom:\", error);\n            toast.error(\"Failed to add document to dataroom\");\n            setUploading(false);\n            return;\n          }\n        } else {\n          const linkId = document.links[0].id;\n\n          // track the event\n          analytics.capture(\"Document Added\", {\n            documentId: document.id,\n            name: document.name,\n            numPages: document.numPages,\n            path: router.asPath,\n            type: document.type,\n            contentType: document.contentType,\n            teamId: teamId,\n          });\n          analytics.capture(\"Link Added\", {\n            linkId: linkId,\n            documentId: document.id,\n            customDomain: null,\n            teamId: teamId,\n          });\n\n          setTimeout(() => {\n            setCurrentDocId && setCurrentDocId(document.id);\n            setCurrentLinkId(linkId);\n            setUploading(false);\n          }, 2000);\n        }\n      }\n    } catch (error) {\n      console.error(\"An error occurred while uploading the file: \", error);\n      setCurrentFile(null);\n      setUploading(false);\n    }\n  };\n\n  return (\n    <motion.div\n      className=\"z-10 flex flex-col space-y-10 text-center\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <h1 className=\"font-display text-balance text-3xl font-semibold text-foreground transition-colors sm:text-4xl\">\n          {dataroomId\n            ? `Upload your first document to your data room`\n            : `Upload your ${\n                router.query.type === \"sales-document\"\n                  ? \"document\"\n                  : `${router.query.type}`\n              }`}\n        </h1>\n      </motion.div>\n      <motion.div variants={STAGGER_CHILD_VARIANTS}>\n        <main className=\"mx-auto mt-8 max-w-md\">\n          <form\n            encType=\"multipart/form-data\"\n            onSubmit={handleBrowserUpload}\n            className=\"flex flex-col\"\n          >\n            <div className=\"space-y-12\">\n              <div className=\"pb-6\">\n                <div className=\"mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6\">\n                  <DocumentUpload\n                    currentFile={currentFile}\n                    setCurrentFile={setCurrentFile}\n                  />\n                </div>\n              </div>\n            </div>\n\n            <div className=\"flex justify-center\">\n              <Button\n                type=\"submit\"\n                className=\"w-full\"\n                loading={uploading}\n                disabled={!currentFile}\n              >\n                {uploading ? \"Uploading...\" : \"Upload Document\"}\n              </Button>\n            </div>\n          </form>\n\n          <div className=\"text-xs text-muted-foreground\">\n            <span>Use our</span>{\" \"}\n            <Button\n              variant=\"link\"\n              className=\"px-0 text-xs font-normal text-muted-foreground underline hover:text-gray-700\"\n              onClick={async () => {\n                const response = await fetch(\n                  \"/_example/papermark-example-document.pdf\",\n                );\n                const blob = await response.blob();\n                const file = new File(\n                  [blob],\n                  \"papermark-example-document.pdf\",\n                  {\n                    type: \"application/pdf\",\n                  },\n                );\n                setCurrentFile(file);\n              }}\n            >\n              sample document\n            </Button>\n          </div>\n        </main>\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/dataroom-ai-generate.tsx",
    "content": "import { useRouter } from \"next/router\";\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { FolderIcon, Sparkles } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nimport { Button } from \"../ui/button\";\nimport { Checkbox } from \"../ui/checkbox\";\nimport { Input } from \"../ui/input\";\nimport { Label } from \"../ui/label\";\nimport { Textarea } from \"../ui/textarea\";\n\nexport default function DataroomAIGenerate() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n  \n  const [aiDescription, setAiDescription] = useState<string>(\"\");\n  const [aiGenerating, setAiGenerating] = useState<boolean>(false);\n  const [generatedFolders, setGeneratedFolders] = useState<any[] | null>(null);\n  const [showPreview, setShowPreview] = useState<boolean>(false);\n  const [dataroomName, setDataroomName] = useState<string>(\"\");\n  const [loading, setLoading] = useState<boolean>(false);\n  const [selectedFolderPaths, setSelectedFolderPaths] = useState<Set<string>>(\n    new Set(),\n  );\n  const [editingFolderPath, setEditingFolderPath] = useState<string | null>(null);\n  const [editingFolderName, setEditingFolderName] = useState<string>(\"\");\n\n  // Generate a unique path for each folder\n  const generateFolderPath = (folder: any, parentPath: string = \"\"): string => {\n    const currentPath = parentPath ? `${parentPath}/${folder.name}` : folder.name;\n    return currentPath;\n  };\n\n  // Initialize all folders as selected when preview is shown\n  const initializeFolderSelection = (folders: any[], parentPath: string = \"\") => {\n    const paths = new Set<string>();\n    const collectPaths = (f: any[], currentPath: string = \"\") => {\n      f.forEach((folder) => {\n        const folderPath = generateFolderPath(folder, currentPath);\n        paths.add(folderPath);\n        if (folder.subfolders) {\n          collectPaths(folder.subfolders, folderPath);\n        }\n      });\n    };\n    collectPaths(folders);\n    setSelectedFolderPaths(paths);\n  };\n\n  // Toggle folder selection\n  const toggleFolderSelection = (path: string, folder: any) => {\n    const newSelected = new Set(selectedFolderPaths);\n    \n    if (newSelected.has(path)) {\n      newSelected.delete(path);\n      const removeSubfolders = (f: any, currentPath: string) => {\n        if (f.subfolders) {\n          f.subfolders.forEach((sub: any) => {\n            const subPath = generateFolderPath(sub, currentPath);\n            newSelected.delete(subPath);\n            removeSubfolders(sub, subPath);\n          });\n        }\n      };\n      removeSubfolders(folder, path);\n    } else {\n      newSelected.add(path);\n    }\n    \n    setSelectedFolderPaths(newSelected);\n  };\n\n  // Filter folders based on selection\n  const filterSelectedFolders = (folders: any[], parentPath: string = \"\"): any[] => {\n    return folders\n      .map((folder) => {\n        const currentPath = generateFolderPath(folder, parentPath);\n        const isSelected = selectedFolderPaths.has(currentPath);\n        \n        if (!isSelected) {\n          return null;\n        }\n        \n        const filteredSubfolders = folder.subfolders\n          ? filterSelectedFolders(folder.subfolders, currentPath)\n          : undefined;\n        \n        return {\n          ...folder,\n          subfolders: filteredSubfolders && filteredSubfolders.length > 0 \n            ? filteredSubfolders \n            : undefined,\n        };\n      })\n      .filter((folder) => folder !== null);\n  };\n\n  // Update folder name in the folder structure\n  const updateFolderName = (\n    folders: any[],\n    targetPath: string,\n    newName: string,\n    currentPath: string = \"\",\n  ): any[] => {\n    return folders.map((folder) => {\n      const folderPath = generateFolderPath(folder, currentPath);\n      \n      if (folderPath === targetPath) {\n        return { ...folder, name: newName };\n      }\n      \n      if (folder.subfolders && folder.subfolders.length > 0) {\n        return {\n          ...folder,\n          subfolders: updateFolderName(\n            folder.subfolders,\n            targetPath,\n            newName,\n            folderPath,\n          ),\n        };\n      }\n      \n      return folder;\n    });\n  };\n\n  // Handle folder name edit\n  const handleFolderNameEdit = (path: string, currentName: string) => {\n    setEditingFolderPath(path);\n    setEditingFolderName(currentName);\n  };\n\n  // Save edited folder name\n  const saveFolderName = () => {\n    if (!editingFolderPath || !generatedFolders) return;\n    \n    if (editingFolderName.trim()) {\n      const updatedFolders = updateFolderName(\n        generatedFolders,\n        editingFolderPath,\n        editingFolderName.trim(),\n      );\n      setGeneratedFolders(updatedFolders);\n      initializeFolderSelection(updatedFolders);\n    }\n    \n    setEditingFolderPath(null);\n    setEditingFolderName(\"\");\n  };\n\n  // Cancel editing\n  const cancelFolderNameEdit = () => {\n    setEditingFolderPath(null);\n    setEditingFolderName(\"\");\n  };\n\n  const handleGenerateFolders = async () => {\n    if (!aiDescription.trim()) {\n      return toast.error(\"Please describe what kind of dataroom you want to create.\");\n    }\n\n    setAiGenerating(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai-structure`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            description: aiDescription.trim(),\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setAiGenerating(false);\n        toast.error(message || \"Failed to generate folder structure\");\n        return;\n      }\n\n      const { name, folders } = await response.json();\n      setGeneratedFolders(folders);\n      if (name) {\n        setDataroomName(name);\n      }\n      initializeFolderSelection(folders);\n      setShowPreview(true);\n      setAiGenerating(false);\n    } catch (error) {\n      setAiGenerating(false);\n      toast.error(\"Error generating folder structure. Please try again.\");\n    }\n  };\n\n  const handleCreateDataroom = async () => {\n    if (!dataroomName.trim()) {\n      return toast.error(\"Please provide a dataroom name.\");\n    }\n\n    if (dataroomName.trim().length < 3) {\n      return toast.error(\n        \"Please provide a dataroom name with at least 3 characters.\",\n      );\n    }\n\n    if (!generatedFolders) return;\n\n    if (!teamInfo?.currentTeam?.id) {\n      return toast.error(\"Team not found. Please refresh the page and try again.\");\n    }\n\n    const filteredFolders = filterSelectedFolders(generatedFolders);\n    \n    if (filteredFolders.length === 0) {\n      return toast.error(\"Please select at least one folder to include.\");\n    }\n\n    setLoading(true);\n    try {\n      const response = await fetch(\n        `/api/teams/${teamInfo.currentTeam.id}/datarooms/generate-ai`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: dataroomName.trim(),\n            folders: filteredFolders,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({}));\n        const errorMessage = errorData.message || errorData.error || \"Error creating dataroom. Please try again.\";\n        setLoading(false);\n        toast.error(errorMessage);\n        return;\n      }\n\n      const { dataroom } = await response.json();\n\n      analytics.capture(\"Dataroom Generated with AI\", {\n        dataroomName: dataroomName.trim(),\n      });\n\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);\n      toast.success(\"Dataroom successfully generated! 🎉\");\n      router.push(`/datarooms/${dataroom.id}/documents`);\n    } catch (error) {\n      setLoading(false);\n      toast.error(\"Error creating dataroom. Please try again.\");\n    }\n  };\n\n  const renderFolderPreview = (\n    folders: any[],\n    indent = 0,\n    parentPath: string = \"\",\n  ) => {\n    return (\n      <div className=\"space-y-1\">\n        {folders.map((folder, index) => {\n          const currentPath = generateFolderPath(folder, parentPath);\n          const isSelected = selectedFolderPaths.has(currentPath);\n          \n          return (\n            <div key={`${parentPath}-${index}`} className=\"pl-4\">\n              <div className=\"flex items-center gap-2 py-1\">\n                <div\n                  className=\"h-4 w-4\"\n                  style={{ marginLeft: `${indent * 16}px` }}\n                />\n                <Checkbox\n                  checked={isSelected}\n                  onCheckedChange={() => toggleFolderSelection(currentPath, folder)}\n                  id={`folder-${currentPath}`}\n                />\n                <label\n                  htmlFor={`folder-${currentPath}`}\n                  className=\"flex items-center gap-2 cursor-pointer flex-1\"\n                >\n                  <FolderIcon className=\"h-4 w-4 text-gray-500\" />\n                  {editingFolderPath === currentPath ? (\n                    <Input\n                      value={editingFolderName}\n                      onChange={(e) => setEditingFolderName(e.target.value)}\n                      onBlur={saveFolderName}\n                      onKeyDown={(e) => {\n                        if (e.key === \"Enter\") {\n                          e.preventDefault();\n                          saveFolderName();\n                        } else if (e.key === \"Escape\") {\n                          e.preventDefault();\n                          cancelFolderNameEdit();\n                        }\n                      }}\n                      className=\"h-6 text-sm px-2 py-1\"\n                      autoFocus\n                      onClick={(e) => e.stopPropagation()}\n                    />\n                  ) : (\n                    <span\n                      className=\"text-sm hover:bg-gray-100 dark:hover:bg-gray-800 px-1 py-0.5 rounded cursor-text\"\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        handleFolderNameEdit(currentPath, folder.name);\n                      }}\n                      title=\"Click to edit folder name\"\n                    >\n                      {folder.name}\n                    </span>\n                  )}\n                </label>\n              </div>\n              {folder.subfolders && folder.subfolders.length > 0 && (\n                <div className=\"ml-4\">\n                  {renderFolderPreview(folder.subfolders, indent + 1, currentPath)}\n                </div>\n              )}\n            </div>\n          );\n        })}\n      </div>\n    );\n  };\n\n  return (\n    <motion.div\n      className=\"z-10 flex flex-col space-y-10 text-center\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <h1 className=\"font-display text-balance text-3xl font-semibold text-foreground transition-colors sm:text-4xl\">\n          Generate dataroom with AI\n        </h1>\n      \n      </motion.div>\n\n      <motion.div variants={STAGGER_CHILD_VARIANTS}>\n        <main className=\"mx-auto mt-8 max-w-md w-full px-4 sm:px-0\">\n          <div className=\"space-y-6\">\n        {!showPreview ? (\n          <>\n            <div className=\"space-y-2 text-left\">\n              <Label htmlFor=\"ai-description\" className=\"text-base\">\n                Describe your dataroom in details{\" \"}\n                <span className=\"text-black dark:text-white\">*</span>\n              </Label>\n              <Textarea\n                id=\"ai-description\"\n                placeholder=\"A data room for a Series B fundraising round for a AI startup called 'Acme AI'. Create advanced data room with IP information and financials.\"\n                value={aiDescription}\n                onChange={(e) => setAiDescription(e.target.value)}\n                rows={4}\n                className=\"resize-none\"\n              />\n            </div>\n            <Button\n              onClick={handleGenerateFolders}\n              loading={aiGenerating}\n              disabled={!aiDescription.trim() || aiGenerating}\n              className=\"w-full\"\n              size=\"lg\"\n            >\n              Generate data room structure\n            </Button>\n          </>\n        ) : (\n          <>\n            <div className=\"space-y-2 text-left\">\n              <Label htmlFor=\"dataroom-name-ai\" className=\"text-base\">\n                Dataroom Name{\" \"}\n                <span className=\"text-black dark:text-white\">*</span>\n              </Label>\n              <Input\n                id=\"dataroom-name-ai\"\n                placeholder=\"AI Generated Data Room\"\n                value={dataroomName}\n                onChange={(e) => setDataroomName(e.target.value)}\n                required\n                className=\"text-base\"\n              />\n            </div>\n            <div className=\"space-y-2 text-left\">\n              <Label className=\"text-base\">\n                Generated Folder Structure{\" \"}\n                <span className=\"text-xs text-muted-foreground font-normal\">\n                  (Select folders to include)\n                </span>\n              </Label>\n              <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n                <Checkbox\n                  checked={\n                    generatedFolders &&\n                    selectedFolderPaths.size > 0 &&\n                    generatedFolders.every((folder) => {\n                      const path = generateFolderPath(folder);\n                      return selectedFolderPaths.has(path);\n                    })\n                      ? true\n                      : false\n                  }\n                  onCheckedChange={(checked) => {\n                    if (checked && generatedFolders) {\n                      initializeFolderSelection(generatedFolders);\n                    } else {\n                      setSelectedFolderPaths(new Set());\n                    }\n                  }}\n                  id=\"select-all\"\n                />\n                <label\n                  htmlFor=\"select-all\"\n                  className=\"cursor-pointer\"\n                >\n                  Select/Deselect All\n                </label>\n              </div>\n              <div className=\"max-h-96 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900\">\n                {generatedFolders && renderFolderPreview(generatedFolders)}\n              </div>\n            </div>\n            <Button\n              onClick={handleCreateDataroom}\n              loading={loading}\n              className=\"w-full\"\n              size=\"lg\"\n            >\n              Create Dataroom\n            </Button>\n          </>\n        )}\n          </div>\n        </main>\n      </motion.div>\n    </motion.div>\n  );\n}\n\n"
  },
  {
    "path": "components/welcome/dataroom-choice.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { FileTextIcon, FolderIcon, Sparkles } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nexport default function DataroomChoice({ dataroomId }: { dataroomId: string }) {\n  const router = useRouter();\n  return (\n    <motion.div\n      className=\"z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <p className=\"text-2xl font-bold tracking-tighter text-foreground\">\n          Papermark\n        </p>\n        <h1 className=\"font-display max-w-md text-3xl font-semibold transition-colors sm:text-4xl\">\n          How would you like to set up your data room?\n        </h1>\n      </motion.div>\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"grid w-full max-w-3xl grid-cols-1 divide-y divide-border rounded-md border border-border text-foreground md:grid-cols-3 md:divide-x md:divide-y-0\"\n      >\n        <button\n          onClick={() =>\n            router.push({\n              pathname: \"/welcome\",\n              query: {\n                type: \"dataroom-upload\",\n                dataroomId,\n              },\n            })\n          }\n          className=\"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10\"\n        >\n          <FolderIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p className=\"text-lg font-medium\">Create from Scratch</p>\n          <p className=\"text-sm text-muted-foreground\">\n            Start with an empty data room and add documents\n          </p>\n        </button>\n        <button\n          onClick={() =>\n            router.push({\n              pathname: \"/welcome\",\n              query: {\n                type: \"dataroom-templates\",\n                dataroomId,\n              },\n            })\n          }\n          className=\"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10\"\n        >\n          <FileTextIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p className=\"text-lg font-medium\">Use a Template</p>\n          <p className=\"text-sm text-muted-foreground\">\n            Start with pre-configured folders for your use case\n          </p>\n        </button>\n        <button\n          onClick={() =>\n            router.push({\n              pathname: \"/welcome\",\n              query: {\n                type: \"dataroom-ai-generate\",\n                dataroomId,\n              },\n            })\n          }\n          className=\"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-orange-50 hover:dark:bg-orange-900/20 md:p-10\"\n        >\n          <Sparkles className=\"pointer-events-none h-auto w-12 sm:w-12 text-orange-500\" />\n          <p className=\"text-lg font-medium text-orange-600 dark:text-orange-400\">\n            Generate with AI\n          </p>\n          <p className=\"text-sm text-muted-foreground\">\n            Let AI create a unique data room structure\n          </p>\n        </button>\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/dataroom-trial.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { E164Number } from \"libphonenumber-js\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\nimport { PhoneInput } from \"@/components/ui/phone-input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\nimport { UpgradePlanModal } from \"../billing/upgrade-plan-modal\";\nimport { Input } from \"../ui/input\";\n\nexport default function DataroomTrial() {\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n  const router = useRouter();\n\n  const [useCase, setUseCase] = useState<string>(\"\");\n  const [customUseCase, setCustomUseCase] = useState<string>(\"\");\n  const [dealSize, setDealSize] = useState<string>(\"\");\n  const [companySize, setCompanySize] = useState<string>(\"\");\n  const [name, setName] = useState<string>(\"\");\n  const [companyName, setCompanyName] = useState<string>(\"\");\n  const [tools, setTools] = useState<string>(\"\");\n\n  const [loading, setLoading] = useState<boolean>(false);\n\n  // Map use case to deal type for survey\n  const useCaseToDealType: Record<string, string> = {\n    \"mergers-and-acquisitions\": \"mergers-acquisitions\",\n    \"startup-fundraising\": \"startup-fundraising\",\n    \"fund-management\": \"fund-management\",\n    sales: \"sales\",\n    \"project-management\": \"project-management\",\n    operations: \"financial-operations\",\n    \"real-estate\": \"real-estate\",\n  };\n\n  // Check if use case needs deal size question\n  const needsDealSize =\n    !!useCase && useCase !== \"project-management\";\n\n  // Helper function to convert use case to proper dataroom name\n  const getDataroomName = (useCaseValue: string, customValue: string = \"\") => {\n    if (useCaseValue === \"other\" && customValue) {\n      return `${customValue} Data Room`;\n    }\n\n    const useCaseNames: Record<string, string> = {\n      \"mergers-and-acquisitions\": \"Mergers & Acquisitions Data Room\",\n      \"startup-fundraising\": \"Startup Fundraising Data Room\",\n      \"fund-management\": \"Fundraising & Reporting Data Room\",\n      sales: \"Sales Data Room\",\n      \"project-management\": \"Project Management Data Room\",\n      operations: \"Financial Operations Data Room\",\n      \"real-estate\": \"Real Estate Data Room\",\n    };\n\n    return useCaseNames[useCaseValue] || \"Data Room\";\n  };\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    if (!name || !companyName || !useCase || !companySize || !tools) {\n      toast.error(\"Please fill out all fields.\");\n      return;\n    }\n\n    // Check if deal size is required but not filled\n    if (needsDealSize && !dealSize) {\n      toast.error(\"Please select a deal size.\");\n      return;\n    }\n\n    setLoading(true);\n\n    const dataroomName = getDataroomName(useCase, customUseCase.trim());\n\n    try {\n      // Save survey data to team\n      const dealType = useCase === \"other\" ? \"other\" : useCaseToDealType[useCase];\n      if (dealType) {\n        await fetch(`/api/teams/${teamInfo?.currentTeam?.id}/survey`, {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify({\n            dealType,\n            dealTypeOther: useCase === \"other\" ? customUseCase.trim() : null,\n            dealSize: dealSize || null,\n          }),\n        });\n      }\n\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/trial`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: dataroomName,\n            fullName: name,\n            companyName,\n            useCase: useCase === \"other\" ? customUseCase.trim() : useCase,\n            companySize,\n            tools,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        setLoading(false);\n        toast.error(message);\n        return;\n      }\n\n      const { id: dataroomId } = await response.json(); // Assuming the API returns the created dataroom's ID\n\n      if (!dataroomId) {\n        throw new Error(\"No dataroom ID returned from the server\");\n      }\n\n      analytics.capture(\"Dataroom Trial Created\", {\n        dataroomName: dataroomName,\n        useCase: useCase === \"other\" ? customUseCase.trim() : useCase,\n        companySize,\n        dealSize,\n        dataroomId,\n      });\n      toast.success(\"Dataroom successfully created! 🎉\");\n\n      await Promise.all([\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),\n        mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),\n      ]);\n\n      // Navigate to dataroom choice page (scratch vs templates)\n      router.push(`/welcome?type=dataroom-choice&dataroomId=${dataroomId}`);\n    } catch (error) {\n      toast.error(\"Error adding dataroom. Please try again.\");\n      console.error(\"Error creating dataroom:\", error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <motion.div\n      className=\"z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <p className=\"text-2xl font-bold tracking-tighter text-foreground\">\n          Papermark\n        </p>\n        <h1 className=\"font-display max-w-lg text-3xl font-semibold transition-colors sm:text-4xl\">\n          Start a 7-day free trial!\n        </h1>\n        {/* <p className=\"mt-2 text-lg text-muted-foreground\">\n          Data Room Plan Trial\n        </p> */}\n      </motion.div>\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"mx-auto mt-24 w-full max-w-md text-left\"\n      >\n        <form onSubmit={handleSubmit} className=\"space-y-4\">\n          <div className=\"space-y-1\">\n            <Label htmlFor=\"name\" className=\"opacity-80\">\n              Your Full Name*\n            </Label>\n            <Input\n              id=\"name\"\n              type=\"text\"\n              autoComplete=\"off\"\n              data-1p-ignore\n              placeholder=\"John Doe\"\n              className=\"mb-4 mt-1 w-full\"\n              onChange={(e) => setName(e.target.value)}\n            />\n          </div>\n          <div className=\"space-y-1\">\n            <Label htmlFor=\"company-name\" className=\"opacity-80\">\n              Company Name*\n            </Label>\n            <Input\n              id=\"company-name\"\n              type=\"text\"\n              autoComplete=\"off\"\n              data-1p-ignore\n              placeholder=\"ACME Inc.\"\n              className=\"mb-4 mt-1 w-full\"\n              onChange={(e) => setCompanyName(e.target.value)}\n            />\n          </div>\n          {/* <div className=\"space-y-1\">\n            <Label className=\"opacity-80\">Industry</Label>\n            <Select onValueChange={(value) => setIndustry(value)}>\n              <SelectTrigger>\n                <SelectValue placeholder=\"Select an industry\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"finance-banking\">\n                  Finance and Banking\n                </SelectItem>\n                <SelectItem value=\"legal\">Legal</SelectItem>\n                <SelectItem value=\"real-estate\">Real Estate</SelectItem>\n                <SelectItem value=\"technology\">Technology</SelectItem>\n                <SelectItem value=\"pharmaceuticals\">Pharmaceuticals</SelectItem>\n                <SelectItem value=\"energy\">Energy</SelectItem>\n                <SelectItem value=\"manufacturing\">Manufacturing</SelectItem>\n                <SelectItem value=\"healthcare\">Healthcare</SelectItem>\n                <SelectItem value=\"consulting\">\n                  Consulting and Professional Services\n                </SelectItem>\n                <SelectItem value=\"government\">\n                  Government and Public Sector\n                </SelectItem>\n                <SelectItem value=\"entertainment\">\n                  Entertainment and Media\n                </SelectItem>\n                <SelectItem value=\"other\">Other</SelectItem>\n              </SelectContent>\n            </Select>\n          </div> */}\n          <div className=\"space-y-1\">\n            <Label className=\"opacity-80\">Company Size*</Label>\n            <Select onValueChange={(value) => setCompanySize(value)}>\n              <SelectTrigger>\n                <SelectValue placeholder=\"Select a company size\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"1\">1 employee</SelectItem>\n                <SelectItem value=\"2-10\">2-10 employees</SelectItem>\n                <SelectItem value=\"11-50\">11-50 employees</SelectItem>\n                <SelectItem value=\"51-200\">51-200 employees</SelectItem>\n                <SelectItem value=\"201-500\">201-500 employees</SelectItem>\n                <SelectItem value=\"501-1000\">501-1,000 employees</SelectItem>\n                <SelectItem value=\"1001-5000\">1,001-5,000 employees</SelectItem>\n                <SelectItem value=\"5001-10000\">\n                  5,001-10,000 employees\n                </SelectItem>\n                <SelectItem value=\"10001+\">10,001+ employees</SelectItem>\n              </SelectContent>\n            </Select>\n          </div>\n          <div className=\"space-y-1\">\n            <Label className=\"opacity-80\">Use Case*</Label>\n            <Select\n              onValueChange={(value) => {\n                setUseCase(value);\n                if (value !== \"other\") {\n                  setCustomUseCase(\"\");\n                }\n                // Reset deal size when use case changes\n                setDealSize(\"\");\n              }}\n            >\n              <SelectTrigger>\n                <SelectValue placeholder=\"Select your use case\" />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectItem value=\"mergers-and-acquisitions\">\n                  Mergers & Acquisitions\n                </SelectItem>\n                <SelectItem value=\"startup-fundraising\">\n                  Startup Fundraising\n                </SelectItem>\n                <SelectItem value=\"fund-management\">\n                  Fundraising & Reporting\n                </SelectItem>\n                <SelectItem value=\"sales\">Sales</SelectItem>\n                <SelectItem value=\"project-management\">\n                  Project Management\n                </SelectItem>\n                <SelectItem value=\"operations\">Financial Operations</SelectItem>\n                <SelectItem value=\"real-estate\">Real Estate</SelectItem>\n\n                <SelectItem value=\"other\">Other</SelectItem>\n              </SelectContent>\n            </Select>\n            {useCase === \"other\" && (\n              <input\n                type=\"text\"\n                className=\"mt-2 w-full rounded border border-gray-300 px-3 py-2 text-sm\"\n                placeholder=\"Please specify your use case\"\n                value={customUseCase}\n                onChange={(e) => setCustomUseCase(e.target.value)}\n              />\n            )}\n          </div>\n\n          {/* Deal Size - shown after use case is selected (except project-management and operations) */}\n          {needsDealSize && (\n            <div className=\"space-y-1\">\n              <Label className=\"opacity-80\">\n                {useCase === \"startup-fundraising\" || useCase === \"fund-management\"\n                  ? \"How much are you raising?*\"\n                  : \"What's the deal size?*\"}\n              </Label>\n              <Select onValueChange={(value) => setDealSize(value)}>\n                <SelectTrigger>\n                  <SelectValue placeholder=\"Select deal size\" />\n                </SelectTrigger>\n                <SelectContent>\n                  <SelectItem value=\"0-500k\">$0 - $500K</SelectItem>\n                  <SelectItem value=\"500k-5m\">$500K - $5M</SelectItem>\n                  <SelectItem value=\"5m-10m\">$5M - $10M</SelectItem>\n                  <SelectItem value=\"10m-100m\">$10M - $100M</SelectItem>\n                  <SelectItem value=\"100m+\">$100M+</SelectItem>\n                </SelectContent>\n              </Select>\n            </div>\n          )}\n\n          <div className=\"space-y-1\">\n            <Label htmlFor=\"tools\" className=\"opacity-80\">\n              Tools*\n            </Label>\n            <Input\n              id=\"tools\"\n              type=\"text\"\n              autoComplete=\"off\"\n              data-1p-ignore\n              placeholder=\"Current software you are using for data rooms\"\n              className=\"mb-4 mt-1 w-full\"\n              onChange={(e) => setTools(e.target.value)}\n            />\n          </div>\n          {/* <div className=\"space-y-1\">\n            <Label className=\"opacity-80\">Phone Number</Label>\n            <PhoneInput\n              placeholder=\"+1 123 456 7890\"\n              onChange={(value) => setPhoneNumber(value)}\n              defaultCountry=\"US\"\n            />\n          </div> */}\n\n          <div className=\"space-y-4 text-center\">\n            <Button\n              type=\"submit\"\n              className=\"h-9 w-full\"\n              disabled={\n                !tools ||\n                !companySize ||\n                !useCase ||\n                !name ||\n                !companyName ||\n                (useCase === \"other\" && !customUseCase.trim()) ||\n                (needsDealSize && !dealSize)\n              }\n              loading={loading}\n            >\n              Access your data room\n            </Button>\n\n            <div className=\"text-xs text-muted-foreground\">\n              {/* Data rooms are available on our{\" \"}\n              <UpgradePlanModal clickedPlan={PlanEnum.Business}>\n                <button className=\"underline\">Business</button>\n              </UpgradePlanModal>{\" \"}\n              plan. <br /> */}\n              No credit card is required. After the trial, upgrade to{\" \"}\n              <UpgradePlanModal\n                clickedPlan={PlanEnum.Business}\n                highlightItem={[\"datarooms\"]}\n                trigger=\"dataroom_trial_form\"\n              >\n                <button className=\"underline\">Papermark Data Rooms</button>\n              </UpgradePlanModal>{\" \"}\n              to continue using data rooms.\n            </div>\n          </div>\n        </form>\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/dataroom-upload.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { LinkType } from \"@prisma/client\";\n\nimport {\n  DEFAULT_LINK_PROPS,\n  DEFAULT_LINK_TYPE,\n} from \"@/components/links/link-sheet\";\n\nimport { LinkOptionContainer } from \"./containers/link-option-container\";\nimport { UploadContainer } from \"./containers/upload-container\";\n\nexport default function DataroomUpload({ dataroomId }: { dataroomId: string }) {\n  const router = useRouter();\n  const { groupId } = router.query as {\n    groupId?: string;\n  };\n  const [currentFile, setCurrentFile] = useState<File | null>(null);\n  const [currentBlob, setCurrentBlob] = useState<boolean>(false);\n  const [currentLinkId, setCurrentLinkId] = useState<string | null>(null);\n  const [linkData, setLinkData] = useState<DEFAULT_LINK_TYPE>(\n    DEFAULT_LINK_PROPS(LinkType.DATAROOM_LINK, groupId),\n  );\n\n  if (!currentBlob) {\n    return (\n      <UploadContainer\n        currentFile={currentFile}\n        setCurrentFile={setCurrentFile}\n        setCurrentBlob={setCurrentBlob}\n        setCurrentLinkId={setCurrentLinkId}\n        dataroomId={dataroomId}\n      />\n    );\n  }\n\n  if (currentBlob) {\n    return (\n      <LinkOptionContainer\n        currentLinkId={currentLinkId}\n        linkData={linkData}\n        setLinkData={setLinkData}\n        currentDataroomId={dataroomId}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "components/welcome/dataroom.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { motion } from \"motion/react\";\n\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nimport { Button } from \"../ui/button\";\n\nexport default function Dataroom() {\n  const router = useRouter();\n  return (\n    <motion.div\n      className=\"z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <p className=\"text-2xl font-bold tracking-tighter text-foreground\">\n          Papermark\n        </p>\n        <h1 className=\"font-display max-w-xl text-3xl font-semibold transition-colors sm:text-4xl\">\n          Get started with data rooms!\n        </h1>\n      </motion.div>\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"mx-auto mt-24 w-full\"\n      >\n        <video\n          width=\"100%\"\n          id=\"video1\"\n          style={{ borderRadius: \"6px\" }}\n          aria-hidden=\"true\"\n          playsInline\n          autoPlay\n          muted\n          loop\n          controls\n        >\n          <source\n            src=\"https://assets.papermark.io/upload/file_A4qNV68jr3MAUayMNi3WmY-Data-Room-demo-2.mp4\"\n            type=\"video/mp4\"\n          />\n        </video>\n      </motion.div>\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"mt-10 flex flex-col items-center space-y-4 text-center\"\n      >\n        <Button\n          className=\"px-10 text-base font-medium\"\n          onClick={() =>\n            router.push({\n              pathname: \"/welcome\",\n              query: {\n                type: \"dataroom-trial\",\n              },\n            })\n          }\n        >\n          Get a data room trial\n        </Button>\n        <span className=\"text-xs text-muted-foreground\">\n          Data rooms are available on our `Data Rooms` plans and on the\n          `Business` plan. <br />\n          You receive a 7-day trial.\n        </span>\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/intro.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { motion } from \"motion/react\";\n\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nimport { Button } from \"../ui/button\";\n\nexport default function Intro() {\n  const router = useRouter();\n\n  return (\n    <motion.div\n      className=\"z-10\"\n      exit={{ opacity: 0, scale: 0.95 }}\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={{\n          show: {\n            transition: {\n              staggerChildren: 0.2,\n            },\n          },\n        }}\n        initial=\"hidden\"\n        animate=\"show\"\n        className=\"mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto\"\n      >\n        <motion.h1\n          className=\"font-display text-4xl font-bold text-foreground transition-colors sm:text-5xl\"\n          variants={STAGGER_CHILD_VARIANTS}\n        >\n          Welcome to{\" \"}\n          <span className=\"font-bold tracking-tighter\">Papermark</span>\n        </motion.h1>\n        <motion.p\n          className=\"max-w-md text-accent-foreground/80 transition-colors sm:text-lg\"\n          variants={STAGGER_CHILD_VARIANTS}\n        >\n          Papermark gives you the power to securely share your documents with an\n          impression that lasts.\n        </motion.p>\n        <motion.div\n          variants={STAGGER_CHILD_VARIANTS}\n          // className=\"rounded  px-10 py-2 font-medium transition-colors text-gray-900 bg-gray-100 hover:text-gray-100 hover:bg-gray-500\"\n        >\n          <Button\n            className=\"px-10 text-base font-medium\"\n            onClick={() =>\n              router.push({\n                pathname: \"/welcome\",\n                query: {\n                  type: \"next\",\n                },\n              })\n            }\n          >\n            Get Started\n          </Button>\n        </motion.div>\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/next.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { FileIcon, ServerIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nexport default function Next() {\n  const router = useRouter();\n  return (\n    <motion.div\n      className=\"z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <p className=\"text-2xl font-bold tracking-tighter text-foreground\">\n          Papermark\n        </p>\n        <h1 className=\"font-display max-w-md text-3xl font-semibold transition-colors sm:text-4xl\">\n          What do you want to share today?\n        </h1>\n      </motion.div>\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"grid w-full max-w-2xl grid-cols-1 divide-y divide-border rounded-md border border-border text-foreground md:grid-cols-2 md:divide-x\"\n      >\n        <button\n          onClick={() =>\n            router.push({\n              pathname: \"/welcome\",\n              query: {\n                type: \"select\",\n              },\n            })\n          }\n          className=\"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10\"\n        >\n          <FileIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p className=\"text-lg font-medium\">Document</p>\n          <p className=\"text-sm text-muted-foreground\">\n            Share a single document with page-by-page analytics\n          </p>\n        </button>\n        <button\n          onClick={() =>\n            router.push({\n              pathname: \"/welcome\",\n              query: {\n                type: \"dataroom\",\n              },\n            })\n          }\n          className=\"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10\"\n        >\n          <ServerIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p className=\"text-lg font-medium\">Data Room</p>\n          <p className=\"text-sm text-muted-foreground\">\n            Share multiple documents with folders and page-by-page analytics\n          </p>\n        </button>\n      </motion.div>\n\n      {/* <motion.div variants={STAGGER_CHILD_VARIANTS} className=\"text-center\">\n        <button\n          className=\"text-center text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80\"\n          onClick={() =>\n            router.push({\n              pathname: \"/welcome\",\n              query: {\n                type: \"dataroom\",\n              },\n            })\n          }\n        >\n          Sharing Data Room is possible in 7 day free trial\n        </button>\n      </motion.div> */}\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/notion-form.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { type FormEvent, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { LinkType } from \"@prisma/client\";\nimport { motion } from \"motion/react\";\nimport { parsePageId } from \"notion-utils\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\nimport {\n  convertDataUrlToFile,\n  copyToClipboard,\n  uploadImage,\n} from \"@/lib/utils\";\n\nimport {\n  DEFAULT_LINK_PROPS,\n  DEFAULT_LINK_TYPE,\n} from \"@/components/links/link-sheet\";\nimport { LinkOptions } from \"@/components/links/link-sheet/link-options\";\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { Button } from \"@/components/ui/button\";\nimport { Label } from \"@/components/ui/label\";\n\nimport Skeleton from \"../Skeleton\";\n\nexport default function NotionForm() {\n  const router = useRouter();\n  const analytics = useAnalytics();\n  const [uploading, setUploading] = useState<boolean>(false);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [currentLinkId, setCurrentLinkId] = useState<string | null>(null);\n  const [currentDocId, setCurrentDocId] = useState<string | null>(null);\n  const [notionLink, setNotionLink] = useState<string | null>(null);\n  const [linkData, setLinkData] = useState<DEFAULT_LINK_TYPE>(\n    DEFAULT_LINK_PROPS(LinkType.DOCUMENT_LINK),\n  );\n  const teamInfo = useTeam();\n\n  const createNotionFileName = () => {\n    // Extract Notion file name from the URL\n    const urlSegments = (notionLink as string).split(\"/\")[3];\n    // Remove the last hyphen along with the Notion ID\n    const extractName = urlSegments.replace(/-([^/-]+)$/, \"\");\n    const notionFileName = extractName.replaceAll(\"-\", \" \") || \"Notion Link\";\n\n    return notionFileName;\n  };\n\n  const handleNotionUpload = async (\n    event: FormEvent<HTMLFormElement>,\n  ): Promise<void> => {\n    event.preventDefault();\n    const validateNotionPageURL = parsePageId(notionLink);\n    // Check if it's a valid URL or not by Regx\n    const isValidURL =\n      /^(https?:\\/\\/)?([a-zA-Z0-9-]+\\.){1,}[a-zA-Z]{2,}([a-zA-Z0-9-._~:/?#[\\]@!$&'()*+,;=]+)?$/;\n\n    // Check if the field is empty or not\n    if (!notionLink) {\n      toast.error(\"Please enter a Notion link to proceed.\");\n      return; // prevent form from submitting\n    }\n    if (validateNotionPageURL === null || !isValidURL.test(notionLink)) {\n      toast.error(\"Please enter a valid Notion link to proceed.\");\n      return;\n    }\n\n    try {\n      setUploading(true);\n\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/documents`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            name: createNotionFileName(),\n            url: notionLink,\n            numPages: 1,\n            type: \"notion\",\n            createLink: true,\n          }),\n        },\n      );\n\n      if (response) {\n        const document = await response.json();\n        const linkId = document.links[0].id;\n\n        // track the event\n        analytics.capture(\"Document Added\", {\n          documentId: document.id,\n          name: document.name,\n          fileSize: null,\n          path: router.asPath,\n          type: \"notion\",\n          teamId: teamInfo?.currentTeam?.id,\n        });\n        analytics.capture(\"Link Added\", {\n          linkId: document.links[0].id,\n          documentId: document.id,\n          customDomain: null,\n          teamId: teamInfo?.currentTeam?.id,\n        });\n\n        // redirect to the document page\n        setTimeout(() => {\n          setCurrentDocId(document.id);\n          setCurrentLinkId(linkId);\n          setUploading(false);\n        }, 2000);\n      }\n    } catch (error) {\n      setUploading(false);\n      toast.error(\n        \"Oops! Can't access the Notion page. Please double-check it's set to 'Public'.\",\n      );\n      console.error(\n        \"An error occurred while processing the Notion link: \",\n        error,\n      );\n    } finally {\n      setNotionLink(null);\n      setUploading(false);\n    }\n  };\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n\n    setIsLoading(true);\n\n    // Upload the image if it's a data URL\n    let blobUrl: string | null =\n      linkData.metaImage && linkData.metaImage.startsWith(\"data:\")\n        ? null\n        : linkData.metaImage;\n    if (linkData.metaImage && linkData.metaImage.startsWith(\"data:\")) {\n      // Convert the data URL to a blob\n      const blob = convertDataUrlToFile({ dataUrl: linkData.metaImage });\n      // Upload the blob to vercel storage\n      blobUrl = await uploadImage(blob);\n      setLinkData({ ...linkData, metaImage: blobUrl });\n    }\n\n    const response = await fetch(`/api/links/${currentLinkId}`, {\n      method: \"PUT\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...linkData,\n        metaImage: blobUrl,\n        targetId: currentDocId,\n        linkType: LinkType.DOCUMENT_LINK,\n        teamId: teamInfo?.currentTeam?.id,\n      }),\n    });\n\n    if (!response.ok) {\n      // handle error with toast message\n      const { error } = await response.json();\n      toast.error(error);\n      setIsLoading(false);\n      return;\n    }\n\n    copyToClipboard(\n      `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${currentLinkId}`,\n      \"Link copied to clipboard. Redirecting to document page...\",\n    );\n\n    router.push(`/documents/${currentDocId}`);\n    setIsLoading(false);\n  };\n\n  return (\n    <>\n      {!currentDocId && (\n        <motion.div\n          className=\"z-10 -mt-10 flex flex-col space-y-10\"\n          variants={{\n            hidden: { opacity: 0, scale: 0.95 },\n            show: {\n              opacity: 1,\n              scale: 1,\n              transition: {\n                staggerChildren: 0.2,\n              },\n            },\n          }}\n          initial=\"hidden\"\n          animate=\"show\"\n          exit=\"hidden\"\n          transition={{ duration: 0.3, type: \"spring\" }}\n        >\n          <motion.div\n            variants={STAGGER_CHILD_VARIANTS}\n            className=\"flex flex-col items-center space-y-10 text-center\"\n          >\n            <h1 className=\"font-display text-3xl font-semibold text-foreground transition-colors sm:text-4xl\">\n              Share a Notion Page\n            </h1>\n          </motion.div>\n          <motion.div variants={STAGGER_CHILD_VARIANTS}>\n            <form\n              encType=\"multipart/form-data\"\n              onSubmit={handleNotionUpload}\n              className=\"flex flex-col\"\n            >\n              <div className=\"space-y-1 pb-8\">\n                <Label htmlFor=\"notion-link\">Add Notion Page Link</Label>\n                <div className=\"mt-2\">\n                  <input\n                    type=\"text\"\n                    name=\"notion-link\"\n                    id=\"notion-link\"\n                    placeholder=\"notion.site/...\"\n                    className=\"flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6\"\n                    value={notionLink || \"\"}\n                    onChange={(e) => setNotionLink(e.target.value)}\n                  />\n                </div>\n                <small className=\"text-xs text-muted-foreground\">\n                  Your Notion page needs to be shared publicly.\n                </small>\n              </div>\n              <div className=\"flex justify-center\">\n                <Button\n                  type=\"submit\"\n                  className=\"w-full lg:w-1/2\"\n                  disabled={uploading || !notionLink}\n                  loading={uploading}\n                >\n                  {uploading ? \"Saving...\" : \"Save Notion Link\"}\n                </Button>\n              </div>\n            </form>\n\n            <div className=\"text-center text-xs text-muted-foreground\">\n              <span>Use our</span>{\" \"}\n              <Button\n                variant=\"link\"\n                className=\"px-0 text-xs font-normal text-muted-foreground underline hover:text-gray-700\"\n                onClick={async () => {\n                  setNotionLink(\n                    \"https://mfts.notion.site/Papermark-7b582345016b42b6951396f6ee626121\",\n                  );\n                }}\n              >\n                sample Notion link\n              </Button>\n            </div>\n          </motion.div>\n        </motion.div>\n      )}\n\n      {currentDocId && (\n        <motion.div\n          className=\"z-10 flex flex-col space-y-10 text-center\"\n          variants={{\n            hidden: { opacity: 0, scale: 0.95 },\n            show: {\n              opacity: 1,\n              scale: 1,\n              transition: {\n                staggerChildren: 0.2,\n              },\n            },\n          }}\n          initial=\"hidden\"\n          animate=\"show\"\n          exit=\"hidden\"\n          transition={{ duration: 0.3, type: \"spring\" }}\n        >\n          <motion.div\n            variants={STAGGER_CHILD_VARIANTS}\n            className=\"flex flex-col items-center space-y-10 text-center\"\n          >\n            <h1 className=\"font-display text-3xl font-semibold text-foreground transition-colors sm:text-4xl\">\n              Share your unique link\n            </h1>\n          </motion.div>\n\n          <motion.div variants={STAGGER_CHILD_VARIANTS}>\n            {!currentLinkId && (\n              <main className=\"min-h-[300px]\">\n                <div className=\"flex flex-col justify-center\">\n                  <div className=\"flex py-8\">\n                    <div className=\"flex w-full focus-within:z-10\">\n                      <Skeleton className=\"h-6 w-full\" />\n                    </div>\n                  </div>\n                </div>\n              </main>\n            )}\n            {currentLinkId && currentDocId && (\n              <main className=\"max-h-[calc(100dvh-10rem)] min-h-[300px] overflow-y-scroll scrollbar-hide\">\n                <div className=\"flex flex-col justify-center\">\n                  <div className=\"relative\">\n                    <div className=\"flex py-8\">\n                      <div className=\"flex w-full max-w-xs focus-within:z-10 sm:max-w-lg\">\n                        <p className=\"block w-full overflow-y-scroll rounded-md border-0 bg-secondary px-4 py-1.5 text-left leading-6 text-secondary-foreground md:min-w-[500px]\">\n                          {`${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${currentLinkId}`}\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                  <div className=\"w-full max-w-xs pb-8 sm:max-w-lg\">\n                    <Accordion type=\"single\" collapsible>\n                      <AccordionItem value=\"item-1\" className=\"border-none\">\n                        <AccordionTrigger className=\"space-x-2 rounded-lg py-0\">\n                          <span className=\"text-sm font-medium leading-6 text-foreground\">\n                            Configure Link Options\n                          </span>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"first:pt-5\">\n                          <LinkOptions\n                            data={linkData}\n                            setData={setLinkData}\n                            linkType={LinkType.DOCUMENT_LINK}\n                          />\n                        </AccordionContent>\n                      </AccordionItem>\n                    </Accordion>\n                  </div>\n                  <div className=\"mb-4 flex items-center justify-center\">\n                    <Button loading={isLoading} onClick={handleSubmit}>\n                      Share document link\n                    </Button>\n                  </div>\n                  <div className=\"text-center text-xs text-muted-foreground\">\n                    <span>You can change configurations later</span>\n                  </div>\n                </div>\n              </main>\n            )}\n          </motion.div>\n        </motion.div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/welcome/select.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { FileChartPieIcon, FileIcon, PresentationIcon } from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nimport NotionIcon from \"@/components/shared/icons/files/notion\";\n\nconst DEAL_TYPE_OPTIONS = [\n  { value: \"startup-fundraising\", label: \"Startup Fundraising\" },\n  { value: \"fund-management\", label: \"Fundraising & Reporting\" },\n  { value: \"mergers-acquisitions\", label: \"Mergers & Acquisitions\" },\n  { value: \"financial-operations\", label: \"Financial Operations\" },\n  { value: \"real-estate\", label: \"Real Estate\" },\n  { value: \"project-management\", label: \"Project Management\" },\n];\n\nconst DEAL_SIZE_OPTIONS = [\n  { value: \"0-500k\", label: \"$0-500K\" },\n  { value: \"500k-5m\", label: \"$500K-5M\" },\n  { value: \"5m-10m\", label: \"$5M-10M\" },\n  { value: \"10m-100m\", label: \"$10M-100M\" },\n  { value: \"100m+\", label: \"$100M+\" },\n];\n\nexport default function Select() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const [selectedDoc, setSelectedDoc] = useState<string | null>(null);\n  const [dealType, setDealType] = useState<string | null>(null);\n  const [dealTypeOther, setDealTypeOther] = useState<string>(\"\");\n  const [showOtherInput, setShowOtherInput] = useState(false);\n  const [dealSize, setDealSize] = useState<string | null>(null);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  // Determine which survey step to show based on document type\n  const needsDealTypeQuestion =\n    selectedDoc === \"notion\" || selectedDoc === \"document\";\n  const needsDealSizeQuestion =\n    selectedDoc === \"pitchdeck\" ||\n    selectedDoc === \"sales-document\" ||\n    (needsDealTypeQuestion && dealType && dealType !== \"project-management\");\n\n  const showDealTypeOptions = needsDealTypeQuestion && !dealType && !showOtherInput;\n  const showDealSizeOptions =\n    needsDealSizeQuestion &&\n    (dealType || !needsDealTypeQuestion) &&\n    !showOtherInput;\n\n  const handleDocSelect = (docType: string) => {\n    setSelectedDoc(docType);\n    setDealType(null);\n    setDealTypeOther(\"\");\n    setShowOtherInput(false);\n    setDealSize(null);\n\n    // Auto-set deal type for specific document types\n    if (docType === \"pitchdeck\") {\n      setDealType(\"startup-fundraising\");\n    } else if (docType === \"sales-document\") {\n      setDealType(\"sales\");\n    }\n  };\n\n  const handleDealTypeSelect = (value: string) => {\n    if (isSubmitting) return;\n    setDealType(value);\n    setShowOtherInput(false);\n    if (value === \"project-management\") {\n      setIsSubmitting(true);\n      saveSurveyAndProceed(value, null, null);\n    }\n  };\n\n  const handleOtherConfirm = () => {\n    if (!dealTypeOther.trim()) return;\n    setDealType(\"other\");\n    setShowOtherInput(false);\n    // Show deal size question after confirming \"Other\"\n  };\n\n  const handleDealSizeSelect = (value: string) => {\n    if (isSubmitting) return;\n    setDealSize(value);\n    setIsSubmitting(true);\n    saveSurveyAndProceed(dealType, value, dealTypeOther || null);\n  };\n\n  const saveSurveyAndProceed = async (\n    type: string | null,\n    size: string | null,\n    otherText: string | null,\n  ) => {\n    setIsSubmitting(true);\n\n    try {\n      if (teamInfo?.currentTeam?.id && type) {\n        const res = await fetch(\n          `/api/teams/${teamInfo.currentTeam.id}/survey`,\n          {\n            method: \"POST\",\n            headers: { \"Content-Type\": \"application/json\" },\n            body: JSON.stringify({\n              dealType: type,\n              dealTypeOther: otherText,\n              dealSize: size,\n            }),\n          },\n        );\n\n        if (!res.ok) {\n          throw new Error(`Survey save failed: ${res.status}`);\n        }\n      }\n\n      await router.push({\n        pathname: \"/welcome\",\n        query: { type: selectedDoc },\n      });\n    } catch (error) {\n      console.error(\"Failed to save survey:\", error);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const getDealSizeQuestion = () => {\n    if (\n      selectedDoc === \"pitchdeck\" ||\n      dealType === \"startup-fundraising\" ||\n      dealType === \"fund-management\"\n    ) {\n      return \"How much are you raising?\";\n    }\n    if (\n      dealType === \"mergers-acquisitions\" ||\n      dealType === \"real-estate\" ||\n      dealType === \"financial-operations\" ||\n      dealType === \"other\"\n    ) {\n      return \"What's the deal size?\";\n    }\n    return \"What's the typical deal size?\";\n  };\n\n  return (\n    <motion.div\n      className=\"z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <p className=\"text-2xl font-bold tracking-tighter text-foreground\">\n          Papermark\n        </p>\n        <h1 className=\"font-display max-w-md text-3xl font-semibold transition-colors sm:text-4xl\">\n          Which document do you want to share today?\n        </h1>\n      </motion.div>\n\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"grid w-full grid-cols-1 divide-y divide-border rounded-md border border-border text-foreground md:grid-cols-4 md:divide-x\"\n      >\n        <button\n          onClick={() => handleDocSelect(\"pitchdeck\")}\n          className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${\n            selectedDoc === \"pitchdeck\"\n              ? \"bg-primary/5 ring-2 ring-inset ring-primary\"\n              : \"hover:bg-gray-200 hover:dark:bg-gray-800\"\n          }`}\n        >\n          <PresentationIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p>Pitchdeck</p>\n        </button>\n\n        <button\n          onClick={() => handleDocSelect(\"sales-document\")}\n          className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${\n            selectedDoc === \"sales-document\"\n              ? \"bg-primary/5 ring-2 ring-inset ring-primary\"\n              : \"hover:bg-gray-200 hover:dark:bg-gray-800\"\n          }`}\n        >\n          <FileChartPieIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p>Sales document</p>\n        </button>\n\n        <button\n          onClick={() => handleDocSelect(\"notion\")}\n          className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${\n            selectedDoc === \"notion\"\n              ? \"bg-primary/5 ring-2 ring-inset ring-primary\"\n              : \"hover:bg-gray-200 hover:dark:bg-gray-800\"\n          }`}\n        >\n          <NotionIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p>Notion Page</p>\n        </button>\n\n        <button\n          onClick={() => handleDocSelect(\"document\")}\n          className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${\n            selectedDoc === \"document\"\n              ? \"bg-primary/5 ring-2 ring-inset ring-primary\"\n              : \"hover:bg-gray-200 hover:dark:bg-gray-800\"\n          }`}\n        >\n          <FileIcon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n          <p>Another document</p>\n        </button>\n      </motion.div>\n\n      {/* Inline Survey Questions */}\n      {selectedDoc && (\n        <motion.div\n          initial={{ opacity: 0, y: 10 }}\n          animate={{ opacity: 1, y: 0 }}\n          className=\"w-full max-w-xl space-y-4\"\n        >\n          {/* Deal Type Question (for notion/document) */}\n          {showDealTypeOptions && (\n            <div className=\"space-y-3\">\n              <p className=\"text-sm font-medium text-muted-foreground\">\n                What do you use Papermark for?\n              </p>\n              <div className=\"flex flex-wrap justify-center gap-2\">\n                {DEAL_TYPE_OPTIONS.map((option) => (\n                  <button\n                    key={option.value}\n                    onClick={() => handleDealTypeSelect(option.value)}\n                    disabled={isSubmitting}\n                    className=\"rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5\"\n                  >\n                    {option.label}\n                  </button>\n                ))}\n                <button\n                  onClick={() => setShowOtherInput(true)}\n                  disabled={isSubmitting}\n                  className=\"rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5\"\n                >\n                  Other\n                </button>\n              </div>\n            </div>\n          )}\n\n          {/* Other Input Field - inline */}\n          {showOtherInput && !dealType && (\n            <div className=\"space-y-3\">\n              <p className=\"text-sm font-medium text-muted-foreground\">\n                What do you use Papermark for?\n              </p>\n              <div className=\"flex items-center justify-center gap-2\">\n                <input\n                  type=\"text\"\n                  value={dealTypeOther}\n                  onChange={(e) => setDealTypeOther(e.target.value)}\n                  placeholder=\"Please specify...\"\n                  className=\"max-w-xs rounded-full border border-border bg-background px-4 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary\"\n                  autoFocus\n                  onKeyDown={(e) => {\n                    if (e.key === \"Enter\" && dealTypeOther.trim()) {\n                      handleOtherConfirm();\n                    }\n                  }}\n                />\n                <button\n                  onClick={handleOtherConfirm}\n                  disabled={!dealTypeOther.trim() || isSubmitting}\n                  className=\"rounded-full border border-primary bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-all hover:bg-primary/90 disabled:opacity-50\"\n                >\n                  →\n                </button>\n              </div>\n            </div>\n          )}\n\n          {/* Deal Size Question */}\n          {showDealSizeOptions && !showDealTypeOptions && (\n            <div className=\"space-y-3\">\n              <p className=\"text-sm font-medium text-muted-foreground\">\n                {getDealSizeQuestion()}\n              </p>\n              <div className=\"flex flex-wrap justify-center gap-2\">\n                {DEAL_SIZE_OPTIONS.map((option) => (\n                  <button\n                    key={option.value}\n                    onClick={() => handleDealSizeSelect(option.value)}\n                    disabled={isSubmitting}\n                    className=\"rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5\"\n                  >\n                    {option.label}\n                  </button>\n                ))}\n              </div>\n            </div>\n          )}\n\n          {/* Skip option */}\n          <button\n            onClick={() => saveSurveyAndProceed(dealType, null, dealTypeOther || null)}\n            disabled={isSubmitting}\n            aria-disabled={isSubmitting}\n            className=\"text-xs text-muted-foreground underline-offset-4 hover:underline disabled:opacity-50 disabled:pointer-events-none\"\n          >\n            Skip\n          </button>\n        </motion.div>\n      )}\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "components/welcome/special-upload.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { LinkType } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\nimport { DocumentData, createDocument } from \"@/lib/documents/create-document\";\nimport { putFile } from \"@/lib/files/put-file\";\nimport {\n  convertDataUrlToFile,\n  copyToClipboard,\n  uploadImage,\n} from \"@/lib/utils\";\nimport { getSupportedContentType } from \"@/lib/utils/get-content-type\";\n\nimport DocumentUpload from \"@/components/document-upload\";\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { Button } from \"@/components/ui/button\";\n\nimport Skeleton from \"../Skeleton\";\nimport { DEFAULT_LINK_PROPS, DEFAULT_LINK_TYPE } from \"../links/link-sheet\";\nimport { LinkOptions } from \"../links/link-sheet/link-options\";\n\nexport default function DeckGeneratorUpload() {\n  const router = useRouter();\n  const { groupId } = router.query as {\n    id: string;\n    groupId?: string;\n  };\n  const analytics = useAnalytics();\n  const [uploading, setUploading] = useState<boolean>(false);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [currentFile, setCurrentFile] = useState<File | null>(null);\n  const [currentBlob, setCurrentBlob] = useState<boolean>(false);\n  const [currentLinkId, setCurrentLinkId] = useState<string | null>(null);\n  const [currentDocId, setCurrentDocId] = useState<string | null>(null);\n  const [linkData, setLinkData] = useState<DEFAULT_LINK_TYPE>(\n    DEFAULT_LINK_PROPS(LinkType.DOCUMENT_LINK, groupId),\n  );\n  const teamInfo = useTeam();\n\n  const teamId = teamInfo?.currentTeam?.id as string;\n\n  // When the user enters this page, it will fetch the reportUrl from the cookies with js-cookie, then it will use putFile to upload the file to the server\n\n  useEffect(() => {\n    const reportUrl = Cookies.get(\"savedReport\");\n    if (reportUrl) {\n      const blob = fetch(reportUrl)\n        .then((response) => response.blob())\n        .then((blob) => {\n          const file = new File([blob], \"Pitchdeck.pdf\", {\n            type: \"application/pdf\",\n          });\n          setCurrentFile(file);\n        })\n        .catch((error) => console.error(\"Error fetching report:\", error));\n\n      // Cookies.remove(\"reportUrl\");\n    }\n  }, []);\n\n  const handleBrowserUpload = async (event: any) => {\n    event.preventDefault();\n\n    // Check if the file is chosen\n    if (!currentFile) {\n      toast.error(\"Please select a file to upload.\");\n      return; // prevent form from submitting\n    }\n\n    try {\n      setUploading(true);\n\n      const contentType = currentFile.type;\n      const supportedFileType = getSupportedContentType(contentType);\n\n      if (!supportedFileType) {\n        setUploading(false);\n        toast.error(\n          \"Unsupported file format. Please upload a PDF or Excel file.\",\n        );\n        return;\n      }\n\n      const { type, data, numPages, fileSize } = await putFile({\n        file: currentFile,\n        teamId,\n      });\n\n      setCurrentFile(null);\n      setCurrentBlob(true);\n\n      const documentData: DocumentData = {\n        name: currentFile.name,\n        key: data!,\n        storageType: type!,\n        contentType: contentType,\n        supportedFileType: supportedFileType,\n        fileSize: fileSize,\n      };\n      // create a document in the database\n      const response = await createDocument({\n        documentData,\n        teamId,\n        numPages,\n        createLink: true,\n      });\n\n      if (response) {\n        const document = await response.json();\n        const linkId = document.links[0].id;\n\n        // track the event\n        analytics.capture(\"Document Added\", {\n          documentId: document.id,\n          name: document.name,\n          numPages: document.numPages,\n          path: router.asPath,\n          type: document.type,\n          teamId: teamInfo?.currentTeam?.id,\n        });\n        analytics.capture(\"Link Added\", {\n          linkId: document.links[0].id,\n          documentId: document.id,\n          customDomain: null,\n          teamId: teamInfo?.currentTeam?.id,\n        });\n\n        setTimeout(() => {\n          setCurrentDocId(document.id);\n          setCurrentLinkId(linkId);\n          setUploading(false);\n        }, 2000);\n      }\n    } catch (error) {\n      console.error(\"An error occurred while uploading the file: \", error);\n      setCurrentFile(null);\n      setUploading(false);\n    }\n  };\n\n  const handleSubmit = async (event: any) => {\n    event.preventDefault();\n\n    setIsLoading(true);\n\n    // Upload the image if it's a data URL\n    let blobUrl: string | null =\n      linkData.metaImage && linkData.metaImage.startsWith(\"data:\")\n        ? null\n        : linkData.metaImage;\n    if (linkData.metaImage && linkData.metaImage.startsWith(\"data:\")) {\n      // Convert the data URL to a blob\n      const blob = convertDataUrlToFile({ dataUrl: linkData.metaImage });\n      // Upload the blob to vercel storage\n      blobUrl = await uploadImage(blob);\n      setLinkData({ ...linkData, metaImage: blobUrl });\n    }\n\n    const response = await fetch(`/api/links/${currentLinkId}`, {\n      method: \"PUT\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        ...linkData,\n        metaImage: blobUrl,\n        targetId: currentDocId,\n        linkType: LinkType.DOCUMENT_LINK,\n        teamId: teamId,\n      }),\n    });\n\n    if (!response.ok) {\n      // handle error with toast message\n      const { error } = await response.json();\n      toast.error(error);\n      setIsLoading(false);\n      return;\n    }\n\n    copyToClipboard(\n      `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${currentLinkId}`,\n      \"Link copied to clipboard. Redirecting to document page...\",\n    );\n\n    router.push(`/documents/${currentDocId}`);\n    setIsLoading(false);\n  };\n\n  return (\n    <>\n      {!currentBlob && (\n        <motion.div\n          className=\"z-10 flex flex-col space-y-10 text-center\"\n          variants={{\n            hidden: { opacity: 0, scale: 0.95 },\n            show: {\n              opacity: 1,\n              scale: 1,\n              transition: {\n                staggerChildren: 0.2,\n              },\n            },\n          }}\n          initial=\"hidden\"\n          animate=\"show\"\n          exit=\"hidden\"\n          transition={{ duration: 0.3, type: \"spring\" }}\n        >\n          <motion.div\n            variants={STAGGER_CHILD_VARIANTS}\n            className=\"flex flex-col items-center space-y-10 text-center\"\n          >\n            <h1 className=\"font-display text-3xl font-semibold text-foreground transition-colors sm:text-4xl\">\n              {`Upload your ${router.query.type ? router.query.type : \"document\"}`}\n            </h1>\n          </motion.div>\n          <motion.div variants={STAGGER_CHILD_VARIANTS}>\n            <main className=\"mt-8\">\n              <form\n                encType=\"multipart/form-data\"\n                onSubmit={handleBrowserUpload}\n                className=\"flex flex-col\"\n              >\n                <div className=\"space-y-12\">\n                  <div className=\"pb-6\">\n                    <div className=\"mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6\">\n                      <DocumentUpload\n                        currentFile={currentFile}\n                        setCurrentFile={setCurrentFile}\n                      />\n                    </div>\n                  </div>\n                </div>\n\n                <div className=\"flex justify-center\">\n                  <Button\n                    type=\"submit\"\n                    className=\"w-full\"\n                    loading={uploading}\n                    disabled={!currentFile}\n                  >\n                    {uploading ? \"Uploading...\" : \"Upload Document\"}\n                  </Button>\n                </div>\n              </form>\n\n              <div className=\"text-xs text-muted-foreground\">\n                <span>Use our</span>{\" \"}\n                <Button\n                  variant=\"link\"\n                  className=\"px-0 text-xs font-normal text-muted-foreground underline hover:text-gray-700\"\n                  onClick={async () => {\n                    const response = await fetch(\n                      \"/_example/papermark-example-document.pdf\",\n                    );\n                    const blob = await response.blob();\n                    const file = new File(\n                      [blob],\n                      \"papermark-example-document.pdf\",\n                      {\n                        type: \"application/pdf\",\n                      },\n                    );\n                    setCurrentFile(file);\n                  }}\n                >\n                  sample document\n                </Button>\n              </div>\n            </main>\n          </motion.div>\n        </motion.div>\n      )}\n\n      {currentBlob && (\n        <motion.div\n          className=\"z-10 flex flex-col space-y-10 text-center\"\n          variants={{\n            hidden: { opacity: 0, scale: 0.95 },\n            show: {\n              opacity: 1,\n              scale: 1,\n              transition: {\n                staggerChildren: 0.2,\n              },\n            },\n          }}\n          initial=\"hidden\"\n          animate=\"show\"\n          exit=\"hidden\"\n          transition={{ duration: 0.3, type: \"spring\" }}\n        >\n          <motion.div\n            variants={STAGGER_CHILD_VARIANTS}\n            className=\"flex flex-col items-center space-y-10 text-center\"\n          >\n            <h1 className=\"font-display text-3xl font-semibold text-foreground transition-colors sm:text-4xl\">\n              Share your unique link\n            </h1>\n          </motion.div>\n\n          <motion.div variants={STAGGER_CHILD_VARIANTS}>\n            {!currentLinkId && (\n              <main className=\"min-h-[300px]\">\n                <div className=\"flex flex-col justify-center\">\n                  <div className=\"flex py-8\">\n                    <div className=\"flex w-full focus-within:z-10\">\n                      <Skeleton className=\"h-6 w-full\" />\n                    </div>\n                  </div>\n                </div>\n              </main>\n            )}\n            {currentLinkId && currentDocId && (\n              <main className=\"max-h-[calc(100dvh-10rem)] min-h-[300px] overflow-y-scroll scrollbar-hide\">\n                <div className=\"flex flex-col justify-center\">\n                  <div className=\"relative\">\n                    <div className=\"flex py-8\">\n                      <div className=\"flex w-full max-w-xs focus-within:z-10 sm:max-w-lg\">\n                        <p className=\"block w-full overflow-y-scroll rounded-md border-0 bg-secondary px-4 py-1.5 text-left leading-6 text-secondary-foreground md:min-w-[500px]\">\n                          {`${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${currentLinkId}`}\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                  <div className=\"w-full max-w-xs pb-8 sm:max-w-lg\">\n                    <Accordion type=\"single\" collapsible>\n                      <AccordionItem value=\"item-1\" className=\"border-none\">\n                        <AccordionTrigger className=\"space-x-2 rounded-lg py-0\">\n                          <span className=\"text-sm font-medium leading-6 text-foreground\">\n                            Configure Link Options\n                          </span>\n                        </AccordionTrigger>\n                        <AccordionContent className=\"first:pt-5\">\n                          <LinkOptions\n                            data={linkData}\n                            setData={setLinkData}\n                            linkType={LinkType.DOCUMENT_LINK}\n                          />\n                        </AccordionContent>\n                      </AccordionItem>\n                    </Accordion>\n                  </div>\n                  <div className=\"mb-4 flex items-center justify-center\">\n                    <Button onClick={handleSubmit} loading={isLoading}>\n                      Share Document\n                    </Button>\n                  </div>\n                </div>\n              </main>\n            )}\n          </motion.div>\n        </motion.div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "components/welcome/upload.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { LinkType } from \"@prisma/client\";\n\nimport {\n  DEFAULT_LINK_PROPS,\n  DEFAULT_LINK_TYPE,\n} from \"@/components/links/link-sheet\";\nimport { LinkOptionContainer } from \"@/components/welcome/containers/link-option-container\";\nimport { UploadContainer } from \"@/components/welcome/containers/upload-container\";\n\nexport default function Upload() {\n  const router = useRouter();\n  const { groupId } = router.query as {\n    id: string;\n    groupId?: string;\n  };\n  const [currentFile, setCurrentFile] = useState<File | null>(null);\n  const [currentBlob, setCurrentBlob] = useState<boolean>(false);\n  const [currentLinkId, setCurrentLinkId] = useState<string | null>(null);\n  const [currentDocId, setCurrentDocId] = useState<string | null>(null);\n  const [linkData, setLinkData] = useState<DEFAULT_LINK_TYPE>(\n    DEFAULT_LINK_PROPS(LinkType.DOCUMENT_LINK, groupId),\n  );\n\n  if (!currentBlob) {\n    return (\n      <UploadContainer\n        currentFile={currentFile}\n        setCurrentFile={setCurrentFile}\n        setCurrentBlob={setCurrentBlob}\n        setCurrentLinkId={setCurrentLinkId}\n        setCurrentDocId={setCurrentDocId}\n      />\n    );\n  }\n\n  if (currentBlob) {\n    return (\n      <LinkOptionContainer\n        currentLinkId={currentLinkId}\n        currentDocId={currentDocId}\n        linkData={linkData}\n        setLinkData={setLinkData}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "components/yearly-recap/globe.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\ninterface GlobePoint {\n  x: number;\n  y: number;\n  z: number;\n  color: string;\n}\n\nexport function Globe() {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const pointsRef = useRef<GlobePoint[]>([]);\n  const rotationRef = useRef({ x: 0, y: 0 });\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    // Set canvas size\n    const width = canvas.clientWidth;\n    const height = canvas.clientHeight;\n    canvas.width = width;\n    canvas.height = height;\n\n    // Create globe points - land and ocean colors\n    const generateGlobePoints = () => {\n      const points: GlobePoint[] = [];\n      const radius = 100;\n      const density = 2500; // number of points\n\n      // Land colors (greens, browns)\n      const landColors = [\n        \"#2d5016\", // dark green\n        \"#3d7020\", // forest green\n        \"#5a8c2d\", // medium green\n        \"#8b7355\", // brown\n        \"#a69968\", // tan\n        \"#6b8e23\", // olive\n      ];\n\n      // Ocean colors (blues)\n      const oceanColors = [\n        \"#1e3a8a\", // dark blue\n        \"#2563eb\", // medium blue\n        \"#0369a1\", // cyan-blue\n        \"#3b82f6\", // sky blue\n      ];\n\n      for (let i = 0; i < density; i++) {\n        // Random spherical coordinates\n        const theta = Math.random() * Math.PI * 2;\n        const phi = Math.acos(Math.random() * 2 - 1);\n\n        const x = radius * Math.sin(phi) * Math.cos(theta);\n        const y = radius * Math.sin(phi) * Math.sin(theta);\n        const z = radius * Math.cos(phi);\n\n        // Determine if this point is \"land\" based on noise-like pattern\n        const landChance = Math.sin(theta * 3) * Math.sin(phi * 2) * 0.5 + 0.5;\n        const isLand = landChance > 0.45;\n\n        const colorArray = isLand ? landColors : oceanColors;\n        const color = colorArray[Math.floor(Math.random() * colorArray.length)];\n\n        points.push({ x, y, z, color });\n      }\n\n      return points;\n    };\n\n    pointsRef.current = generateGlobePoints();\n\n    let animationId: number;\n\n    const animate = () => {\n      // Clear canvas with transparent background\n      ctx.clearRect(0, 0, width, height);\n\n      // Rotation - only around Y axis for smooth round rotation\n      rotationRef.current.y += 0.003;\n\n      const cosY = Math.cos(rotationRef.current.y);\n      const sinY = Math.sin(rotationRef.current.y);\n      // Fixed tilt for a nice viewing angle\n      const tiltAngle = 0.3;\n      const cosX = Math.cos(tiltAngle);\n      const sinX = Math.sin(tiltAngle);\n\n      // Project and sort points\n      const projectedPoints = pointsRef.current\n        .map((point) => {\n          // Rotation around X axis\n          const y = point.y * cosX - point.z * sinX;\n          let z = point.y * sinX + point.z * cosX;\n\n          // Rotation around Y axis\n          const x = point.x * cosY + z * sinY;\n          z = -point.x * sinY + z * cosY;\n\n          // Perspective projection\n          const scale = 300 / (z + 150);\n          const x2d = x * scale + width / 2;\n          const y2d = y * scale + height / 2;\n\n          return { x: x2d, y: y2d, z, color: point.color };\n        })\n        .sort((a, b) => a.z - b.z); // Sort by depth\n\n      // Draw points\n      projectedPoints.forEach((point) => {\n        const depth = (point.z + 150) / 300;\n        const size = Math.max(0.5, depth * 1.5);\n\n        ctx.fillStyle = point.color;\n        ctx.globalAlpha = Math.max(0.3, depth);\n        ctx.beginPath();\n        ctx.arc(point.x, point.y, size, 0, Math.PI * 2);\n        ctx.fill();\n      });\n\n      ctx.globalAlpha = 1;\n      animationId = requestAnimationFrame(animate);\n    };\n\n    animate();\n\n    return () => {\n      cancelAnimationFrame(animationId);\n    };\n  }, []);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className=\"w-full h-full\"\n      style={{ background: \"transparent\" }}\n    />\n  );\n}\n\n"
  },
  {
    "path": "components/yearly-recap/index.ts",
    "content": "export { YearlyRecapBanner } from \"./yearly-recap-banner\";\nexport { YearlyRecapModal } from \"./yearly-recap-modal\";\n\n"
  },
  {
    "path": "components/yearly-recap/yearly-recap-banner.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ArrowRight, Sparkles } from \"lucide-react\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { YearlyRecapModal } from \"./yearly-recap-modal\";\n\n// Decorative SVG Components\nconst DocumentStackSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 100 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <rect\n      x=\"10\"\n      y=\"10\"\n      width=\"60\"\n      height=\"80\"\n      rx=\"2\"\n      fill=\"#FA7A02\"\n      opacity=\"0.4\"\n    />\n    <rect\n      x=\"15\"\n      y=\"20\"\n      width=\"60\"\n      height=\"80\"\n      rx=\"2\"\n      fill=\"#FA7A02\"\n      opacity=\"0.5\"\n    />\n    <rect\n      x=\"20\"\n      y=\"30\"\n      width=\"60\"\n      height=\"80\"\n      rx=\"2\"\n      fill=\"#FA7A02\"\n      opacity=\"0.6\"\n    />\n  </svg>\n);\n\nconst PeachPaperSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 60 60\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path\n      d=\"M10 15 Q15 10, 20 15 L45 40 Q50 45, 45 50 L20 50 Q15 50, 10 45 Z\"\n      fill=\"#FFD4B3\"\n      opacity=\"0.6\"\n    />\n    <path\n      d=\"M10 15 Q12 12, 15 15 L40 40 Q42 42, 40 45 L15 45 Q12 45, 10 42 Z\"\n      fill=\"#FFD4B3\"\n      opacity=\"0.4\"\n    />\n  </svg>\n);\n\nconst ScatterDotsSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 100 100\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <circle cx=\"25\" cy=\"30\" r=\"2\" fill=\"#D3D3D3\" opacity=\"0.5\" />\n    <circle cx=\"45\" cy=\"25\" r=\"1.5\" fill=\"#D3D3D3\" opacity=\"0.5\" />\n    <circle cx=\"65\" cy=\"35\" r=\"2\" fill=\"#D3D3D3\" opacity=\"0.5\" />\n    <circle cx=\"30\" cy=\"55\" r=\"1.5\" fill=\"#D3D3D3\" opacity=\"0.5\" />\n    <circle cx=\"55\" cy=\"60\" r=\"2\" fill=\"#D3D3D3\" opacity=\"0.5\" />\n    <circle cx=\"70\" cy=\"70\" r=\"1.5\" fill=\"#D3D3D3\" opacity=\"0.5\" />\n  </svg>\n);\n\nexport function YearlyRecapBanner() {\n  const teamInfo = useTeam();\n  const router = useRouter();\n  const analytics = useAnalytics();\n  const currentYear = new Date().getFullYear();\n  const [showModal, setShowModal] = useState(false);\n\n  // Check if URL has openRecap parameter - open modal regardless of stats\n  useEffect(() => {\n    if (\n      router.isReady &&\n      router.query.openRecap === \"true\" &&\n      teamInfo?.currentTeam?.id\n    ) {\n      setShowModal(true);\n      // Remove the query parameter\n      const { openRecap, ...rest } = router.query;\n      router.replace({ pathname: router.pathname, query: rest }, undefined, {\n        shallow: true,\n      });\n    }\n  }, [router.isReady, router.query.openRecap, teamInfo?.currentTeam?.id]);\n\n  const handleOpenModal = () => {\n    analytics.capture(\"YIR: Banner Opened\", {\n      teamId: teamInfo?.currentTeam?.id,\n      source: \"banner\",\n    });\n    setShowModal(true);\n  };\n\n  // Don't render anything if no team\n  if (!teamInfo?.currentTeam?.id) {\n    return null;\n  }\n\n  return (\n    <>\n      {/* Modal - always available */}\n      <YearlyRecapModal\n        isOpen={showModal}\n        onClose={() => setShowModal(false)}\n        teamId={teamInfo.currentTeam.id}\n      />\n\n      {/* Banner - always shown */}\n      <div className=\"mx-2 my-2 mb-2\">\n        <div\n          className=\"relative overflow-hidden rounded-xl p-4 sm:p-6\"\n          style={{\n            background:\n              \"linear-gradient(to right, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 25%, #EEEFEB 50%, rgba(16, 185, 129, 0.1) 75%, rgba(16, 185, 129, 0.2) 100%)\",\n          }}\n        >\n          {/* Decorative elements */}\n          <DocumentStackSVG className=\"absolute -right-2 -top-2 h-28 w-24\" />\n          {/* <PeachPaperSVG className=\"absolute bottom-2 left-2 w-16 h-16\" /> */}\n          <ScatterDotsSVG className=\"absolute left-1/4 top-2 h-20 w-20\" />\n\n          <div className=\"relative z-10 flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between\">\n            <div className=\"flex items-center gap-3 sm:gap-4\">\n              <div className=\"relative h-10 w-10 shrink-0 sm:h-12 sm:w-12\">\n                {/* <PeachPaperSVG className=\"absolute inset-0 w-full h-full opacity-60\" /> */}\n                <div className=\"absolute inset-0 flex h-10 w-10 items-center justify-center rounded-full bg-orange-500/20 sm:h-12 sm:w-12\">\n                  <Sparkles className=\"h-5 w-5 text-orange-500 sm:h-6 sm:w-6\" />\n                </div>\n              </div>\n              <div>\n                <h3 className=\"text-lg font-semibold text-gray-900 sm:text-xl\">\n                  Papermark Wrapped {currentYear}\n                </h3>\n                <p className=\"text-xs text-gray-600 sm:text-sm\">\n                  Your year in document sharing\n                </p>\n              </div>\n            </div>\n            <Button\n              onClick={handleOpenModal}\n              className=\"w-full shrink-0 gap-2 bg-gray-900 text-white hover:bg-black sm:w-auto\"\n            >\n              See your Wrapped\n              <ArrowRight className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "components/yearly-recap/yearly-recap-modal.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport {\n  ArrowRight,\n  Calendar,\n  ChevronLeft,\n  ChevronRight,\n  Circle,\n  Clock,\n  Database,\n  Download,\n  Eye,\n  FileText,\n  Files,\n  Folder,\n  Globe2,\n  Grid3x3,\n  MapPin,\n  Paperclip,\n  Share2,\n  StickyNote,\n  Users,\n  X,\n} from \"lucide-react\";\nimport { useHotkeys } from \"react-hotkeys-hook\";\nimport { toast } from \"sonner\";\nimport useSWRImmutable from \"swr/immutable\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { cn, fetcher } from \"@/lib/utils\";\n\nimport LinkedInIcon from \"@/components/shared/icons/linkedin\";\nimport { Button } from \"@/components/ui/button\";\nimport { Dialog, DialogContent } from \"@/components/ui/dialog\";\n\ninterface YearlyRecapStats {\n  year: number;\n  totalViews: number;\n  totalDocuments: number;\n  totalLinks: number;\n  totalDatarooms: number;\n  mostViewedDocument: {\n    documentId: string;\n    documentName: string;\n    viewCount: number;\n  } | null;\n  mostActiveMonth: {\n    month: string;\n    viewCount: number;\n  } | null;\n  mostActiveViewer: {\n    email: string;\n    name: string | null;\n    viewCount: number;\n  } | null;\n  totalDuration: number;\n  uniqueCountries: string[];\n  distanceTraveled: number;\n}\n\ninterface YearlyRecapModalProps {\n  isOpen: boolean;\n  onClose: () => void;\n  teamId: string;\n}\n\n// Decorative SVG Components\nconst DocumentStackSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 100 120\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <rect\n      x=\"10\"\n      y=\"10\"\n      width=\"60\"\n      height=\"80\"\n      rx=\"2\"\n      fill=\"#F5F5F5\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n    />\n    <rect\n      x=\"15\"\n      y=\"20\"\n      width=\"60\"\n      height=\"80\"\n      rx=\"2\"\n      fill=\"#F5F5F5\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n    />\n    <rect\n      x=\"20\"\n      y=\"30\"\n      width=\"60\"\n      height=\"80\"\n      rx=\"2\"\n      fill=\"#F5F5F5\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n    />\n  </svg>\n);\n\nconst FolderTabSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 100 80\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path\n      d=\"M10 20 L10 70 L70 70 L70 30 L45 30 L35 20 Z\"\n      fill=\"#FFB84D\"\n      opacity=\"0.4\"\n      stroke=\"#FFB84D\"\n      strokeWidth=\"1.5\"\n    />\n    <path d=\"M10 20 L35 20 L45 30 L10 30 Z\" fill=\"#FFB84D\" opacity=\"0.6\" />\n  </svg>\n);\n\nconst StickyNoteSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 80 80\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <rect\n      x=\"10\"\n      y=\"10\"\n      width=\"60\"\n      height=\"60\"\n      rx=\"2\"\n      fill=\"#90EE90\"\n      opacity=\"0.4\"\n      stroke=\"#90EE90\"\n      strokeWidth=\"1.5\"\n    />\n    <path d=\"M10 10 L70 10 L60 20 L10 20 Z\" fill=\"#90EE90\" opacity=\"0.6\" />\n  </svg>\n);\n\nconst ScatterDotsSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 100 100\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <circle cx=\"25\" cy=\"30\" r=\"2\" fill=\"#000000\" opacity=\"0.5\" />\n    <circle cx=\"45\" cy=\"25\" r=\"1.5\" fill=\"#000000\" opacity=\"0.5\" />\n    <circle cx=\"65\" cy=\"35\" r=\"2\" fill=\"#000000\" opacity=\"0.5\" />\n    <circle cx=\"30\" cy=\"55\" r=\"1.5\" fill=\"#000000\" opacity=\"0.5\" />\n    <circle cx=\"55\" cy=\"60\" r=\"2\" fill=\"#000000\" opacity=\"0.5\" />\n    <circle cx=\"70\" cy=\"70\" r=\"1.5\" fill=\"#000000\" opacity=\"0.5\" />\n  </svg>\n);\n\nconst CurvedLineSVG = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 200 50\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path\n      d=\"M0 25 Q50 10, 100 25 T200 25\"\n      stroke=\"#D3D3D3\"\n      strokeWidth=\"2\"\n      fill=\"none\"\n      opacity=\"0.5\"\n    />\n  </svg>\n);\n\nconst RECAP_GRADIENT = \"\";\n\nconst slides = [\n  { id: \"intro\", gradient: RECAP_GRADIENT },\n  { id: \"globalFootprint\", gradient: RECAP_GRADIENT },\n  { id: \"minutes\", gradient: RECAP_GRADIENT },\n  { id: \"viewsStats\", gradient: RECAP_GRADIENT },\n  { id: \"mostActive\", gradient: RECAP_GRADIENT },\n  { id: \"summary\", gradient: RECAP_GRADIENT },\n  { id: \"shareOffer\", gradient: RECAP_GRADIENT },\n];\n\nexport function YearlyRecapModal({\n  isOpen,\n  onClose,\n  teamId,\n}: YearlyRecapModalProps) {\n  const [currentSlide, setCurrentSlide] = useState(0);\n  const [showShareView, setShowShareView] = useState(false);\n  const [isCapturing, setIsCapturing] = useState(false);\n  const shareCardRef = useRef<HTMLDivElement>(null);\n  const analytics = useAnalytics();\n\n  const { data: stats, isLoading } = useSWRImmutable<YearlyRecapStats>(\n    teamId && isOpen ? `/api/teams/${teamId}/yearly-recap` : null,\n    fetcher,\n  );\n\n  const handleClose = () => {\n    setCurrentSlide(0);\n    setShowShareView(false);\n    onClose();\n  };\n\n  // Keyboard shortcuts\n  useHotkeys(\n    \"right\",\n    () => {\n      if (!showShareView && currentSlide < slides.length - 1) {\n        setCurrentSlide((prev) => prev + 1);\n      }\n    },\n    { enabled: isOpen },\n    [showShareView, currentSlide],\n  );\n\n  useHotkeys(\n    \"left\",\n    () => {\n      if (!showShareView && currentSlide > 0) {\n        setCurrentSlide((prev) => prev - 1);\n      }\n    },\n    { enabled: isOpen },\n    [showShareView, currentSlide],\n  );\n\n  useHotkeys(\n    \"enter, space\",\n    (e) => {\n      if (!showShareView && currentSlide < slides.length - 1) {\n        e.preventDefault();\n        setCurrentSlide((prev) => prev + 1);\n      }\n    },\n    { enabled: isOpen },\n    [showShareView, currentSlide],\n  );\n\n  useHotkeys(\n    \"escape\",\n    () => {\n      if (showShareView) {\n        setShowShareView(false);\n      } else {\n        handleClose();\n      }\n    },\n    { enabled: isOpen },\n    [showShareView],\n  );\n\n  const nextSlide = () => {\n    if (currentSlide < slides.length - 1) {\n      setCurrentSlide(currentSlide + 1);\n    }\n  };\n\n  const prevSlide = () => {\n    if (currentSlide > 0) {\n      setCurrentSlide(currentSlide - 1);\n    }\n  };\n\n  const handleShare = () => {\n    analytics.capture(\"YIR: Share Clicked\", { teamId });\n    setShowShareView(true);\n  };\n\n  const captureImage = useCallback(async (): Promise<Blob | null> => {\n    if (!shareCardRef.current) return null;\n\n    try {\n      const html2canvas = (await import(\"html2canvas\")).default;\n      const canvas = await html2canvas(shareCardRef.current, {\n        scale: 2,\n        backgroundColor: null,\n        useCORS: true,\n      });\n\n      return new Promise((resolve) => {\n        canvas.toBlob((blob) => {\n          resolve(blob);\n        }, \"image/png\");\n      });\n    } catch (error) {\n      console.error(\"Error capturing image:\", error);\n      toast.error(\"Failed to capture image\");\n      return null;\n    }\n  }, []);\n\n  const handleDownload = async () => {\n    analytics.capture(\"YIR: Share Platform Clicked\", {\n      teamId,\n      platform: \"download\",\n    });\n    setIsCapturing(true);\n    try {\n      const blob = await captureImage();\n      if (blob) {\n        const url = URL.createObjectURL(blob);\n        const a = document.createElement(\"a\");\n        a.href = url;\n        a.download = `papermark-wrapped-${stats?.year || 2025}.png`;\n        document.body.appendChild(a);\n        a.click();\n        document.body.removeChild(a);\n        URL.revokeObjectURL(url);\n        toast.success(\"Image downloaded!\");\n      }\n    } catch (error) {\n      toast.error(\"Failed to download image\");\n    } finally {\n      setIsCapturing(false);\n    }\n  };\n\n  const getShareText = () => {\n    const totalMinutes = Math.floor((stats?.totalDuration || 0) / 60_000); // from milliseconds to minutes\n    const countriesCount = stats?.uniqueCountries?.length || 0;\n    const distanceTraveled = stats?.distanceTraveled || 0;\n\n    return `· ${totalMinutes.toLocaleString()} min my docs were viewed\n· ${distanceTraveled.toLocaleString()} km travelled my documents\n· ${stats?.totalDocuments} documents\n· ${stats?.totalViews?.toLocaleString()} views\n· ${countriesCount} countries\n\nMy Papermark Wrapped ${stats?.year}!\n\n#PapermarkWrapped https://www.papermark.com/`;\n  };\n\n  const handleShareLinkedIn = async () => {\n    analytics.capture(\"YIR: Share Platform Clicked\", {\n      teamId,\n      platform: \"linkedin\",\n    });\n    const text = getShareText();\n    window.open(\n      `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(\"https://www.papermark.com/\")}&summary=${encodeURIComponent(text)}`,\n      \"_blank\",\n    );\n  };\n\n  const handleShareTwitter = async () => {\n    analytics.capture(\"YIR: Share Platform Clicked\", {\n      teamId,\n      platform: \"twitter\",\n    });\n    const text = getShareText();\n    window.open(\n      `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`,\n      \"_blank\",\n    );\n  };\n\n  if (isLoading || !stats) {\n    return (\n      <Dialog open={isOpen} onOpenChange={handleClose}>\n        <DialogContent\n          className=\"max-w-4xl overflow-hidden border-0 p-0\"\n          style={{\n            background:\n              \"linear-gradient(to right, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 25%, #EEEFEB 50%, rgba(16, 185, 129, 0.1) 75%, rgba(16, 185, 129, 0.2) 100%)\",\n          }}\n        >\n          <div className=\"flex items-center justify-center p-8\">\n            <div className=\"text-balance text-center\">\n              Loading your recap...\n            </div>\n          </div>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n\n  // Share View\n  if (showShareView) {\n    return (\n      <Dialog open={isOpen} onOpenChange={handleClose}>\n        <DialogContent className=\"w-full max-w-[700px] overflow-hidden border-0 bg-white p-0 [&>button]:hidden\">\n          {/* Close button */}\n          <button\n            onClick={() => setShowShareView(false)}\n            className=\"absolute right-4 top-4 z-50 text-foreground/60 transition-colors hover:text-foreground\"\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n\n          {/* Shareable Card */}\n          <div className=\"p-4 sm:p-6\">\n            {(() => {\n              const totalMinutes = Math.floor(stats.totalDuration / 60_000); // from milliseconds to minutes\n              const distanceTraveled = stats.distanceTraveled || 0;\n\n              return (\n                <div\n                  ref={shareCardRef}\n                  className=\"mx-auto w-full max-w-[500px] overflow-hidden rounded-2xl bg-gray-100 p-4 sm:p-8\"\n                >\n                  {/* Stats Grid */}\n                  <div className=\"mb-4 rounded-xl bg-white p-4 shadow-sm sm:mb-6 sm:p-6\">\n                    <div className=\"grid grid-cols-2 gap-3 sm:gap-4\">\n                      <div className=\"text-center\">\n                        <div className=\"mb-1 text-2xl font-bold text-black sm:text-4xl\">\n                          {totalMinutes.toLocaleString()}\n                        </div>\n                        <p className=\"text-[10px] text-gray-600 sm:text-xs\">\n                          minutes viewed\n                        </p>\n                      </div>\n                      <div className=\"text-center\">\n                        <div className=\"mb-1 text-2xl font-bold text-black sm:text-4xl\">\n                          {distanceTraveled.toLocaleString()}\n                        </div>\n                        <p className=\"text-[10px] text-gray-600 sm:text-xs\">\n                          km travelled\n                        </p>\n                      </div>\n                      <div className=\"text-center\">\n                        <div className=\"mb-1 text-2xl font-bold text-black sm:text-4xl\">\n                          {stats.totalDocuments}\n                        </div>\n                        <p className=\"text-[10px] text-gray-600 sm:text-xs\">\n                          documents\n                        </p>\n                      </div>\n                      <div className=\"text-center\">\n                        <div className=\"mb-1 text-2xl font-bold text-black sm:text-4xl\">\n                          {stats.totalViews.toLocaleString()}\n                        </div>\n                        <p className=\"text-[10px] text-gray-600 sm:text-xs\">\n                          views\n                        </p>\n                      </div>\n                    </div>\n                    {/* <div className=\"text-center mt-4 pt-4 border-t border-gray-200\">\n                      <div className=\"text-4xl font-bold text-orange-500 mb-1\">{countriesCount}</div>\n                      <p className=\"text-xs text-gray-600\">countries reached</p>\n                    </div> */}\n                  </div>\n\n                  {/* Branding */}\n                  <div className=\"mt-4 text-center sm:mt-6\">\n                    <div className=\"inline-flex items-center gap-2\">\n                      <span className=\"text-base font-bold text-gray-900 sm:text-lg\">\n                        Papermark\n                      </span>\n                      <span className=\"text-base font-black text-gray-900 sm:text-lg\">\n                        WRAPPED\n                      </span>\n                    </div>\n                    <p className=\"mt-1 text-xs text-gray-500\">papermark.com</p>\n                  </div>\n                </div>\n              );\n            })()}\n          </div>\n\n          {/* $50 grant text */}\n          <p className=\"mb-4 px-4 text-center text-xs text-muted-foreground sm:px-6 sm:text-sm\">\n            Share your stats and receive{\" \"}\n            <span className=\"font-semibold text-orange-600\">$50</span> in\n            credits on your papermark account, please send confirmation to{\" \"}\n            <span className=\"font-medium\">support@papermark.com</span> and\n            include screenshot or link to your post.\n          </p>\n\n          {/* Share buttons */}\n          <div className=\"flex flex-col-reverse items-center gap-3 px-4 pb-4 sm:flex-row sm:justify-between sm:px-6 sm:pb-6\">\n            <div className=\"flex items-center justify-center gap-2\">\n              <Button\n                onClick={handleShareLinkedIn}\n                variant=\"secondary\"\n                size=\"icon\"\n                className=\"rounded-full\"\n              >\n                <LinkedInIcon className=\"h-4 w-4\" color={false} />\n              </Button>\n              <Button\n                onClick={handleShareTwitter}\n                variant=\"secondary\"\n                size=\"icon\"\n                className=\"rounded-full\"\n              >\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  viewBox=\"0 0 1200 1227\"\n                  className=\"h-4 w-4\"\n                  fill=\"currentColor\"\n                >\n                  <path d=\"M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z\" />\n                </svg>\n              </Button>\n              <Button\n                onClick={handleDownload}\n                disabled={isCapturing}\n                variant=\"secondary\"\n                size=\"icon\"\n                className=\"rounded-full\"\n              >\n                <Download className=\"h-4 w-4\" />\n              </Button>\n            </div>\n\n            <Button\n              onClick={() => setShowShareView(false)}\n              className=\"w-full gap-2 rounded-full bg-gradient-to-r from-orange-500 to-orange-500 text-white hover:from-orange-600 hover:to-orange-600 sm:w-auto\"\n            >\n              Back to Wrapped\n              <ArrowRight className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </DialogContent>\n      </Dialog>\n    );\n  }\n\n  return (\n    <Dialog open={isOpen} onOpenChange={handleClose}>\n      <DialogContent className=\"w-full max-w-[1100px] overflow-hidden border-0 p-0 sm:min-w-[700px] [&>button]:hidden\">\n        <div\n          className=\"relative min-h-[500px] overflow-hidden rounded-3xl shadow-2xl sm:min-h-[700px]\"\n          style={{\n            background:\n              \"linear-gradient(to right, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.1) 25%, #EEEFEB 50%, rgba(16, 185, 129, 0.1) 75%, rgba(16, 185, 129, 0.2) 100%)\",\n          }}\n        >\n          {/* Decorative background elements - same as banner */}\n          {/* <DocumentStackSVG className=\"absolute top-16 -right-2 w-24 h-28 text-foreground opacity-50\" /> */}\n          {/* <ScatterDotsSVG className=\"absolute top-1/4 left-1/4 w-20 h-20\" /> */}\n          {/* <Paperclip className=\"absolute bottom-16 left-8 w-16 h-16 text-foreground/30\" strokeWidth=\"1\" /> */}\n\n          {/* Progress bar */}\n          <div className=\"relative z-10 flex gap-1.5 p-4 sm:gap-2 sm:p-8\">\n            {slides.map((_, index) => (\n              <div\n                key={index}\n                className={cn(\n                  \"h-1.5 flex-1 rounded-full transition-all duration-300\",\n                  index <= currentSlide\n                    ? \"bg-foreground/40\"\n                    : \"bg-foreground/10\",\n                )}\n              />\n            ))}\n          </div>\n\n          {/* Close button */}\n          <button\n            onClick={handleClose}\n            className=\"absolute right-4 top-4 z-50 text-foreground/60 transition-colors hover:text-foreground sm:right-8 sm:top-8\"\n          >\n            <X className=\"h-5 w-5 sm:h-6 sm:w-6\" />\n          </button>\n\n          {/* Slide content */}\n          <div className=\"relative z-10 px-4 pb-20 pt-2 sm:px-16 sm:pb-16 sm:pt-4\">\n            {currentSlide === 0 && (\n              <IntroSlide stats={stats} onNext={nextSlide} />\n            )}\n            {currentSlide === 1 && <GlobalFootprintSlide stats={stats} />}\n            {currentSlide === 2 && <MinutesSlide stats={stats} />}\n            {currentSlide === 3 && <ViewsStatsSlide stats={stats} />}\n            {currentSlide === 4 && <MostActiveSlide stats={stats} />}\n            {currentSlide === 5 && <SummarySlide stats={stats} />}\n            {currentSlide === 6 && <ShareOfferSlide stats={stats} />}\n          </div>\n\n          {/* Navigation - hidden on first slide */}\n          {currentSlide > 0 && (\n            <div className=\"absolute bottom-4 left-0 right-0 z-10 flex items-center justify-center gap-2 sm:bottom-8 sm:gap-3\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                onClick={prevSlide}\n                disabled={currentSlide === 0}\n                className=\"h-10 w-10 rounded-full bg-foreground/10 hover:bg-foreground/20 disabled:opacity-30\"\n              >\n                <ChevronLeft className=\"h-5 w-5\" />\n              </Button>\n              <Button\n                onClick={handleShare}\n                className=\"gap-2 rounded-full bg-foreground px-6 text-gray-100 hover:bg-foreground/90\"\n              >\n                <Share2 className=\"h-4 w-4\" />\n                Share\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                onClick={nextSlide}\n                disabled={currentSlide === slides.length - 1}\n                className=\"h-10 w-10 rounded-full bg-foreground/10 hover:bg-foreground/20 disabled:opacity-30\"\n              >\n                <ChevronRight className=\"h-5 w-5\" />\n              </Button>\n            </div>\n          )}\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction IntroSlide({\n  stats,\n  onNext,\n}: {\n  stats: YearlyRecapStats;\n  onNext: () => void;\n}) {\n  return (\n    <div className=\"relative flex min-h-[350px] flex-col items-center justify-center text-center sm:min-h-[520px]\">\n      <h1 className=\"relative z-10 mb-4 text-balance text-3xl font-semibold text-foreground sm:text-6xl\">\n        Your {stats.year} with{\" \"}\n        <span className=\"text-orange-500\">Papermark</span>\n      </h1>\n      <p className=\"relative z-10 mb-10 max-w-xl px-2 text-xs text-gray-500 sm:mb-14 sm:px-0 sm:text-sm\">\n        This review is personalised to your platform usage and contains your\n        stats.\n      </p>\n\n      {/* Let's go button - only appears from bottom */}\n      <Button\n        onClick={onNext}\n        size=\"lg\"\n        className=\"relative z-10 h-auto rounded-full bg-black px-8 py-2.5 text-base text-white shadow-xl duration-700 animate-in slide-in-from-bottom-12 hover:from-orange-600 hover:to-orange-600 sm:px-10 sm:py-3 sm:text-lg\"\n      >\n        Let&apos;s go\n        <ArrowRight className=\"ml-2 h-4 w-4 sm:h-5 sm:w-5\" />\n      </Button>\n    </div>\n  );\n}\n\nfunction GlobalFootprintSlide({ stats }: { stats: YearlyRecapStats }) {\n  const countriesCount = stats.uniqueCountries.length;\n  const distanceTraveled = stats.distanceTraveled;\n\n  return (\n    <div className=\"flex min-h-[350px] flex-col items-center justify-center text-center sm:min-h-[520px]\">\n      {/* Title section - appears from top */}\n      <div className=\"duration-700 animate-in slide-in-from-top-8\">\n        {/* Big number */}\n        <h2 className=\"mb-2 text-balance text-5xl font-bold text-foreground sm:mb-4 sm:text-9xl\">\n          {distanceTraveled.toLocaleString()}\n          <span className=\"text-lg font-normal text-foreground sm:text-2xl\">\n            km\n          </span>\n        </h2>\n\n        <p className=\"mb-6 text-balance text-lg font-normal text-foreground sm:mb-8 sm:text-2xl\">\n          your documents travelled this year\n        </p>\n      </div>\n\n      {/* Countries box - appears from bottom */}\n      <div className=\"duration-700 animate-in slide-in-from-bottom-8\">\n        <div className=\"w-40 rounded-xl bg-card p-5 shadow-lg sm:w-56 sm:rounded-2xl sm:p-8\">\n          <MapPin className=\"mx-auto mb-2 h-6 w-6 text-orange-600 sm:mb-4 sm:h-8 sm:w-8\" />\n          <div className=\"text-3xl font-bold text-foreground sm:text-5xl\">\n            {countriesCount}\n          </div>\n          <p className=\"mt-1 text-balance text-xs text-muted-foreground sm:mt-2 sm:text-sm\">\n            Countries\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction MinutesSlide({ stats }: { stats: YearlyRecapStats }) {\n  const totalMinutes = Math.floor(stats.totalDuration / 60_000); // from milliseconds to minutes\n\n  return (\n    <div className=\"flex min-h-[350px] flex-col items-center justify-center text-center sm:min-h-[520px]\">\n      {/* Header from top */}\n      <div className=\"duration-700 animate-in slide-in-from-top-8\">\n        <p className=\"mb-2 text-sm text-muted-foreground sm:text-lg\">\n          Total time on your documents\n        </p>\n        <h2 className=\"mb-2 text-5xl font-bold text-orange-500 sm:text-8xl\">\n          {totalMinutes.toLocaleString()}\n        </h2>\n        <p className=\"text-lg font-medium text-foreground sm:text-2xl\">\n          minutes\n        </p>\n      </div>\n\n      {/* Document card from left */}\n      {stats.mostViewedDocument && (\n        <div className=\"mt-6 w-full max-w-xl rounded-xl border border-foreground/5 bg-white/80 p-4 shadow-sm backdrop-blur duration-700 animate-in slide-in-from-left-8 sm:mt-8 sm:p-5\">\n          <div className=\"flex items-center gap-3 sm:gap-4\">\n            <div className=\"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-orange-500/10 sm:h-12 sm:w-12\">\n              <FileText className=\"h-5 w-5 text-orange-500 sm:h-6 sm:w-6\" />\n            </div>\n            <div className=\"min-w-0 text-left\">\n              <span className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n                Most Viewed\n              </span>\n              <h3 className=\"mt-1 truncate text-sm font-semibold text-foreground sm:text-base\">\n                {stats.mostViewedDocument.documentName}\n              </h3>\n              <p className=\"mt-1 text-xs text-muted-foreground sm:text-sm\">\n                <span className=\"font-semibold text-orange-500\">\n                  {stats.mostViewedDocument.viewCount.toLocaleString()}\n                </span>{\" \"}\n                views\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction ViewsStatsSlide({ stats }: { stats: YearlyRecapStats }) {\n  return (\n    <div className=\"flex min-h-[350px] flex-col items-center justify-center sm:min-h-[520px]\">\n      {/* Header from left */}\n      <h2 className=\"mb-6 text-balance text-center text-xl font-bold text-foreground duration-700 animate-in slide-in-from-left-8 sm:mb-8 sm:text-3xl\">\n        Your {stats.year} activity on Papermark\n      </h2>\n\n      {/* Cards from right */}\n      <div className=\"grid w-full max-w-5xl grid-cols-3 gap-2 duration-700 animate-in slide-in-from-right-8 sm:gap-5\">\n        <div className=\"rounded-xl bg-card p-4 text-center shadow-lg sm:rounded-2xl sm:p-10\">\n          <Eye className=\"mx-auto mb-2 h-5 w-5 text-orange-500 sm:mb-4 sm:h-6 sm:w-6\" />\n          <span className=\"block text-2xl font-bold text-foreground sm:text-5xl\">\n            {stats.totalViews.toLocaleString()}\n          </span>\n          <p className=\"mt-1 text-balance text-xs font-medium text-muted-foreground sm:mt-3 sm:text-sm\">\n            Views\n          </p>\n        </div>\n        <div className=\"rounded-xl bg-card p-4 text-center shadow-lg sm:rounded-2xl sm:p-10\">\n          <FileText className=\"mx-auto mb-2 h-5 w-5 text-orange-500 sm:mb-4 sm:h-6 sm:w-6\" />\n          <span className=\"block text-2xl font-bold text-foreground sm:text-5xl\">\n            {stats.totalDocuments.toLocaleString()}\n          </span>\n          <p className=\"mt-1 text-balance text-xs font-medium text-muted-foreground sm:mt-3 sm:text-sm\">\n            Documents\n          </p>\n        </div>\n        <div className=\"rounded-xl bg-card p-4 text-center shadow-lg sm:rounded-2xl sm:p-10\">\n          <Database className=\"mx-auto mb-2 h-5 w-5 text-orange-600 sm:mb-4 sm:h-6 sm:w-6\" />\n          <span className=\"block text-2xl font-bold text-foreground sm:text-5xl\">\n            {stats.totalDatarooms.toLocaleString()}\n          </span>\n          <p className=\"mt-1 text-balance text-xs font-medium text-muted-foreground sm:mt-3 sm:text-sm\">\n            Datarooms\n          </p>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction MostActiveSlide({ stats }: { stats: YearlyRecapStats }) {\n  return (\n    <div className=\"flex min-h-[350px] flex-col items-center justify-center text-center sm:min-h-[520px]\">\n      <h2 className=\"mb-2 text-balance text-xl font-bold text-foreground sm:text-3xl\">\n        Your Most Active Viewer\n      </h2>\n      <p className=\"mb-6 text-balance text-sm text-muted-foreground sm:mb-10 sm:text-lg\">\n        Someone really loves your documents!\n      </p>\n\n      {/* Card appears from bottom only */}\n      {stats.mostActiveViewer ? (\n        <div className=\"w-full max-w-xl rounded-2xl bg-card p-6 shadow-lg duration-700 animate-in slide-in-from-bottom-8 sm:rounded-3xl sm:p-12\">\n          <div className=\"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/40 sm:mb-6 sm:h-24 sm:w-24\">\n            <Users className=\"h-8 w-8 text-primary sm:h-12 sm:w-12\" />\n          </div>\n          <h3 className=\"truncate text-balance text-lg font-bold text-foreground sm:text-2xl\">\n            {stats.mostActiveViewer.name || stats.mostActiveViewer.email}\n          </h3>\n          <p className=\"mt-2 text-balance text-sm text-muted-foreground sm:mt-3 sm:text-base\">\n            Viewed your documents{\" \"}\n            <span className=\"font-semibold text-primary\">\n              {stats.mostActiveViewer.viewCount.toLocaleString()} times\n            </span>\n          </p>\n        </div>\n      ) : (\n        <div className=\"rounded-2xl bg-card p-6 shadow-lg sm:rounded-3xl sm:p-12\">\n          <p className=\"text-balance text-muted-foreground\">\n            No viewer data available\n          </p>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction SummarySlide({ stats }: { stats: YearlyRecapStats }) {\n  return (\n    <div className=\"flex min-h-[350px] flex-col items-center justify-center text-center sm:min-h-[520px]\">\n      <h2 className=\"mb-3 text-balance text-2xl font-bold text-foreground sm:mb-4 sm:text-4xl\">\n        What a year! 🎉\n      </h2>\n      <p className=\"max-w-xl text-balance px-2 text-sm text-muted-foreground sm:px-0 sm:text-lg\">\n        You&apos;ve made an impact with your documents. Here&apos;s to an even\n        bigger {stats.year + 1}!\n      </p>\n\n      {/* Busiest month */}\n      {stats.mostActiveMonth && (\n        <div className=\"mt-6 rounded-xl bg-card px-5 py-4 shadow-lg duration-700 animate-in zoom-in-75 sm:mt-8 sm:rounded-2xl sm:px-8 sm:py-5\">\n          <div className=\"flex items-center gap-2 text-xs sm:gap-3 sm:text-sm\">\n            <Calendar className=\"h-4 w-4 text-orange-600 sm:h-5 sm:w-5\" />\n            <span className=\"text-muted-foreground\">Busiest month:</span>\n            <span className=\"font-semibold text-foreground\">\n              {stats.mostActiveMonth.month}\n            </span>\n          </div>\n        </div>\n      )}\n\n      {/* Numbers only */}\n      {/* <div className=\"flex items-center justify-center gap-8 mt-10\">\n        <span className=\"text-5xl font-bold text-foreground animate-in zoom-in-75 duration-700\" style={{ animationDelay: \"0ms\" }}>\n          {stats.totalViews.toLocaleString()}\n        </span>\n        <span className=\"text-5xl font-bold text-foreground animate-in zoom-in-75 duration-700\" style={{ animationDelay: \"150ms\" }}>\n          {stats.totalDocuments.toLocaleString()}\n        </span>\n        <span className=\"text-5xl font-bold text-foreground animate-in zoom-in-75 duration-700\" style={{ animationDelay: \"300ms\" }}>\n          {stats.totalDatarooms.toLocaleString()}\n        </span>\n      </div> */}\n    </div>\n  );\n}\n\nfunction ShareOfferSlide({ stats }: { stats: YearlyRecapStats }) {\n  return (\n    <div className=\"flex min-h-[350px] flex-col items-center justify-center text-center sm:min-h-[520px]\">\n      <h2 className=\"mb-4 text-balance text-xl font-bold text-foreground sm:mb-6 sm:text-3xl\">\n        Share your stats or experience with Papermark\n      </h2>\n\n      <div className=\"duration-1000 animate-in zoom-in-50\">\n        <span className=\"mb-2 block text-6xl font-bold text-orange-500 sm:text-8xl\">\n          $50\n        </span>\n      </div>\n\n      <p className=\"mt-6 max-w-sm text-balance px-2 text-xs text-muted-foreground sm:mt-8 sm:max-w-none sm:px-0 sm:text-sm\">\n        You will receive $50 in credits on your papermark account, please send\n        confirmation to{\" \"}\n        <span className=\"font-medium\">support@papermark.com</span> and include\n        screenshot or link to your post.\n      </p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.js\",\n    \"css\": \"styles/globals.css\",\n    \"baseColor\": \"gray\",\n    \"cssVariables\": true\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/lib\",\n    \"hooks\": \"@/lib/hooks\"\n  }\n}\n"
  },
  {
    "path": "context/pending-uploads-context.tsx",
    "content": "import {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nexport type PendingUploadDocument = {\n  id: string;\n  name: string;\n  folderId: string | null;\n  uploadedAt: Date;\n  status: \"uploading\" | \"processing\" | \"complete\" | \"error\";\n  progress: number;\n  documentId?: string;\n  dataroomDocumentId?: string;\n  /** Version ID used to subscribe to Trigger.dev realtime processing updates */\n  documentVersionId?: string;\n  fileType?: string;\n  errorMessage?: string;\n  /** Whether this upload was loaded from the server (persisted) */\n  persisted?: boolean;\n};\n\nexport type PendingUploadsContextType = {\n  pendingUploads: PendingUploadDocument[];\n  addPendingUpload: (upload: PendingUploadDocument) => void;\n  updatePendingUpload: (\n    id: string,\n    update: Partial<PendingUploadDocument>,\n  ) => void;\n  removePendingUpload: (id: string) => void;\n  clearCompletedUploads: () => void;\n  getPendingUploadsForFolder: (\n    folderId: string | null,\n  ) => PendingUploadDocument[];\n  /** Returns all uploads (in-flight + persisted) */\n  getAllUploads: () => PendingUploadDocument[];\n  /** Whether the visitor has any uploads */\n  hasUploads: boolean;\n  /** Whether persisted uploads are still loading */\n  isLoading: boolean;\n};\n\nexport const initialState: PendingUploadsContextType = {\n  pendingUploads: [],\n  addPendingUpload: () => {},\n  updatePendingUpload: () => {},\n  removePendingUpload: () => {},\n  clearCompletedUploads: () => {},\n  getPendingUploadsForFolder: () => [],\n  getAllUploads: () => [],\n  hasUploads: false,\n  isLoading: false,\n};\n\nconst PendingUploadsContext =\n  createContext<PendingUploadsContextType>(initialState);\n\nexport const PendingUploadsProvider = ({\n  children,\n  linkId,\n  dataroomId,\n}: {\n  children: React.ReactNode;\n  linkId?: string;\n  dataroomId?: string;\n}): JSX.Element => {\n  // In-flight uploads from the current session (uploading, processing, etc.)\n  const [pendingUploads, setPendingUploads] = useState<\n    PendingUploadDocument[]\n  >([]);\n  // Persisted uploads loaded from the server\n  const [persistedUploads, setPersistedUploads] = useState<\n    PendingUploadDocument[]\n  >([]);\n  const [isLoading, setIsLoading] = useState(false);\n  const fetchedRef = useRef(false);\n\n  // Fetch the viewer's persisted uploads on mount\n  useEffect(() => {\n    if (!linkId || !dataroomId || fetchedRef.current) return;\n    fetchedRef.current = true;\n\n    const fetchUploads = async () => {\n      setIsLoading(true);\n      try {\n        const res = await fetch(\n          `/api/links/${linkId}/upload?dataroomId=${dataroomId}`,\n        );\n        if (!res.ok) return;\n\n        const data = await res.json();\n        if (data.uploads && Array.isArray(data.uploads)) {\n          const loaded: PendingUploadDocument[] = data.uploads.map(\n            (u: any) => ({\n              id: u.id,\n              name: u.name,\n              folderId: u.folderId,\n              uploadedAt: new Date(u.uploadedAt),\n              status: u.status as \"complete\" | \"processing\",\n              progress: 100,\n              documentId: u.documentId,\n              dataroomDocumentId: u.dataroomDocumentId,\n              documentVersionId: u.documentVersionId ?? undefined,\n              fileType: u.fileType,\n              persisted: true,\n            }),\n          );\n          setPersistedUploads(loaded);\n        }\n      } catch (err) {\n        console.error(\"Failed to fetch viewer uploads:\", err);\n      } finally {\n        setIsLoading(false);\n      }\n    };\n\n    fetchUploads();\n  }, [linkId, dataroomId]);\n\n  const addPendingUpload = useCallback((upload: PendingUploadDocument) => {\n    setPendingUploads((prev) => [...prev, upload]);\n  }, []);\n\n  const updatePendingUpload = useCallback(\n    (id: string, update: Partial<PendingUploadDocument>) => {\n      setPendingUploads((prev) =>\n        prev.map((upload) =>\n          upload.id === id ? { ...upload, ...update } : upload,\n        ),\n      );\n      setPersistedUploads((prev) =>\n        prev.map((upload) =>\n          upload.id === id ? { ...upload, ...update } : upload,\n        ),\n      );\n    },\n    [],\n  );\n\n  const removePendingUpload = useCallback((id: string) => {\n    setPendingUploads((prev) => prev.filter((upload) => upload.id !== id));\n  }, []);\n\n  const clearCompletedUploads = useCallback(() => {\n    setPendingUploads((prev) =>\n      prev.filter((upload) => upload.status !== \"complete\"),\n    );\n  }, []);\n\n  // Merge in-flight and persisted uploads, deduplicating by documentId\n  const allUploads = useMemo(() => {\n    // In-flight uploads take priority (they have real-time status)\n    const inFlightDocIds = new Set(\n      pendingUploads\n        .filter((u) => u.documentId)\n        .map((u) => u.documentId),\n    );\n\n    // Filter out persisted uploads that already have an in-flight version\n    const filteredPersisted = persistedUploads.filter(\n      (u) => !inFlightDocIds.has(u.documentId),\n    );\n\n    return [...pendingUploads, ...filteredPersisted];\n  }, [pendingUploads, persistedUploads]);\n\n  const getPendingUploadsForFolder = useCallback(\n    (folderId: string | null) => {\n      return allUploads.filter((upload) => upload.folderId === folderId);\n    },\n    [allUploads],\n  );\n\n  const getAllUploads = useCallback(() => {\n    return allUploads;\n  }, [allUploads]);\n\n  const hasUploads = allUploads.length > 0;\n\n  const value = useMemo(\n    () => ({\n      pendingUploads: allUploads,\n      addPendingUpload,\n      updatePendingUpload,\n      removePendingUpload,\n      clearCompletedUploads,\n      getPendingUploadsForFolder,\n      getAllUploads,\n      hasUploads,\n      isLoading,\n    }),\n    [\n      allUploads,\n      addPendingUpload,\n      updatePendingUpload,\n      removePendingUpload,\n      clearCompletedUploads,\n      getPendingUploadsForFolder,\n      getAllUploads,\n      hasUploads,\n      isLoading,\n    ],\n  );\n\n  return (\n    <PendingUploadsContext.Provider value={value}>\n      {children}\n    </PendingUploadsContext.Provider>\n  );\n};\n\nexport const usePendingUploads = () => useContext(PendingUploadsContext);\n"
  },
  {
    "path": "context/team-context.tsx",
    "content": "import {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { useTeams } from \"@/lib/swr/use-teams\";\nimport { Team } from \"@/lib/types\";\n\ninterface TeamContextProps {\n  children: React.ReactNode;\n}\n\nexport type TeamContextType = {\n  teams: Team[];\n  currentTeam: Team | null;\n  currentTeamId: string | null;\n  isLoading: boolean;\n  setCurrentTeam: (team: Team) => void;\n};\n\nexport const initialState = {\n  teams: [],\n  currentTeam: null,\n  currentTeamId: null,\n  isLoading: false,\n  setCurrentTeam: (team: Team) => {},\n};\n\nconst TeamContext = createContext<TeamContextType>(initialState);\n\nexport const TeamProvider = ({ children }: TeamContextProps): JSX.Element => {\n  const { teams, loading } = useTeams();\n  const [currentTeam, setCurrentTeamState] = useState<Team | null>(null);\n\n  // Effect to set initial currentTeam on mount\n  useEffect(() => {\n    if (!teams || teams.length === 0 || currentTeam) return;\n\n    const savedTeamId =\n      typeof localStorage !== \"undefined\"\n        ? localStorage.getItem(\"currentTeamId\")\n        : null;\n\n    let teamToSet: Team | null = null;\n\n    if (savedTeamId) {\n      teamToSet = teams.find((team) => team.id === savedTeamId) || null;\n    }\n\n    if (!teamToSet && teams.length > 0) {\n      teamToSet = teams[0];\n    }\n\n    if (teamToSet) {\n      setCurrentTeamState(teamToSet);\n      if (typeof localStorage !== \"undefined\") {\n        localStorage.setItem(\"currentTeamId\", teamToSet.id);\n      }\n    }\n  }, [teams, currentTeam]);\n\n  const setCurrentTeam = useCallback((team: Team) => {\n    setCurrentTeamState(team);\n    if (typeof localStorage !== \"undefined\") {\n      localStorage.setItem(\"currentTeamId\", team.id);\n    }\n  }, []);\n\n  const value = useMemo(\n    () => ({\n      teams: teams || [],\n      currentTeam,\n      currentTeamId: currentTeam?.id || null,\n      isLoading: loading,\n      setCurrentTeam,\n    }),\n    [teams, currentTeam, loading, setCurrentTeam],\n  );\n\n  return <TeamContext.Provider value={value}>{children}</TeamContext.Provider>;\n};\n\nexport const useTeam = () => useContext(TeamContext);\n"
  },
  {
    "path": "ee/LICENSE.md",
    "content": "The Papermark Commercial License (the “Commercial License”)\nCopyright (c) 2023-present Papermark, Inc.\n\nWith regard to the Papermark Software:\n\nThis software and associated documentation files (the \"Software\") may only be\nused in production, if you (and any entity that you represent) have agreed to,\nand are in compliance with, an agreement governing\nthe use of the Software, as mutually agreed by you and Papermark, Inc. (\"Papermark\"),\nand otherwise have a valid Papermark Enterprise Edition subscription (\"Commercial Subscription\").\nSubject to the foregoing sentence, you are free to modify this Software and publish patches to the Software.\nYou agree that Papermark and/or its licensors (as applicable) retain all right, title and interest in\nand to all such modifications and/or patches, and all such modifications and/or\npatches may only be used, copied, modified, displayed, distributed, or otherwise\nexploited with a valid Commercial Subscription for the correct number of hosts.\nNotwithstanding the foregoing, you may copy and modify the Software for development\nand testing purposes, without requiring a subscription. You agree that Papermark and/or\nits licensors (as applicable) retain all right, title and interest in and to all such\nmodifications. You are not granted any other rights beyond what is expressly stated herein.\nSubject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,\nand/or sell the Software.\n\nThis Commercial License applies only to the part of this Software that is not distributed under\nthe AGPLv3 license. Any part of this Software distributed under the MIT license or which\nis served client-side as an image, font, cascading stylesheet (CSS), file which produces\nor is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or\nin part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall\nbe included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nFor all third party components incorporated into the Papermark Software, those\ncomponents are licensed under the original license provided by the owner of the\napplicable component.\n"
  },
  {
    "path": "ee/README.md",
    "content": "<div align=\"center\">\n  <h1 align=\"center\">Papermark</h1>\n  <a href=\"https://www.papermark.com/enterprise\">Get an Enterprise License</a>\n</div>\n\n# Enterprise Edition\n\nWelcome to the Enterprise Edition of Papermark.\n\nThe [/(ee)](https://github.com/mfts/papermark/tree/main/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://www.papermark.com/pricing) plan and enterprise-grade features for [Enterprise](https://www.papermark.com/enterprise), included but not limited to the following:\n\n- Data Rooms\n- Q&A Conversations\n- Advanced Permissions\n\n> _❗ WARNING: This repository is copyrighted. You are not allowed to use this code to host your own version of app.papermark.com without obtaining a proper [license](https://www.papermark.com/enterprise) first❗_\n"
  },
  {
    "path": "ee/emails/pause-resume-reminder.tsx",
    "content": "import {\n  Body,\n  Container,\n  Head,\n  Heading,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface PauseResumeReminderEmailProps {\n  teamName?: string;\n  userName?: string;\n  resumeDate?: string;\n  plan?: string;\n  userRole?: string;\n}\n\nconst baseUrl =\n  process.env.NEXT_PUBLIC_MARKETING_URL || \"https://www.papermark.com\";\n\nexport default function PauseResumeReminderEmail({\n  teamName = \"Your Team\",\n  userName = \"Team Member\",\n  resumeDate = \"March 15, 2024\",\n  plan = \"Pro\",\n  userRole = \"Admin\",\n}: PauseResumeReminderEmailProps) {\n  const previewText = `Your ${teamName} subscription will resume billing in 3 days`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-[40px] w-[465px] rounded border border-solid border-[#eaeaea] p-[20px]\">\n            <Section className=\"mt-[32px]\">\n              <Img\n                src={`${baseUrl}/_static/papermark-logo.png`}\n                width=\"160\"\n                height=\"48\"\n                alt=\"Papermark\"\n                className=\"mx-auto my-0\"\n              />\n            </Section>\n\n            <Heading className=\"mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black\">\n              Subscription Resume Reminder\n            </Heading>\n\n            <Text className=\"text-[14px] leading-[24px] text-black\">\n              Hello {userName},\n            </Text>\n\n            <Text className=\"text-[14px] leading-[24px] text-black\">\n              This is a friendly reminder that your <strong>{teamName}</strong>{\" \"}\n              team's paused subscription will automatically resume billing in{\" \"}\n              <strong>3 days</strong>.\n            </Text>\n\n            <Section className=\"my-[32px] rounded-lg border border-solid border-[#e5e7eb] bg-[#f9fafb] p-[24px]\">\n              <Text className=\"m-0 text-[14px] font-semibold leading-[24px] text-black\">\n                📅 Resume Details:\n              </Text>\n              <Text className=\"mb-0 mt-[12px] text-[14px] leading-[20px] text-[#6b7280]\">\n                <strong>Team:</strong> {teamName}\n                <br />\n                <strong>Plan:</strong> {plan}\n                <br />\n                <strong>Resume Date:</strong> {resumeDate}\n                <br />\n                <strong>Your Role:</strong> {userRole}\n              </Text>\n            </Section>\n\n            <Text className=\"text-[14px] leading-[24px] text-black\">\n              <strong>What happens next?</strong>\n            </Text>\n\n            <Text className=\"text-[14px] leading-[20px] text-black\">\n              • Your subscription will automatically resume on{\" \"}\n              <strong>{resumeDate}</strong>\n              <br />\n              • Billing will restart at your regular plan rate\n              <br />\n              • All features will be fully restored\n              <br />• Your existing data and links remain unchanged\n            </Text>\n\n            <Text className=\"text-[14px] leading-[24px] text-black\">\n              <strong>Need to make changes?</strong>\n            </Text>\n\n            <Text className=\"text-[14px] leading-[24px] text-black\">\n              If you'd like to cancel your subscription instead of resuming, or\n              need to update your billing information, you can manage your\n              subscription in your account settings.\n            </Text>\n\n            <Section className=\"my-[32px] text-center\">\n              <Link\n                className=\"rounded bg-[#000000] px-5 py-3 text-center text-[12px] font-semibold text-white no-underline\"\n                href={`${baseUrl}/settings/billing`}\n              >\n                Manage Subscription\n              </Link>\n            </Section>\n\n            <Text className=\"text-[14px] leading-[24px] text-black\">\n              If you have any questions or concerns, please don't hesitate to\n              reach out to our support team.\n            </Text>\n\n            <Text className=\"text-[14px] leading-[24px] text-black\">\n              Best regards,\n              <br />\n              The Papermark Team\n            </Text>\n\n            <Section className=\"mt-[32px] border-t border-solid border-[#eaeaea] pt-[20px]\">\n              <Text className=\"text-[12px] leading-[16px] text-[#666]\">\n                This email was sent to you as an admin/manager of the {teamName}{\" \"}\n                team on Papermark. If you believe this was sent in error, please\n                contact our support team.\n              </Text>\n\n              <Text className=\"text-[12px] leading-[16px] text-[#666]\">\n                Papermark - The secure document sharing platform\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "ee/features/access-notifications/components/blocked-email-attempt.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nexport default function BlockedEmailAttempt({\n  blockedEmail,\n  linkName,\n  resourceName,\n  resourceType = \"document\",\n  timestamp,\n  locationString,\n  accessType,\n}: {\n  blockedEmail: string;\n  linkName: string;\n  resourceName: string;\n  resourceType?: \"document\" | \"dataroom\";\n  timestamp?: string;\n  locationString?: string;\n  accessType?: \"global\" | \"allow\" | \"deny\";\n}) {\n  const accessTypeTexts = {\n    global:\n      \"This email is on your global block list and was denied access. No further action is required.\",\n    allow:\n      \"This email is not on your link's allow list and was denied access. No further action is required.\",\n    deny: \"This email is on your link's block list and was denied access. No further action is required.\",\n    default: \"This email was denied access. No further action is required.\",\n  };\n\n  const accessTypeText = accessType\n    ? accessTypeTexts[accessType]\n    : accessTypeTexts.default;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>Blocked email attempted to access your {resourceType}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 my-7 p-0 text-center text-xl font-semibold text-black\">\n              Blocked Email Attempted Access\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              A blocked email attempted to access your {resourceType}:\n            </Text>\n            <Text className=\"break-all text-sm leading-6 text-black\">\n              <ul>\n                <li className=\"text-sm leading-6 text-black\">\n                  <span className=\"font-semibold\">Email address:</span>{\" \"}\n                  {blockedEmail}\n                </li>\n                <li className=\"text-sm leading-6 text-black\">\n                  <span className=\"font-semibold\">Time:</span>{\" \"}\n                  {timestamp || new Date().toLocaleString()}\n                </li>\n                <li className=\"text-sm leading-6 text-black\">\n                  <span className=\"font-semibold\">\n                    {resourceType === \"dataroom\" ? \"Dataroom\" : \"Document\"}{\" \"}\n                    name:\n                  </span>{\" \"}\n                  {resourceName}\n                </li>\n                <li className=\"text-sm leading-6 text-black\">\n                  <span className=\"font-semibold\">Link name:</span> {linkName}\n                </li>\n              </ul>\n            </Text>\n            <Text className=\"mt-4 text-sm leading-6 text-black\">\n              {accessTypeText}\n            </Text>\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc.\n              </Text>\n              <Text className=\"text-xs\">\n                If you have any feedback or questions about this email, simply\n                reply to it. I&apos;d love to hear from you!\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "ee/features/access-notifications/index.ts",
    "content": "export { reportDeniedAccessAttempt } from \"./lib/report-denied-access-attempt\";\n"
  },
  {
    "path": "ee/features/access-notifications/lib/report-denied-access-attempt.ts",
    "content": "import { Link } from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\n\nimport { sendBlockedEmailAttemptNotification } from \"./send-blocked-email-attempt\";\n\nexport async function reportDeniedAccessAttempt(\n  link: Partial<Link>,\n  email: string,\n  accessType: \"global\" | \"allow\" | \"deny\" = \"global\",\n) {\n  if (!link || !link.teamId) return;\n\n  // Get all admin and manager emails\n  const users = await prisma.userTeam.findMany({\n    where: {\n      role: { in: [\"ADMIN\", \"MANAGER\"] },\n      status: \"ACTIVE\",\n      teamId: link.teamId,\n    },\n    select: {\n      user: { select: { email: true } },\n    },\n  });\n\n  const adminManagerEmails = users\n    .map((u) => u.user?.email)\n    .filter((e): e is string => !!e);\n\n  // Get resource info and owner email\n  let resourceType: \"dataroom\" | \"document\" = \"dataroom\";\n  let resourceName = \"Dataroom\";\n  let ownerEmail: string | undefined;\n\n  if (link.documentId) {\n    resourceType = \"document\";\n    const document = await prisma.document.findUnique({\n      where: { id: link.documentId },\n      select: { name: true, ownerId: true },\n    });\n    resourceName = document?.name || \"Document\";\n\n    if (document?.ownerId) {\n      const owner = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: document.ownerId,\n            teamId: link.teamId,\n          },\n          status: \"ACTIVE\",\n        },\n        select: { user: { select: { email: true } } },\n      });\n      ownerEmail = owner?.user?.email || undefined;\n    }\n  } else if (link.dataroomId) {\n    const dataroom = await prisma.dataroom.findUnique({\n      where: { id: link.dataroomId },\n      select: { name: true },\n    });\n    resourceName = dataroom?.name || \"Dataroom\";\n  }\n\n  // Combine all recipients and remove duplicates\n  const allRecipients = [...adminManagerEmails];\n  if (ownerEmail && !allRecipients.includes(ownerEmail)) {\n    allRecipients.push(ownerEmail);\n  }\n\n  // Send email to all recipients\n  if (allRecipients.length > 0) {\n    const [to, ...cc] = allRecipients;\n    await sendBlockedEmailAttemptNotification({\n      to,\n      cc: cc.length > 0 ? cc : undefined,\n      blockedEmail: email,\n      linkName: link.name || `Link #${link.id?.slice(-5)}`,\n      resourceName,\n      resourceType,\n      timestamp: new Date().toLocaleString(),\n      accessType,\n    });\n  }\n}\n"
  },
  {
    "path": "ee/features/access-notifications/lib/send-blocked-email-attempt.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport BlockedEmailAttempt from \"../components/blocked-email-attempt\";\n\nexport const sendBlockedEmailAttemptNotification = async ({\n  to,\n  cc,\n  blockedEmail,\n  linkName,\n  resourceName,\n  resourceType = \"document\",\n  timestamp,\n  locationString,\n  accessType,\n}: {\n  to: string;\n  cc?: string[];\n  blockedEmail: string;\n  linkName: string;\n  resourceName: string;\n  resourceType?: \"document\" | \"dataroom\";\n  timestamp?: string;\n  locationString?: string;\n  accessType: \"global\" | \"allow\" | \"deny\";\n}) => {\n  const emailTemplate = BlockedEmailAttempt({\n    blockedEmail,\n    linkName,\n    resourceName,\n    resourceType,\n    timestamp,\n    locationString,\n    accessType,\n  });\n  try {\n    await sendEmail({\n      to,\n      cc,\n      subject: `Blocked access attempt to ${resourceType}: ${resourceName}`,\n      react: emailTemplate,\n      system: true,\n      test: process.env.NODE_ENV === \"development\",\n    });\n    return { success: true };\n  } catch (error) {\n    return { success: false, error };\n  }\n};\n"
  },
  {
    "path": "ee/features/ai/components/agents-settings-card.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { CheckCircle, Loader2, XCircle } from \"lucide-react\";\n\nimport PapermarkSparkle from \"@/components/shared/icons/papermark-sparkle\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { Switch } from \"@/components/ui/switch\";\n\nimport { useAIIndexingStatus } from \"../hooks/use-ai-indexing-status\";\n\ninterface AgentsSettingsCardProps {\n  dataroomId: string;\n  teamId: string;\n  agentsEnabled: boolean;\n  vectorStoreId?: string | null;\n  onUpdate?: () => void;\n}\n\ninterface IndexingRun {\n  documentId: string;\n  documentName: string;\n  runId: string;\n}\n\nexport function AgentsSettingsCard({\n  dataroomId,\n  teamId,\n  agentsEnabled: initialEnabled,\n  vectorStoreId,\n  onUpdate,\n}: AgentsSettingsCardProps) {\n  const [agentsEnabled, setAgentsEnabled] = useState(initialEnabled);\n  const [loading, setLoading] = useState(false);\n  const [indexing, setIndexing] = useState(false);\n\n  // Run tracking state for polling\n  const [indexingRuns, setIndexingRuns] = useState<IndexingRun[]>([]);\n\n  // Check if AI feature is enabled for this team\n  const { isFeatureEnabled, isLoading: featuresLoading } = useFeatureFlags();\n  const isAIFeatureEnabled = isFeatureEnabled(\"ai\");\n\n  // Don't render if feature flags are still loading\n  if (featuresLoading) {\n    return (\n      <Card>\n        <CardContent className=\"flex h-32 items-center justify-center\">\n          <LoadingSpinner className=\"h-6 w-6\" />\n        </CardContent>\n      </Card>\n    );\n  }\n\n  // Don't render if AI feature is not enabled for this team\n  if (!isAIFeatureEnabled) {\n    return null;\n  }\n\n  const handleToggleAgents = async (enabled: boolean) => {\n    setLoading(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/datarooms/${dataroomId}`,\n        {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ agentsEnabled: enabled }),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to update agents setting\");\n      }\n\n      setAgentsEnabled(enabled);\n      toast.success(`AI Agents ${enabled ? \"enabled\" : \"disabled\"}`);\n\n      // Mutate the relevant SWR cache\n      await mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`);\n\n      onUpdate?.();\n    } catch (error) {\n      console.error(\"Error toggling agents:\", error);\n      toast.error(\"Failed to update agents setting\");\n      setAgentsEnabled(!enabled); // Revert on error\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const handleIndexDataroom = async () => {\n    setIndexing(true);\n    setIndexingRuns([]);\n\n    try {\n      const response = await fetch(\n        `/api/ai/store/teams/${teamId}/datarooms/${dataroomId}`,\n        {\n          method: \"POST\",\n        },\n      );\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error || \"Failed to index dataroom\");\n      }\n\n      const data = await response.json();\n\n      if (data.error) {\n        throw new Error(data.error);\n      }\n\n      // Dataroom batch indexing\n      if (data.runs && data.runs.length > 0) {\n        // Track runs for documents that need indexing\n        setIndexingRuns(data.runs);\n\n        // Show info about skipped documents\n        if (data.skippedCount > 0) {\n          toast.info(\n            `${data.skippedCount} document${data.skippedCount > 1 ? \"s\" : \"\"} already indexed, indexing ${data.triggeredCount} new document${data.triggeredCount > 1 ? \"s\" : \"\"}`,\n          );\n        }\n      } else if (data.skippedCount > 0 && data.triggeredCount === 0) {\n        // All documents were already indexed\n        toast.success(\n          `All ${data.skippedCount} document${data.skippedCount > 1 ? \"s are\" : \" is\"} already indexed`,\n        );\n        setIndexing(false);\n      } else if (data.totalDocuments === 0) {\n        // No documents in dataroom\n        toast.info(\"No documents found in dataroom to index\");\n        setIndexing(false);\n      }\n\n      if (data.errors && data.errors.length > 0) {\n        toast.warning(`Some documents had errors: ${data.errors.join(\", \")}`);\n      }\n    } catch (error: any) {\n      console.error(\"Error indexing dataroom:\", error);\n      toast.error(error.message || \"Failed to index dataroom\");\n      setIndexing(false);\n    }\n  };\n\n  // Handle batch indexing completion\n  const handleBatchIndexingComplete = async () => {\n    setIndexing(false);\n    setIndexingRuns([]);\n    toast.success(\"All documents indexed successfully\");\n\n    // Mutate the dataroom cache\n    await mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`);\n    onUpdate?.();\n  };\n\n  const isIndexed = !!vectorStoreId;\n\n  return (\n    <Card>\n      <CardHeader>\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <PapermarkSparkle className=\"h-5 w-5 text-primary\" />\n            <CardTitle>AI Agents</CardTitle>\n          </div>\n          <span\n            className=\"relative ml-auto flex h-4 w-4\"\n            title={`AI Agents are ${agentsEnabled ? \"\" : \"not\"} enabled`}\n          >\n            <span\n              className={cn(\n                \"absolute inline-flex h-full w-full rounded-full opacity-75\",\n                agentsEnabled ? \"animate-ping bg-green-400\" : \"\",\n              )}\n            />\n            <span\n              className={cn(\n                \"relative inline-flex h-4 w-4 rounded-full\",\n                agentsEnabled ? \"bg-green-500\" : \"bg-gray-400\",\n              )}\n            />\n          </span>\n        </div>\n        <CardDescription>\n          Enable AI-powered chat to let visitors ask questions about documents\n          in this dataroom. Documents must be indexed for chat to work.\n        </CardDescription>\n      </CardHeader>\n\n      <CardContent className=\"space-y-4\">\n        <div className=\"flex items-center justify-between space-x-2\">\n          <Label htmlFor=\"agents-enabled\" className=\"flex flex-col space-y-1\">\n            <span>Enable AI Agents</span>\n            <span className=\"text-xs font-normal leading-snug text-muted-foreground\">\n              Allow visitors to chat with AI about these documents\n            </span>\n          </Label>\n          <Switch\n            id=\"agents-enabled\"\n            checked={agentsEnabled}\n            onCheckedChange={handleToggleAgents}\n            disabled={loading}\n          />\n        </div>\n\n        {agentsEnabled && (\n          <div className=\"space-y-4 border-t pt-4\">\n            <div className=\"flex items-center justify-between space-x-2\">\n              <div className=\"flex flex-col space-y-1\">\n                <span className=\"text-sm font-medium\">Index Status</span>\n                <span className=\"text-xs text-muted-foreground\">\n                  {isIndexed\n                    ? \"Dataroom indexed and ready\"\n                    : \"Dataroom needs to be indexed\"}\n                </span>\n              </div>\n              <Button\n                onClick={handleIndexDataroom}\n                disabled={indexing}\n                size=\"sm\"\n              >\n                {indexing ? (\n                  <>\n                    <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                    Indexing...\n                  </>\n                ) : (\n                  <>Index Dataroom</>\n                )}\n              </Button>\n            </div>\n\n            {/* Batch dataroom indexing status */}\n            {indexingRuns.length > 0 && (\n              <DataroomIndexingStatus\n                runs={indexingRuns}\n                onAllComplete={handleBatchIndexingComplete}\n              />\n            )}\n          </div>\n        )}\n      </CardContent>\n\n      <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n        <p className=\"text-sm text-muted-foreground transition-colors\">\n          AI Agents use OpenAI to answer questions. Supports PDF, DOCX, PPTX,\n          Excel, CSV, and image files.\n        </p>\n      </CardFooter>\n    </Card>\n  );\n}\n\n/**\n * Component for displaying batch indexing status for datarooms\n */\nfunction DataroomIndexingStatus({\n  runs,\n  onAllComplete,\n}: {\n  runs: IndexingRun[];\n  onAllComplete: () => void;\n}) {\n  const [completedRuns, setCompletedRuns] = useState<Set<string>>(new Set());\n  const [failedRuns, setFailedRuns] = useState<Set<string>>(new Set());\n\n  const handleRunComplete = (runId: string) => {\n    setCompletedRuns((prev) => new Set(prev).add(runId));\n  };\n\n  const handleRunError = (runId: string) => {\n    setFailedRuns((prev) => new Set(prev).add(runId));\n  };\n\n  // Check if all runs are finished (completed or failed)\n  useEffect(() => {\n    const allFinished = runs.every(\n      (run) => completedRuns.has(run.runId) || failedRuns.has(run.runId),\n    );\n    if (allFinished && runs.length > 0) {\n      onAllComplete();\n    }\n  }, [completedRuns, failedRuns, runs, onAllComplete]);\n\n  const completedCount = completedRuns.size;\n  const failedCount = failedRuns.size;\n  const totalCount = runs.length;\n  const progress = Math.round(\n    ((completedCount + failedCount) / totalCount) * 100,\n  );\n\n  return (\n    <div className=\"space-y-3 rounded-lg border p-3\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n          <span className=\"text-sm font-medium\">\n            Indexing {totalCount} document{totalCount > 1 ? \"s\" : \"\"}\n          </span>\n        </div>\n        <span className=\"text-xs text-muted-foreground\">\n          {completedCount}/{totalCount} completed\n          {failedCount > 0 && `, ${failedCount} failed`}\n        </span>\n      </div>\n\n      <Progress value={progress} className=\"h-1.5\" />\n\n      <div className=\"max-h-40 space-y-2 overflow-y-auto\">\n        {runs.map((run) => (\n          <DataroomDocumentIndexingRow\n            key={run.runId}\n            run={run}\n            isCompleted={completedRuns.has(run.runId)}\n            isFailed={failedRuns.has(run.runId)}\n            onComplete={() => handleRunComplete(run.runId)}\n            onError={() => handleRunError(run.runId)}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Individual document row in batch indexing status\n */\nfunction DataroomDocumentIndexingRow({\n  run,\n  isCompleted,\n  isFailed,\n  onComplete,\n  onError,\n}: {\n  run: IndexingRun;\n  isCompleted: boolean;\n  isFailed: boolean;\n  onComplete: () => void;\n  onError: () => void;\n}) {\n  const status = useAIIndexingStatus({ runId: run.runId });\n\n  // Trigger callbacks when status changes\n  useEffect(() => {\n    if (status.isCompleted && !isCompleted) {\n      onComplete();\n    }\n    if (status.isFailed && !isFailed) {\n      onError();\n    }\n  }, [\n    status.isCompleted,\n    status.isFailed,\n    isCompleted,\n    isFailed,\n    onComplete,\n    onError,\n  ]);\n\n  return (\n    <div className=\"flex items-center justify-between rounded border px-2 py-1.5\">\n      <span className=\"max-w-[60%] truncate text-xs\" title={run.documentName}>\n        {run.documentName}\n      </span>\n      <div className=\"flex items-center gap-2\">\n        {status.isProcessing && (\n          <>\n            <Loader2 className=\"h-3 w-3 animate-spin text-primary\" />\n            <span className=\"text-xs text-muted-foreground\">\n              {status.progress}%\n            </span>\n          </>\n        )}\n        {isCompleted && <CheckCircle className=\"h-3.5 w-3.5 text-green-500\" />}\n        {isFailed && <XCircle className=\"h-3.5 w-3.5 text-red-500\" />}\n        {!status.isProcessing && !isCompleted && !isFailed && (\n          <span className=\"text-xs text-muted-foreground\">Queued</span>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/ai/components/ai-indexing-status.tsx",
    "content": "\"use client\";\n\nimport { useEffect } from \"react\";\n\nimport { CheckCircle, Loader2, XCircle } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Progress } from \"@/components/ui/progress\";\n\nimport { useAIIndexingStatus } from \"../hooks/use-ai-indexing-status\";\n\ninterface AIIndexingStatusProps {\n  runId: string | null;\n  onComplete?: (vectorStoreFileId: string) => void;\n  onError?: (error: string) => void;\n  className?: string;\n}\n\n/**\n * AI indexing status display component with polling\n * Shows progress, current step, and final status\n */\nexport function AIIndexingStatus({\n  runId,\n  onComplete,\n  onError,\n  className,\n}: AIIndexingStatusProps) {\n  const {\n    isProcessing,\n    isCompleted,\n    isFailed,\n    step,\n    progress,\n    error,\n    vectorStoreFileId,\n  } = useAIIndexingStatus({ runId });\n\n  // Handle completion callback\n  useEffect(() => {\n    if (isCompleted && vectorStoreFileId && onComplete) {\n      onComplete(vectorStoreFileId);\n    }\n  }, [isCompleted, vectorStoreFileId, onComplete]);\n\n  // Handle error callback\n  useEffect(() => {\n    if (isFailed && error && onError) {\n      onError(error);\n    }\n  }, [isFailed, error, onError]);\n\n  if (!runId) {\n    return null;\n  }\n\n  return (\n    <div className={cn(\"flex flex-col gap-2\", className)}>\n      {isProcessing && (\n        <div className=\"flex items-center gap-3\">\n          <Loader2 className=\"h-4 w-4 animate-spin text-primary\" />\n          <div className=\"flex flex-1 flex-col gap-1\">\n            <span className=\"text-sm text-muted-foreground\">\n              {step || \"Processing...\"}\n            </span>\n            <Progress value={progress} className=\"h-1.5\" />\n          </div>\n          <span className=\"text-xs text-muted-foreground\">{progress}%</span>\n        </div>\n      )}\n\n      {isCompleted && (\n        <div className=\"flex items-center gap-2\">\n          <CheckCircle className=\"h-4 w-4 text-green-500\" />\n          <span className=\"text-sm text-green-600\">Indexed successfully</span>\n        </div>\n      )}\n\n      {isFailed && (\n        <div className=\"flex items-center gap-2\">\n          <XCircle className=\"h-4 w-4 text-red-500\" />\n          <span className=\"text-sm text-red-600\">\n            {error || \"Indexing failed\"}\n          </span>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/ai/components/chat-message.tsx",
    "content": "\"use client\";\n\nimport { type ComponentPropsWithoutRef, memo, useMemo, useState } from \"react\";\n\nimport {\n  CheckIcon,\n  ChevronDownIcon,\n  CopyIcon,\n  FileIcon,\n  FileSpreadsheetIcon,\n  FileTextIcon,\n  FolderIcon,\n  ImageIcon,\n  PresentationIcon,\n} from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport {\n  Message,\n  MessageAction,\n  MessageActions,\n  MessageContent,\n  MessageResponse,\n} from \"@/components/ai-elements/message\";\nimport { Shimmer } from \"@/components/ai-elements/shimmer\";\n\nimport type { ChatStreamSource } from \"../lib/chat/send-message\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface ChatMessageProps {\n  role: \"user\" | \"assistant\";\n  content: string;\n  isStreaming?: boolean;\n  className?: string;\n  messageId?: string;\n  onFeedback?: (messageId: string, feedback: \"up\" | \"down\") => void;\n  sources?: ChatStreamSource[];\n  suggestedQuestions?: string[];\n  onSuggestedQuestionClick?: (question: string) => void;\n  isLastAssistantMessage?: boolean;\n}\n\ntype FeedbackState = \"up\" | \"down\" | null;\n\nfunction getViewerBasePath(): string {\n  if (typeof window === \"undefined\") return \"\";\n  const path = window.location.pathname;\n  const dIndex = path.indexOf(\"/d/\");\n  return dIndex !== -1 ? path.slice(0, dIndex) : path;\n}\n\nfunction buildDocumentUrl(\n  dataroomDocumentId: string,\n  page?: number,\n): string {\n  const base = getViewerBasePath();\n  const query = page ? `?p=${page}` : \"\";\n  return `${base}/d/${dataroomDocumentId}${query}`;\n}\n\nfunction normalizeAssistantLinkHref(href?: string): string | undefined {\n  if (!href) {\n    return href;\n  }\n\n  if (href.startsWith(\"/\")) {\n    const dIndex = href.indexOf(\"/d/\");\n    if (dIndex !== -1) {\n      const docSegment = href.slice(dIndex);\n      return `${getViewerBasePath()}${docSegment}`;\n    }\n    return href;\n  }\n\n  try {\n    const parsed = new URL(href);\n    const dIndex = parsed.pathname.indexOf(\"/d/\");\n    if (dIndex !== -1) {\n      const docSegment = parsed.pathname.slice(dIndex);\n      return `${getViewerBasePath()}${docSegment}${parsed.search}${parsed.hash}`;\n    }\n  } catch {\n    // Keep original href if URL parsing fails.\n  }\n\n  return href;\n}\n\nfunction AssistantLink({\n  children,\n  className,\n  href,\n  ...props\n}: ComponentPropsWithoutRef<\"a\">) {\n  const normalizedHref = normalizeAssistantLinkHref(href);\n\n  return (\n    <a\n      {...props}\n      className={cn(\"inline-flex items-center\", className)}\n      href={normalizedHref}\n      rel=\"noopener noreferrer\"\n      target=\"_blank\"\n    >\n      {children}\n    </a>\n  );\n}\n\n// ============================================================================\n// File type icon helper\n// ============================================================================\n\nfunction getFileIcon(filename: string) {\n  const ext = filename.split(\".\").pop()?.toLowerCase();\n  switch (ext) {\n    case \"pdf\":\n      return <FileTextIcon className=\"size-3.5 text-red-500\" />;\n    case \"ppt\":\n    case \"pptx\":\n      return <PresentationIcon className=\"size-3.5 text-orange-500\" />;\n    case \"xls\":\n    case \"xlsx\":\n    case \"csv\":\n      return <FileSpreadsheetIcon className=\"size-3.5 text-green-600\" />;\n    case \"jpg\":\n    case \"jpeg\":\n    case \"png\":\n    case \"gif\":\n    case \"webp\":\n      return <ImageIcon className=\"size-3.5 text-purple-500\" />;\n    default:\n      return <FileIcon className=\"size-3.5 text-blue-500\" />;\n  }\n}\n\n// ============================================================================\n// Sources Section\n// ============================================================================\n\nfunction SourcesSection({ sources }: { sources: ChatStreamSource[] }) {\n  const [expanded, setExpanded] = useState(false);\n\n  if (sources.length === 0) return null;\n\n  return (\n    <div className=\"!mt-3 rounded-lg border border-gray-200 bg-gray-50/50\">\n      <button\n        type=\"button\"\n        onClick={() => setExpanded(!expanded)}\n        className=\"flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100/50\"\n      >\n        <span>\n          Source: {sources.length}{\" \"}\n          {sources.length === 1 ? \"document\" : \"documents\"}\n        </span>\n        <ChevronDownIcon\n          className={cn(\n            \"size-3.5 text-gray-400 transition-transform duration-200\",\n            expanded && \"rotate-180\",\n          )}\n        />\n      </button>\n\n      {expanded && (\n        <div className=\"border-t border-gray-200 px-3 py-2\">\n          <div className=\"space-y-1.5\">\n            {sources.map((source) => (\n              <a\n                key={`${source.id}-${source.page}`}\n                href={\n                  source.dataroomDocumentId\n                    ? buildDocumentUrl(source.dataroomDocumentId, source.page)\n                    : normalizeAssistantLinkHref(source.url)\n                }\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"group flex items-start gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-gray-100\"\n              >\n                <span className=\"mt-0.5 shrink-0 text-[10px] font-semibold text-gray-400\">\n                  {source.id}\n                </span>\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"flex items-center gap-1.5\">\n                    {getFileIcon(source.name)}\n                    <span className=\"truncate text-xs font-medium text-gray-900 group-hover:text-primary\">\n                      {source.name}\n                    </span>\n                    {source.page && (\n                      <span className=\"shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600\">\n                        Page {source.page}\n                      </span>\n                    )}\n                  </div>\n                  {source.folderPath && (\n                    <div className=\"mt-0.5 flex items-center gap-1 text-[10px] text-gray-400\">\n                      <FolderIcon className=\"size-2.5\" />\n                      <span className=\"truncate\">{source.folderPath}</span>\n                    </div>\n                  )}\n                </div>\n              </a>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\n// ============================================================================\n// Suggested Questions\n// ============================================================================\n\nfunction SuggestedQuestions({\n  questions,\n  onQuestionClick,\n}: {\n  questions: string[];\n  onQuestionClick?: (question: string) => void;\n}) {\n  if (questions.length === 0) return null;\n\n  return (\n    <div className=\"!mt-5\">\n      <p className=\"mb-1.5 text-[10px] font-medium uppercase tracking-wider text-gray-400\">\n        Suggested questions\n      </p>\n      <div className=\"space-y-1\">\n        {questions.map((question, idx) => (\n          <button\n            key={idx}\n            type=\"button\"\n            onClick={() => onQuestionClick?.(question)}\n            className=\"block w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-left text-xs text-gray-700 transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-gray-900\"\n          >\n            {question}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n// ============================================================================\n// Main Component\n// ============================================================================\n\nexport const ChatMessage = memo(function ChatMessage({\n  role,\n  content,\n  isStreaming = false,\n  className,\n  messageId,\n  onFeedback,\n  sources,\n  suggestedQuestions,\n  onSuggestedQuestionClick,\n  isLastAssistantMessage,\n}: ChatMessageProps) {\n  const [copied, setCopied] = useState(false);\n  const [feedback, setFeedback] = useState<FeedbackState>(null);\n  const isUser = role === \"user\";\n  const responseComponents = useMemo(\n    () => ({\n      a: AssistantLink,\n    }),\n    [],\n  );\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(content);\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } catch (err) {\n      console.error(\"Failed to copy:\", err);\n    }\n  };\n\n  const handleFeedback = (type: \"up\" | \"down\") => {\n    const newFeedback = feedback === type ? null : type;\n    setFeedback(newFeedback);\n\n    if (messageId && onFeedback && newFeedback) {\n      onFeedback(messageId, newFeedback);\n    }\n  };\n\n  if (isUser) {\n    return (\n      <Message from=\"user\" className={className}>\n        <MessageContent>\n          <p className=\"whitespace-pre-wrap\">{content}</p>\n        </MessageContent>\n      </Message>\n    );\n  }\n\n  return (\n    <div className=\"space-y-1\">\n      <Message from=\"assistant\" className={className}>\n        <MessageContent>\n          {isStreaming && !content ? (\n            <Shimmer duration={1.5}>Generating response...</Shimmer>\n          ) : (\n            <div className=\"prose prose-sm max-w-none dark:prose-invert\">\n              <MessageResponse\n                components={responseComponents}\n                linkSafety={{ enabled: false }}\n              >\n                {content}\n              </MessageResponse>\n            </div>\n          )}\n        </MessageContent>\n      </Message>\n\n      {content && !isStreaming && (\n        <>\n          <MessageActions className=\"ml-0\">\n            <MessageAction\n              onClick={handleCopy}\n              tooltip={copied ? \"Copied!\" : \"Copy\"}\n              label={copied ? \"Copied\" : \"Copy message\"}\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-7\"\n            >\n              {copied ? (\n                <CheckIcon className=\"size-3.5 text-green-500\" />\n              ) : (\n                <CopyIcon className=\"size-3.5 text-muted-foreground\" />\n              )}\n            </MessageAction>\n          </MessageActions>\n\n          {sources && sources.length > 0 && (\n            <SourcesSection sources={sources} />\n          )}\n\n          {isLastAssistantMessage &&\n            suggestedQuestions &&\n            suggestedQuestions.length > 0 && (\n              <SuggestedQuestions\n                questions={suggestedQuestions}\n                onQuestionClick={onSuggestedQuestionClick}\n              />\n            )}\n        </>\n      )}\n    </div>\n  );\n});\n\nexport default ChatMessage;\n"
  },
  {
    "path": "ee/features/ai/components/document-ai-dialog.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { ExternalLink, Shield } from \"lucide-react\";\n\nimport PapermarkSparkle from \"@/components/shared/icons/papermark-sparkle\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport { Switch } from \"@/components/ui/switch\";\n\nimport { AIIndexingStatus } from \"./ai-indexing-status\";\n\ninterface DocumentAIDialogProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  documentId: string;\n  teamId: string;\n  agentsEnabled: boolean;\n  vectorStoreFileId?: string | null;\n}\n\nexport function DocumentAIDialog({\n  open,\n  onOpenChange,\n  documentId,\n  teamId,\n  agentsEnabled: initialEnabled,\n  vectorStoreFileId,\n}: DocumentAIDialogProps) {\n  const { isFeatureEnabled } = useFeatureFlags();\n  const isAIFeatureEnabled = isFeatureEnabled(\"ai\");\n\n  const [agentsEnabled, setAgentsEnabled] = useState(initialEnabled);\n  const [loading, setLoading] = useState(false);\n  const [indexing, setIndexing] = useState(false);\n\n  // Run tracking state for polling\n  const [indexingRunId, setIndexingRunId] = useState<string | null>(null);\n\n  // Don't render if AI feature is not enabled\n  if (!isAIFeatureEnabled) {\n    return null;\n  }\n\n  const isIndexed = !!vectorStoreFileId;\n\n  // Disable agents (only allowed if already enabled)\n  const handleDisableAgents = async () => {\n    setLoading(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${teamId}/documents/${documentId}`,\n        {\n          method: \"PATCH\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ agentsEnabled: false }),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to disable agents\");\n      }\n\n      setAgentsEnabled(false);\n      toast.success(\"AI Agents disabled\");\n\n      // Mutate the document cache\n      await mutate(`/api/teams/${teamId}/documents/${documentId}`);\n    } catch (error) {\n      console.error(\"Error disabling agents:\", error);\n      toast.error(\"Failed to disable agents\");\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Enable agents and index document in one flow\n  const handleEnableAndIndex = async () => {\n    setIndexing(true);\n    setIndexingRunId(null);\n\n    try {\n      const response = await fetch(\n        `/api/ai/store/teams/${teamId}/documents/${documentId}`,\n        {\n          method: \"POST\",\n        },\n      );\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error || \"Failed to index document\");\n      }\n\n      const data = await response.json();\n\n      if (data.error) {\n        throw new Error(data.error);\n      }\n\n      // Set up polling tracking\n      if (data.runId) {\n        setIndexingRunId(data.runId);\n      }\n    } catch (error: any) {\n      console.error(\"Error indexing document:\", error);\n      toast.error(error.message || \"Failed to index document\");\n      setIndexing(false);\n    }\n  };\n\n  // Re-index an already enabled document\n  const handleReindex = async () => {\n    setIndexing(true);\n    setIndexingRunId(null);\n\n    try {\n      const response = await fetch(\n        `/api/ai/store/teams/${teamId}/documents/${documentId}`,\n        {\n          method: \"POST\",\n        },\n      );\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error || \"Failed to re-index document\");\n      }\n\n      const data = await response.json();\n\n      if (data.error) {\n        throw new Error(data.error);\n      }\n\n      // Set up polling tracking\n      if (data.runId) {\n        setIndexingRunId(data.runId);\n      }\n    } catch (error: any) {\n      console.error(\"Error re-indexing document:\", error);\n      toast.error(error.message || \"Failed to re-index document\");\n      setIndexing(false);\n    }\n  };\n\n  const handleIndexingComplete = async () => {\n    setIndexing(false);\n    setIndexingRunId(null);\n\n    // Only enable agents after successful indexing\n    if (!agentsEnabled) {\n      try {\n        const response = await fetch(\n          `/api/teams/${teamId}/documents/${documentId}`,\n          {\n            method: \"PATCH\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({ agentsEnabled: true }),\n          },\n        );\n\n        if (!response.ok) {\n          throw new Error(\"Failed to enable agents\");\n        }\n\n        setAgentsEnabled(true);\n      } catch (error) {\n        console.error(\"Error enabling agents after indexing:\", error);\n        toast.error(\"Indexed successfully but failed to enable agents\");\n        await mutate(`/api/teams/${teamId}/documents/${documentId}`);\n        return;\n      }\n    }\n\n    toast.success(\"Document indexed successfully\");\n    // Mutate the document cache\n    await mutate(`/api/teams/${teamId}/documents/${documentId}`);\n  };\n\n  const handleIndexingError = (error: string) => {\n    setIndexing(false);\n    setIndexingRunId(null);\n    toast.error(error || \"Failed to index document\");\n    // Don't enable agentsEnabled on failure\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent className=\"sm:max-w-md\">\n        <DialogHeader>\n          <DialogTitle className=\"flex items-center gap-2\">\n            <PapermarkSparkle className=\"h-5 w-5 text-primary\" />\n            AI Agents\n          </DialogTitle>\n          <DialogDescription>\n            Enable AI-powered chat for this document. Visitors can ask questions\n            and get intelligent answers.\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"space-y-4 py-4\">\n          {/* Status & Actions Section */}\n          <div className=\"space-y-3 rounded-lg border p-4\">\n            <div className=\"flex items-center justify-between space-x-2\">\n              <div className=\"flex flex-col space-y-1\">\n                <span className=\"text-sm font-medium\">\n                  {agentsEnabled ? \"AI Chat Status\" : \"Enable AI Chat\"}\n                </span>\n                <span className=\"text-xs text-muted-foreground\">\n                  {agentsEnabled\n                    ? isIndexed\n                      ? \"Document is indexed and ready for AI chat\"\n                      : \"Document needs to be re-indexed\"\n                    : \"Index your document to enable AI-powered chat\"}\n                </span>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                {agentsEnabled && (\n                  <span\n                    className={cn(\n                      \"inline-flex h-2 w-2 rounded-full\",\n                      isIndexed ? \"bg-green-500\" : \"bg-amber-500\",\n                    )}\n                  />\n                )}\n                {agentsEnabled ? (\n                  <>\n                    <Button\n                      onClick={handleReindex}\n                      disabled={indexing}\n                      size=\"sm\"\n                      variant=\"outline\"\n                    >\n                      {indexing ? \"Indexing...\" : \"Re-index\"}\n                    </Button>\n                    <Button\n                      onClick={handleDisableAgents}\n                      disabled={loading || indexing}\n                      size=\"sm\"\n                      variant=\"ghost\"\n                      className=\"text-destructive hover:text-destructive\"\n                    >\n                      Disable\n                    </Button>\n                  </>\n                ) : (\n                  <Button\n                    onClick={handleEnableAndIndex}\n                    disabled={indexing}\n                    size=\"sm\"\n                  >\n                    {indexing ? \"Indexing...\" : \"Enable AI Chat\"}\n                  </Button>\n                )}\n              </div>\n            </div>\n\n            {/* Indexing status with polling */}\n            {indexingRunId && (\n              <AIIndexingStatus\n                runId={indexingRunId}\n                onComplete={handleIndexingComplete}\n                onError={handleIndexingError}\n                className=\"pt-2 border-t\"\n              />\n            )}\n          </div>\n\n          {/* Privacy Notice */}\n          <div className=\"rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950/30\">\n            <div className=\"flex items-start gap-3\">\n              <Shield className=\"mt-0.5 h-5 w-5 shrink-0 text-green-600\" />\n              <div className=\"space-y-2 text-sm\">\n                <p className=\"font-medium text-green-900 dark:text-green-100\">\n                  Privacy & Data Usage\n                </p>\n                <ul className=\"space-y-1 text-green-800 dark:text-green-200\">\n                  <li>• Powered by OpenAI&apos;s API</li>\n                  <li>• Your data is NOT used to train AI models</li>\n                  <li>• Document embeddings stored securely</li>\n                  <li>• Delete anytime by disabling AI</li>\n                </ul>\n                <a\n                  href=\"https://openai.com/policies/api-data-usage-policies\"\n                  target=\"_blank\"\n                  rel=\"noopener noreferrer\"\n                  className=\"inline-flex items-center gap-1 text-xs text-green-700 hover:underline dark:text-green-300\"\n                >\n                  OpenAI Data Usage Policy\n                  <ExternalLink className=\"h-3 w-3\" />\n                </a>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n            Close\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "ee/features/ai/components/document-context-selector.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\n\nimport { DataroomFolder } from \"@prisma/client\";\nimport { FileTextIcon, FolderIcon, XIcon } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport { DataroomDocumentForChat } from \"./viewer-chat-provider\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface SelectedContextItem {\n  type: \"document\" | \"folder\";\n  id: string; // dataroomDocumentId for documents, folder.id for folders\n  name: string;\n}\n\ninterface DocumentContextSelectorProps {\n  documents: DataroomDocumentForChat[];\n  folders: DataroomFolder[];\n  selectedItems: SelectedContextItem[];\n  onSelectionChange: (items: SelectedContextItem[]) => void;\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  disabled?: boolean;\n}\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Get all documents within a folder (including nested folders)\n */\nfunction getDocumentsInFolder(\n  folderId: string,\n  documents: DataroomDocumentForChat[],\n  folders: DataroomFolder[],\n): DataroomDocumentForChat[] {\n  // Get direct documents in this folder\n  const directDocs = documents.filter((doc) => doc.folderId === folderId);\n\n  // Get child folders\n  const childFolders = folders.filter((f) => f.parentId === folderId);\n\n  // Recursively get documents from child folders\n  const nestedDocs = childFolders.flatMap((childFolder) =>\n    getDocumentsInFolder(childFolder.id, documents, folders),\n  );\n\n  return [...directDocs, ...nestedDocs];\n}\n\n// ============================================================================\n// Main Component\n// ============================================================================\n\nexport function DocumentContextSelector({\n  documents,\n  folders,\n  selectedItems,\n  onSelectionChange,\n  open,\n  onOpenChange,\n  disabled = false,\n}: DocumentContextSelectorProps) {\n  const [searchQuery, setSearchQuery] = useState(\"\");\n\n  // Reset search query when popover opens\n  useEffect(() => {\n    if (open) {\n      setSearchQuery(\"\");\n    }\n  }, [open]);\n\n  // Handle item selection\n  const handleSelect = useCallback(\n    (type: \"document\" | \"folder\", id: string, name: string) => {\n      // Check if already selected\n      const isSelected = selectedItems.some(\n        (item) => item.type === type && item.id === id,\n      );\n\n      if (isSelected) {\n        // Remove if already selected\n        onSelectionChange(\n          selectedItems.filter(\n            (item) => !(item.type === type && item.id === id),\n          ),\n        );\n      } else {\n        // Add new selection\n        onSelectionChange([...selectedItems, { type, id, name }]);\n      }\n\n      // Close popover\n      onOpenChange(false);\n    },\n    [selectedItems, onSelectionChange, onOpenChange],\n  );\n\n  // Filter items based on search query\n  const filteredFolders = useMemo(() => {\n    if (!searchQuery) return folders;\n    const query = searchQuery.toLowerCase();\n    return folders.filter((folder) =>\n      folder.name.toLowerCase().includes(query),\n    );\n  }, [folders, searchQuery]);\n\n  const filteredDocuments = useMemo(() => {\n    if (!searchQuery) return documents;\n    const query = searchQuery.toLowerCase();\n    return documents.filter((doc) => doc.name.toLowerCase().includes(query));\n  }, [documents, searchQuery]);\n\n  if (disabled || (documents.length === 0 && folders.length === 0)) {\n    return null;\n  }\n\n  return (\n    <Popover open={open} onOpenChange={onOpenChange}>\n      <PopoverTrigger asChild>\n        <div className=\"sr-only\" aria-hidden=\"true\" />\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"w-[320px] p-0\"\n        align=\"start\"\n        side=\"top\"\n        sideOffset={8}\n        onOpenAutoFocus={(e) => e.preventDefault()}\n      >\n        <Command>\n          <CommandInput\n            placeholder=\"Search documents and folders...\"\n            value={searchQuery}\n            onValueChange={setSearchQuery}\n            autoFocus\n            wrapperClassName=\"border-b border-border\"\n            className=\"focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-none focus:outline-none\"\n          />\n          <CommandList className=\"max-h-[300px]\">\n            <CommandEmpty>No items found.</CommandEmpty>\n\n            {filteredFolders.length > 0 && (\n              <CommandGroup heading=\"Folders\">\n                {filteredFolders.map((folder) => {\n                  const isSelected = selectedItems.some(\n                    (item) => item.type === \"folder\" && item.id === folder.id,\n                  );\n                  const docCount = getDocumentsInFolder(\n                    folder.id,\n                    documents,\n                    folders,\n                  ).length;\n\n                  return (\n                    <CommandItem\n                      key={`folder-${folder.id}`}\n                      value={`folder-${folder.name}`}\n                      onSelect={() =>\n                        handleSelect(\"folder\", folder.id, folder.name)\n                      }\n                      className={cn(\n                        \"flex items-center gap-2\",\n                        isSelected && \"bg-accent\",\n                      )}\n                    >\n                      <FolderIcon className=\"size-4 text-muted-foreground\" />\n                      <span className=\"flex-1 truncate\">{folder.name}</span>\n                      <span className=\"text-xs text-muted-foreground\">\n                        {docCount} doc{docCount !== 1 ? \"s\" : \"\"}\n                      </span>\n                    </CommandItem>\n                  );\n                })}\n              </CommandGroup>\n            )}\n\n            {filteredDocuments.length > 0 && (\n              <CommandGroup heading=\"Documents\">\n                {filteredDocuments.map((doc) => {\n                  const isSelected = selectedItems.some(\n                    (item) =>\n                      item.type === \"document\" &&\n                      item.id === doc.dataroomDocumentId,\n                  );\n\n                  return (\n                    <CommandItem\n                      key={`doc-${doc.dataroomDocumentId}`}\n                      value={`doc-${doc.name}`}\n                      onSelect={() =>\n                        handleSelect(\n                          \"document\",\n                          doc.dataroomDocumentId,\n                          doc.name,\n                        )\n                      }\n                      className={cn(\n                        \"flex items-center gap-2\",\n                        isSelected && \"bg-accent\",\n                      )}\n                    >\n                      <FileTextIcon className=\"size-4 text-muted-foreground\" />\n                      <span className=\"flex-1 truncate\">{doc.name}</span>\n                    </CommandItem>\n                  );\n                })}\n              </CommandGroup>\n            )}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\n// ============================================================================\n// Selected Items Display Component\n// ============================================================================\n\ninterface SelectedContextItemsProps {\n  items: SelectedContextItem[];\n  onRemove: (type: \"document\" | \"folder\", id: string) => void;\n}\n\nexport function SelectedContextItems({\n  items,\n  onRemove,\n}: SelectedContextItemsProps) {\n  if (items.length === 0) return null;\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-2\">\n      {items.map((item) => (\n        <ContextItemAttachment\n          key={`${item.type}-${item.id}`}\n          item={item}\n          onRemove={() => onRemove(item.type, item.id)}\n        />\n      ))}\n    </div>\n  );\n}\n\n// ============================================================================\n// Context Item Attachment Component (matches PromptInputAttachment style)\n// ============================================================================\n\ninterface ContextItemAttachmentProps {\n  item: SelectedContextItem;\n  onRemove: () => void;\n  className?: string;\n}\n\nfunction ContextItemAttachment({\n  item,\n  onRemove,\n  className,\n}: ContextItemAttachmentProps) {\n  const Icon = item.type === \"folder\" ? FolderIcon : FileTextIcon;\n\n  return (\n    <TooltipProvider>\n      <Tooltip delayDuration={300}>\n        <TooltipTrigger asChild>\n          <div\n            className={cn(\n              \"group relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-md border border-border px-1.5 text-sm font-medium transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n              className,\n            )}\n          >\n            <div className=\"relative size-5 shrink-0\">\n              <div className=\"absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0\">\n                <Icon className=\"size-4 text-muted-foreground\" />\n              </div>\n              <Button\n                aria-label=\"Remove from context\"\n                className=\"absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-3\"\n                onClick={(e) => {\n                  e.preventDefault();\n                  e.stopPropagation();\n                  onRemove();\n                }}\n                type=\"button\"\n                variant=\"ghost\"\n              >\n                <XIcon />\n                <span className=\"sr-only\">Remove</span>\n              </Button>\n            </div>\n            <span className=\"max-w-[150px] flex-1 truncate\">{item.name}</span>\n          </div>\n        </TooltipTrigger>\n        <TooltipContent side=\"top\" className=\"max-w-[300px]\">\n          <p className=\"break-all\">{item.name}</p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n}\n\n// ============================================================================\n// Utility Export\n// ============================================================================\n\nexport { getDocumentsInFolder };\n"
  },
  {
    "path": "ee/features/ai/components/viewer-chat-panel.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\n\nimport {\n  AtSignIcon,\n  FileTextIcon,\n  Loader2,\n  MessageSquareTextIcon,\n  Plus,\n  PlusIcon,\n  SearchIcon,\n  ShieldCheckIcon,\n  SparklesIcon,\n  XIcon,\n} from \"lucide-react\";\n\nimport {\n  Conversation,\n  ConversationContent,\n  ConversationEmptyState,\n  ConversationScrollButton,\n} from \"@/components/ai-elements/conversation\";\nimport {\n  PromptInput,\n  PromptInputButton,\n  PromptInputFooter,\n  PromptInputHeader,\n  type PromptInputMessage,\n  PromptInputSubmit,\n  PromptInputTextarea,\n  PromptInputTools,\n} from \"@/components/ai-elements/prompt-input\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport type {\n  ChatStreamMetadata,\n  ChatStreamSource,\n} from \"../lib/chat/send-message\";\nimport {\n  CHAT_METADATA_PREFIX,\n  CHAT_METADATA_SUFFIX,\n} from \"../lib/chat/send-message\";\nimport { parseTextStream } from \"../lib/stream/parse-text-stream\";\nimport ChatMessage from \"./chat-message\";\nimport {\n  DocumentContextSelector,\n  SelectedContextItem,\n  SelectedContextItems,\n  getDocumentsInFolder,\n} from \"./document-context-selector\";\nimport { useViewerChatSafe } from \"./viewer-chat-provider\";\nimport { ViewerThreadSelector } from \"./viewer-thread-selector\";\n\ninterface Message {\n  id: string;\n  role: \"user\" | \"assistant\";\n  content: string;\n  isStreaming?: boolean;\n  sources?: ChatStreamSource[];\n  suggestedQuestions?: string[];\n}\n\nfunction extractStreamMetadata(content: string): {\n  text: string;\n  metadata?: ChatStreamMetadata;\n} {\n  const prefixIdx = content.indexOf(CHAT_METADATA_PREFIX);\n  if (prefixIdx === -1) return { text: content };\n\n  const jsonStart = prefixIdx + CHAT_METADATA_PREFIX.length;\n  const suffixIdx = content.indexOf(CHAT_METADATA_SUFFIX, jsonStart);\n  if (suffixIdx === -1) return { text: content };\n\n  try {\n    const jsonStr = content.slice(jsonStart, suffixIdx);\n    const metadata = JSON.parse(jsonStr) as ChatStreamMetadata;\n    const text = content.slice(0, prefixIdx).trim();\n    return { text, metadata };\n  } catch {\n    return { text: content };\n  }\n}\n\nconst REFERENCES_REGEX =\n  /\\n\\n(?:#{1,6}\\s*)?References\\s*\\n(?:\\s*\\n)?((?:[-*]\\s.*(?:\\n|$))+)$/i;\nconst REFERENCE_LINE_REGEX = /[-*]\\s*\\[(.+?)]\\((.+?)\\)(?:\\s*-\\s*p\\.\\s*(\\d+))?/;\n\ninterface ResolvedRef {\n  dataroomDocumentId: string;\n  documentName: string;\n  url: string;\n  page?: number;\n  folderPath?: string;\n}\n\nfunction parseMarkdownReferences(content: string): {\n  text: string;\n  sources?: ChatStreamSource[];\n} {\n  const match = content.match(REFERENCES_REGEX);\n  if (!match) return { text: content };\n\n  const text = content.replace(REFERENCES_REGEX, \"\").trim();\n  const lines = match[1].trim().split(\"\\n\").filter(Boolean);\n  const sources: ChatStreamSource[] = [];\n\n  for (let i = 0; i < lines.length; i++) {\n    const lineMatch = lines[i].match(REFERENCE_LINE_REGEX);\n    if (lineMatch) {\n      sources.push({\n        id: `D-${i + 1}`,\n        name: lineMatch[1].replace(/\\\\([\\[\\]])/g, \"$1\"),\n        url: lineMatch[2],\n        page: lineMatch[3] ? parseInt(lineMatch[3]) : undefined,\n      });\n    }\n  }\n\n  return { text, sources: sources.length > 0 ? sources : undefined };\n}\n\ninterface ViewerChatPanelProps {\n  className?: string;\n}\n\n/**\n * Standalone chat panel that reads configuration from ViewerChatProvider.\n * Place this anywhere in the component tree within ViewerChatProvider.\n * It will render as a fixed panel on the right side when open.\n */\nexport function ViewerChatPanel({ className }: ViewerChatPanelProps) {\n  const context = useViewerChatSafe();\n\n  // Don't render if not in provider or not enabled\n  if (!context || !context.isEnabled) {\n    return null;\n  }\n\n  // Don't render if closed\n  if (!context.isOpen) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed bottom-0 right-0 top-16 z-30 w-[400px] shadow-xl\">\n      <ViewerChatPanelContent\n        onClose={context.close}\n        dataroomId={context.config.dataroomId}\n        dataroomName={context.config.dataroomName}\n        documentId={context.config.documentId}\n        documentName={context.config.documentName}\n        linkId={context.config.linkId}\n        viewId={context.config.viewId}\n        viewerId={context.config.viewerId}\n        documents={context.documents}\n        folders={context.folders}\n      />\n    </div>\n  );\n}\n\n// ============================================================================\n// Internal Content Component\n// ============================================================================\n\ninterface ViewerChatPanelContentProps {\n  onClose: () => void;\n  dataroomId?: string;\n  dataroomName?: string;\n  documentId?: string;\n  documentName?: string;\n  linkId?: string;\n  viewId?: string;\n  viewerId?: string;\n  documents?: Array<{\n    dataroomDocumentId: string;\n    id: string;\n    name: string;\n    folderId: string | null;\n  }>;\n  folders?: Array<{\n    id: string;\n    name: string;\n    parentId: string | null;\n    path: string;\n    orderIndex: number | null;\n    dataroomId: string;\n    createdAt: Date;\n    updatedAt: Date;\n    hierarchicalIndex: string | null;\n    icon: string | null;\n    color: string | null;\n  }>;\n}\n\nfunction ViewerChatPanelContent({\n  onClose,\n  dataroomId,\n  dataroomName,\n  documentId,\n  documentName,\n  linkId,\n  viewId,\n  viewerId,\n  documents = [],\n  folders = [],\n}: ViewerChatPanelContentProps) {\n  const [messages, setMessages] = useState<Message[]>([]);\n  const [isLoading, setIsLoading] = useState(false);\n  const [chatId, setChatId] = useState<string | null>(null);\n  const [chatTitle, setChatTitle] = useState<string | undefined>();\n  const [selectedContextItems, setSelectedContextItems] = useState<\n    SelectedContextItem[]\n  >([]);\n  const [selectorOpen, setSelectorOpen] = useState(false);\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  const contextName = dataroomName || documentName || \"Document\";\n\n  // Handle abort/stop\n  const handleAbort = useCallback(() => {\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort();\n      abortControllerRef.current = null;\n    }\n    setIsLoading(false);\n    // Mark any streaming message as complete\n    setMessages((prev) =>\n      prev.map((m) =>\n        m.isStreaming\n          ? {\n              ...m,\n              isStreaming: false,\n              content: m.content || \"Message cancelled.\",\n            }\n          : m,\n      ),\n    );\n  }, []);\n\n  // Calculate selected document IDs from context items (expanding folders)\n  const getSelectedDocumentIds = useCallback((): string[] => {\n    if (selectedContextItems.length === 0) return [];\n\n    const docIds = new Set<string>();\n\n    for (const item of selectedContextItems) {\n      if (item.type === \"document\") {\n        docIds.add(item.id);\n      } else {\n        // For folders, get all documents in the folder\n        const docsInFolder = getDocumentsInFolder(item.id, documents, folders);\n        docsInFolder.forEach((doc) => docIds.add(doc.dataroomDocumentId));\n      }\n    }\n\n    return Array.from(docIds);\n  }, [selectedContextItems, documents, folders]);\n\n  // Handle removing a context item\n  const handleRemoveContextItem = useCallback(\n    (type: \"document\" | \"folder\", id: string) => {\n      setSelectedContextItems((prev) =>\n        prev.filter((item) => !(item.type === type && item.id === id)),\n      );\n    },\n    [],\n  );\n\n  // Create chat session\n  const createChat = async () => {\n    try {\n      const response = await fetch(`/api/ai/chat`, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          documentId,\n          dataroomId,\n          linkId,\n          viewId,\n          viewerId,\n        }),\n      });\n\n      if (!response.ok) throw new Error(\"Failed to create chat\");\n\n      const data = await response.json();\n      setChatId(data.id);\n      setChatTitle(undefined);\n      return data.id;\n    } catch (error) {\n      console.error(\"Error creating chat:\", error);\n      return null;\n    }\n  };\n\n  // Load existing chat\n  const loadChat = useCallback(\n    async (selectedChatId: string) => {\n      try {\n        setIsLoading(true);\n        const queryParams = viewerId ? `?viewerId=${viewerId}` : \"\";\n\n        const response = await fetch(\n          `/api/ai/chat/${selectedChatId}${queryParams}`,\n        );\n\n        if (!response.ok) throw new Error(\"Failed to load chat\");\n\n        const data = await response.json();\n        setChatId(selectedChatId);\n        setChatTitle(data.title);\n\n        const loadedMessages: Message[] = data.messages.map((msg: any) => {\n          if (msg.role === \"assistant\") {\n            const meta = msg.metadata as Record<string, any> | null;\n            const storedSuggestions = meta?.suggestedQuestions as\n              | string[]\n              | undefined;\n            const storedRefs = meta?.references as ResolvedRef[] | undefined;\n\n            if (storedRefs && storedRefs.length > 0) {\n              const { text } = parseMarkdownReferences(msg.content);\n              return {\n                id: msg.id,\n                role: msg.role,\n                content: text,\n                sources: storedRefs.map((ref: ResolvedRef, idx: number) => ({\n                  id: `D-${idx + 1}`,\n                  name: ref.documentName,\n                  url: ref.url,\n                  dataroomDocumentId: ref.dataroomDocumentId,\n                  page: ref.page,\n                  folderPath: ref.folderPath,\n                })),\n                suggestedQuestions: storedSuggestions,\n              };\n            }\n\n            const { text, sources } = parseMarkdownReferences(msg.content);\n            return {\n              id: msg.id,\n              role: msg.role,\n              content: text,\n              sources,\n              suggestedQuestions: storedSuggestions,\n            };\n          }\n          return { id: msg.id, role: msg.role, content: msg.content };\n        });\n\n        setMessages(loadedMessages);\n      } catch (error) {\n        console.error(\"Error loading chat:\", error);\n      } finally {\n        setIsLoading(false);\n      }\n    },\n    [viewerId],\n  );\n\n  // Start new chat\n  const handleNewChat = useCallback(() => {\n    setChatId(null);\n    setChatTitle(undefined);\n    setMessages([]);\n  }, []);\n\n  // Delete chat\n  const handleDeleteChat = useCallback(\n    async (deleteChatId: string) => {\n      try {\n        const queryParams = viewerId ? `?viewerId=${viewerId}` : \"\";\n        await fetch(`/api/ai/chat/${deleteChatId}${queryParams}`, {\n          method: \"DELETE\",\n        });\n\n        // If deleted current chat, start new one\n        if (deleteChatId === chatId) {\n          handleNewChat();\n        }\n      } catch (error) {\n        console.error(\"Error deleting chat:\", error);\n      }\n    },\n    [chatId, viewerId, handleNewChat],\n  );\n\n  const handleSubmit = async (message: PromptInputMessage) => {\n    if (!message.text.trim() || isLoading) return;\n\n    const userMessage: Message = {\n      id: `user-${Date.now()}`,\n      role: \"user\",\n      content: message.text.trim(),\n    };\n\n    setMessages((prev) => [...prev, userMessage]);\n    setIsLoading(true);\n\n    // Create abort controller for this request\n    abortControllerRef.current = new AbortController();\n\n    try {\n      // Create chat if needed\n      let currentChatId = chatId;\n      if (!currentChatId) {\n        currentChatId = await createChat();\n        if (!currentChatId) {\n          setIsLoading(false);\n          return;\n        }\n      }\n\n      const queryParams = viewerId ? `?viewerId=${viewerId}` : \"\";\n\n      // Get selected document IDs for filtering\n      const filterDataroomDocumentIds = getSelectedDocumentIds();\n\n      const response = await fetch(\n        `/api/ai/chat/${currentChatId}/messages${queryParams}`,\n        {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify({\n            content: userMessage.content,\n            // When viewing a specific document in a dataroom, filter search to that document\n            filterDocumentId: documentId,\n            // User-selected context filter (only if items are selected)\n            ...(filterDataroomDocumentIds.length > 0 && {\n              filterDataroomDocumentIds,\n            }),\n          }),\n          signal: abortControllerRef.current.signal,\n        },\n      );\n\n      if (!response.ok) throw new Error(\"Failed to send message\");\n\n      // Get the reader for streaming\n      const reader = response.body?.getReader();\n      if (!reader) throw new Error(\"No response body\");\n\n      // Create placeholder for assistant message\n      const assistantMessageId = `assistant-${Date.now()}`;\n      const assistantMessage: Message = {\n        id: assistantMessageId,\n        role: \"assistant\",\n        content: \"\",\n        isStreaming: true,\n      };\n      setMessages((prev) => [...prev, assistantMessage]);\n\n      await parseTextStream(reader, {\n        onTextDelta: (_delta, accumulated) => {\n          const { text: visibleText } = extractStreamMetadata(accumulated);\n          setMessages((prev) =>\n            prev.map((m) =>\n              m.id === assistantMessageId ? { ...m, content: visibleText } : m,\n            ),\n          );\n        },\n        onTextEnd: (rawContent) => {\n          const { text, metadata } = extractStreamMetadata(rawContent);\n          setMessages((prev) =>\n            prev.map((m) =>\n              m.id === assistantMessageId\n                ? {\n                    ...m,\n                    content: text,\n                    isStreaming: false,\n                    sources: metadata?.sources,\n                    suggestedQuestions: metadata?.suggestedQuestions,\n                  }\n                : m,\n            ),\n          );\n        },\n        onError: (error) => {\n          console.error(\"Stream error:\", error);\n          setMessages((prev) =>\n            prev.map((m) =>\n              m.id === assistantMessageId\n                ? {\n                    ...m,\n                    content:\n                      \"Sorry, there was an error processing your request.\",\n                    isStreaming: false,\n                  }\n                : m,\n            ),\n          );\n        },\n      });\n    } catch (error) {\n      // Don't show error if aborted\n      if (error instanceof Error && error.name === \"AbortError\") {\n        return;\n      }\n      console.error(\"Error sending message:\", error);\n      setMessages((prev) => [\n        ...prev,\n        {\n          id: `error-${Date.now()}`,\n          role: \"assistant\",\n          content: \"Sorry, there was an error sending your message.\",\n        },\n      ]);\n    } finally {\n      setIsLoading(false);\n      abortControllerRef.current = null;\n    }\n  };\n\n  const handleSuggestedQuestion = (question: string) => {\n    if (isLoading) return;\n    handleSubmit({ text: question, files: [] });\n  };\n\n  return (\n    <div className=\"flex h-full w-full flex-col border-l border-gray-200 bg-white\">\n      {/* Header with Thread Selector and New Chat Button */}\n      <div className=\"flex items-center justify-between border-b border-gray-200 px-3 py-2\">\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          <ViewerThreadSelector\n            currentChatId={chatId}\n            currentChatTitle={chatTitle}\n            onSelectChat={loadChat}\n            onNewChat={handleNewChat}\n            onDeleteChat={handleDeleteChat}\n            documentId={documentId}\n            dataroomId={dataroomId}\n            linkId={linkId}\n            viewerId={viewerId}\n            viewId={viewId}\n          />\n        </div>\n        <div className=\"flex items-center gap-1\">\n          <TooltipProvider>\n            <Tooltip>\n              <TooltipTrigger asChild>\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  onClick={handleNewChat}\n                  className=\"size-8 p-0 text-gray-500 hover:bg-gray-100 hover:text-gray-900\"\n                >\n                  <Plus className=\"size-4\" />\n                </Button>\n              </TooltipTrigger>\n              <TooltipContent>New Chat</TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={onClose}\n            className=\"size-8 p-0 text-gray-500 hover:bg-gray-100 hover:text-gray-900\"\n          >\n            <XIcon className=\"size-4\" />\n          </Button>\n        </div>\n      </div>\n\n      {/* Context Name */}\n      <div className=\"border-b border-gray-100 bg-gray-50/50 px-4 py-1.5\">\n        <p className=\"truncate text-xs text-gray-500\">{contextName}</p>\n      </div>\n\n      {/* Messages */}\n      <Conversation className=\"flex-1\">\n        <ConversationContent className=\"gap-4 p-4\">\n          {messages.length === 0 ? (\n            <ConversationEmptyState className=\"items-start text-left\">\n              <div className=\"flex w-full flex-col items-start gap-5 px-2\">\n                <div>\n                  <div className=\"flex items-center gap-2\">\n                    <div className=\"flex size-8 items-center justify-center rounded-full bg-primary/10\">\n                      <SparklesIcon className=\"size-4 text-primary\" />\n                    </div>\n                    <h3 className=\"text-base font-semibold text-gray-900\">\n                      Welcome\n                    </h3>\n                  </div>\n                  <p className=\"mt-1 text-sm text-gray-500\">\n                    Your AI Data Room Agent is ready to help.\n                  </p>\n                </div>\n\n                <div className=\"w-full space-y-3\">\n                  <div className=\"flex items-start gap-2.5\">\n                    <SearchIcon className=\"mt-0.5 size-3.5 shrink-0 text-gray-400\" />\n                    <p className=\"text-xs leading-relaxed text-gray-500\">\n                      Get answers to questions about your{\" \"}\n                      {dataroomId ? \"data room\" : \"document\"} files. Open a\n                      specific document to ask targeted questions.\n                    </p>\n                  </div>\n                  <div className=\"flex items-start gap-2.5\">\n                    <MessageSquareTextIcon className=\"mt-0.5 size-3.5 shrink-0 text-gray-400\" />\n                    <p className=\"text-xs leading-relaxed text-gray-500\">\n                      Ask clear, specific questions for more accurate results.\n                      Multiple languages and document types are supported.\n                    </p>\n                  </div>\n                  <div className=\"flex items-start gap-2.5\">\n                    <ShieldCheckIcon className=\"mt-0.5 size-3.5 shrink-0 text-gray-400\" />\n                    <p className=\"text-xs leading-relaxed text-gray-500\">\n                      All conversations are private and confidential.\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </ConversationEmptyState>\n          ) : (\n            <>\n              {(() => {\n                const lastAssistantIdx = messages.reduce(\n                  (acc, m, i) => (m.role === \"assistant\" ? i : acc),\n                  -1,\n                );\n                return messages.map((message, idx) => (\n                  <div key={message.id} className=\"space-y-2\">\n                    <ChatMessage\n                      role={message.role}\n                      content={message.content}\n                      isStreaming={message.isStreaming}\n                      sources={message.sources}\n                      suggestedQuestions={message.suggestedQuestions}\n                      onSuggestedQuestionClick={handleSuggestedQuestion}\n                      isLastAssistantMessage={\n                        message.role === \"assistant\" &&\n                        idx === lastAssistantIdx &&\n                        !isLoading\n                      }\n                    />\n                  </div>\n                ));\n              })()}\n\n              {isLoading && messages[messages.length - 1]?.role === \"user\" && (\n                <div className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n                  <Loader2 className=\"size-4 animate-spin\" />\n                  <span>Thinking...</span>\n                </div>\n              )}\n            </>\n          )}\n        </ConversationContent>\n        <ConversationScrollButton />\n      </Conversation>\n\n      {/* Input */}\n      <div className=\"relative border-t border-gray-200 p-3\">\n        {/* Document Context Selector (@ mention popover) */}\n        {dataroomId && documents.length > 0 && (\n          <DocumentContextSelector\n            documents={documents}\n            folders={folders}\n            selectedItems={selectedContextItems}\n            onSelectionChange={setSelectedContextItems}\n            open={selectorOpen}\n            onOpenChange={setSelectorOpen}\n          />\n        )}\n\n        <PromptInput onSubmit={handleSubmit}>\n          {/* Show focused document or selected context items */}\n          {(documentId && documentName) || selectedContextItems.length > 0 ? (\n            <PromptInputHeader>\n              {documentId && documentName ? (\n                <TooltipProvider>\n                  <Tooltip delayDuration={300}>\n                    <TooltipTrigger asChild>\n                      <div className=\"relative flex h-8 select-none items-center gap-1.5 rounded-md border border-border bg-muted/50 px-1.5 text-sm font-medium text-muted-foreground transition-all dark:hover:bg-accent/50\">\n                        <div className=\"relative size-5 shrink-0\">\n                          <div className=\"absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background\">\n                            <FileTextIcon className=\"size-4 text-muted-foreground\" />\n                          </div>\n                        </div>\n                        <span className=\"max-w-[150px] flex-1 truncate\">\n                          {documentName}\n                        </span>\n                      </div>\n                    </TooltipTrigger>\n                    <TooltipContent side=\"top\" className=\"max-w-[300px]\">\n                      <p className=\"break-all\">{documentName}</p>\n                    </TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              ) : (\n                <SelectedContextItems\n                  items={selectedContextItems}\n                  onRemove={handleRemoveContextItem}\n                />\n              )}\n            </PromptInputHeader>\n          ) : null}\n          <PromptInputTextarea\n            placeholder={\n              dataroomId && documents.length > 0\n                ? \"Ask a question... (click + to add context)\"\n                : \"Ask a question...\"\n            }\n            disabled={isLoading}\n            className=\"min-h-12\"\n          />\n          <PromptInputFooter className=\"pt-2\">\n            {/* @ button to open document selector */}\n            {dataroomId && documents.length > 0 && (\n              <PromptInputTools>\n                <TooltipProvider>\n                  <Tooltip>\n                    <TooltipTrigger asChild>\n                      <PromptInputButton\n                        onClick={() => setSelectorOpen(true)}\n                        disabled={isLoading}\n                      >\n                        <PlusIcon className=\"size-4\" />\n                      </PromptInputButton>\n                    </TooltipTrigger>\n                    <TooltipContent>Add document context</TooltipContent>\n                  </Tooltip>\n                </TooltipProvider>\n              </PromptInputTools>\n            )}\n            <div className=\"flex-1\" />\n            {isLoading ? (\n              <PromptInputSubmit onClick={handleAbort} status=\"streaming\" />\n            ) : (\n              <PromptInputSubmit status=\"ready\" />\n            )}\n          </PromptInputFooter>\n        </PromptInput>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/ai/components/viewer-chat-provider.tsx",
    "content": "\"use client\";\n\nimport {\n  type ReactNode,\n  createContext,\n  useCallback,\n  useContext,\n  useState,\n} from \"react\";\n\nimport { DataroomFolder } from \"@prisma/client\";\n\nimport { cn } from \"@/lib/utils\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface DataroomDocumentForChat {\n  dataroomDocumentId: string;\n  id: string;\n  name: string;\n  folderId: string | null;\n}\n\ninterface ViewerChatConfig {\n  dataroomId?: string;\n  dataroomName?: string;\n  documentId?: string;\n  documentName?: string;\n  linkId?: string;\n  viewId?: string;\n  viewerId?: string;\n}\n\ninterface ViewerChatContextType {\n  // State\n  isOpen: boolean;\n  isEnabled: boolean;\n  config: ViewerChatConfig;\n  documents: DataroomDocumentForChat[];\n  folders: DataroomFolder[];\n\n  // Actions\n  open: () => void;\n  close: () => void;\n  toggle: () => void;\n}\n\n// ============================================================================\n// Context\n// ============================================================================\n\nconst ViewerChatContext = createContext<ViewerChatContextType | null>(null);\n\n/**\n * Hook to access chat state and actions.\n * Throws if used outside ViewerChatProvider.\n */\nexport function useViewerChat() {\n  const context = useContext(ViewerChatContext);\n  if (!context) {\n    throw new Error(\"useViewerChat must be used within ViewerChatProvider\");\n  }\n  return context;\n}\n\n/**\n * Safe hook that returns null if not within ViewerChatProvider.\n * Useful for components that may or may not be within a chat context.\n */\nexport function useViewerChatSafe() {\n  return useContext(ViewerChatContext);\n}\n\n// ============================================================================\n// Provider\n// ============================================================================\n\ninterface ViewerChatProviderProps {\n  children: ReactNode;\n  enabled?: boolean;\n  dataroomId?: string;\n  dataroomName?: string;\n  documentId?: string;\n  documentName?: string;\n  linkId?: string;\n  viewId?: string;\n  viewerId?: string;\n  documents?: DataroomDocumentForChat[];\n  folders?: DataroomFolder[];\n}\n\nexport function ViewerChatProvider({\n  children,\n  enabled = false,\n  dataroomId,\n  dataroomName,\n  documentId,\n  documentName,\n  linkId,\n  viewId,\n  viewerId,\n  documents = [],\n  folders = [],\n}: ViewerChatProviderProps) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const open = useCallback(() => setIsOpen(true), []);\n  const close = useCallback(() => setIsOpen(false), []);\n  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);\n\n  const config: ViewerChatConfig = {\n    dataroomId,\n    dataroomName,\n    documentId,\n    documentName,\n    linkId,\n    viewId,\n    viewerId,\n  };\n\n  const value: ViewerChatContextType = {\n    isOpen,\n    isEnabled: enabled,\n    config,\n    documents,\n    folders,\n    open,\n    close,\n    toggle,\n  };\n\n  return (\n    <ViewerChatContext.Provider value={value}>\n      {children}\n    </ViewerChatContext.Provider>\n  );\n}\n\n// ============================================================================\n// Layout Component - Applies padding when chat is open\n// ============================================================================\n\ninterface ViewerChatLayoutProps {\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * Layout wrapper that applies padding when chat panel is open.\n * Use this to wrap content that should shrink when chat opens.\n */\nexport function ViewerChatLayout({\n  children,\n  className,\n}: ViewerChatLayoutProps) {\n  const context = useViewerChatSafe();\n\n  // If not in provider or not enabled, just render children\n  if (!context || !context.isEnabled) {\n    return <>{children}</>;\n  }\n\n  return (\n    <div\n      className={cn(\n        \"transition-all duration-300\",\n        context.isOpen && \"pr-[400px]\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/ai/components/viewer-chat-toggle.tsx",
    "content": "\"use client\";\n\nimport { MessageSquare } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { useViewerChatSafe } from \"./viewer-chat-provider\";\n\ninterface ViewerChatToggleProps {\n  className?: string;\n}\n\n/**\n * Floating toggle button for the chat panel.\n * Only renders when chat is enabled and closed.\n * Place this anywhere in the component tree within ViewerChatProvider.\n */\nexport function ViewerChatToggle({ className }: ViewerChatToggleProps) {\n  const context = useViewerChatSafe();\n\n  // Don't render if not in provider, not enabled, or already open\n  if (!context || !context.isEnabled || context.isOpen) {\n    return null;\n  }\n\n  return (\n    <Button\n      onClick={context.open}\n      className=\"fixed bottom-4 right-4 z-40 gap-2 rounded-full shadow-lg\"\n      size=\"lg\"\n    >\n      <MessageSquare className=\"size-5\" />\n      AI Chat\n    </Button>\n  );\n}\n\n"
  },
  {
    "path": "ee/features/ai/components/viewer-thread-selector.tsx",
    "content": "\"use client\";\n\nimport { useMemo } from \"react\";\n\nimport {\n  differenceInDays,\n  format,\n  isThisWeek,\n  isToday,\n  startOfDay,\n} from \"date-fns\";\nimport {\n  ChevronDown,\n  Loader2,\n  MessageSquare,\n  Plus,\n  Trash2,\n} from \"lucide-react\";\nimport useSWR from \"swr\";\n\nimport { cn, fetcher } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\ninterface Chat {\n  id: string;\n  title: string | null;\n  createdAt: string;\n  lastMessageAt: string | null;\n  messages?: { content: string }[];\n}\n\ninterface GroupedChats {\n  today: Chat[];\n  thisWeek: Chat[];\n  older: Chat[];\n}\n\ninterface ViewerThreadSelectorProps {\n  currentChatId: string | null;\n  currentChatTitle?: string;\n  onSelectChat: (chatId: string) => void;\n  onNewChat: () => void;\n  onDeleteChat?: (chatId: string) => void;\n  documentId?: string;\n  dataroomId?: string;\n  linkId?: string;\n  viewerId?: string;\n  viewId?: string;\n}\n\nfunction groupChatsByDate(chats: Chat[]): GroupedChats {\n  const grouped: GroupedChats = {\n    today: [],\n    thisWeek: [],\n    older: [],\n  };\n\n  const now = new Date();\n\n  chats.forEach((chat) => {\n    const chatDate = new Date(chat.lastMessageAt || chat.createdAt);\n\n    if (isToday(chatDate)) {\n      grouped.today.push(chat);\n    } else if (isThisWeek(chatDate, { weekStartsOn: 1 })) {\n      grouped.thisWeek.push(chat);\n    } else {\n      grouped.older.push(chat);\n    }\n  });\n\n  return grouped;\n}\n\nfunction getChatDisplayTitle(chat: Chat): string {\n  if (chat.title) return chat.title;\n  if (chat.messages?.[0]?.content) {\n    const preview = chat.messages[0].content.slice(0, 40);\n    return preview.length < chat.messages[0].content.length\n      ? `${preview}...`\n      : preview;\n  }\n  return \"New Chat\";\n}\n\nexport function ViewerThreadSelector({\n  currentChatId,\n  currentChatTitle,\n  onSelectChat,\n  onNewChat,\n  onDeleteChat,\n  documentId,\n  dataroomId,\n  linkId,\n  viewerId,\n  viewId,\n}: ViewerThreadSelectorProps) {\n  // Build query params for viewer-based chat listing\n  const params = new URLSearchParams();\n  if (viewerId) params.append(\"viewerId\", viewerId);\n  if (documentId) params.append(\"documentId\", documentId);\n  if (dataroomId) params.append(\"dataroomId\", dataroomId);\n  if (linkId) params.append(\"linkId\", linkId);\n  if (viewId) params.append(\"viewId\", viewId);\n\n  const shouldFetch = viewerId && (documentId || dataroomId);\n\n  const {\n    data: chats,\n    isLoading,\n    mutate,\n  } = useSWR<Chat[]>(\n    shouldFetch ? `/api/ai/chat?${params.toString()}` : null,\n    fetcher,\n  );\n\n  const groupedChats = useMemo(() => {\n    if (!chats) return { today: [], thisWeek: [], older: [] };\n    return groupChatsByDate(chats);\n  }, [chats]);\n\n  const currentChat = chats?.find((chat) => chat.id === currentChatId);\n  const displayTitle =\n    currentChatTitle ||\n    getChatDisplayTitle(currentChat || ({} as Chat)) ||\n    \"New Chat\";\n\n  const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => {\n    e.stopPropagation();\n    if (onDeleteChat) {\n      onDeleteChat(chatId);\n    }\n    // If deleting current chat, trigger new chat\n    if (chatId === currentChatId) {\n      onNewChat();\n    }\n    // Optimistically update the list\n    mutate(\n      chats?.filter((c) => c.id !== chatId),\n      false,\n    );\n  };\n\n  const renderChatItem = (chat: Chat) => (\n    <DropdownMenuItem\n      key={chat.id}\n      onClick={() => onSelectChat(chat.id)}\n      className={cn(\n        \"group flex cursor-pointer items-center justify-between gap-2\",\n        chat.id === currentChatId && \"bg-gray-100\",\n      )}\n    >\n      <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <p className=\"truncate text-sm font-medium\">\n          {getChatDisplayTitle(chat)}\n        </p>\n        <p className=\"text-xs text-gray-500\">\n          {format(\n            new Date(chat.lastMessageAt || chat.createdAt),\n            \"MMM d, h:mm a\",\n          )}\n        </p>\n      </div>\n      {onDeleteChat && (\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"size-6 p-0 opacity-0 group-hover:opacity-100\"\n          onClick={(e) => handleDeleteChat(chat.id, e)}\n        >\n          <Trash2 className=\"size-3.5 text-gray-500 hover:text-red-500\" />\n        </Button>\n      )}\n    </DropdownMenuItem>\n  );\n\n  const hasChats =\n    groupedChats.today.length > 0 ||\n    groupedChats.thisWeek.length > 0 ||\n    groupedChats.older.length > 0;\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"h-8 max-w-[180px] gap-1.5 px-2 hover:bg-gray-100\"\n        >\n          <MessageSquare className=\"size-4 shrink-0\" />\n          <span className=\"truncate text-sm font-medium\">{displayTitle}</span>\n          <ChevronDown className=\"size-3 shrink-0 opacity-60\" />\n        </Button>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent align=\"start\" className=\"w-full\">\n        <div className=\"max-h-[400px] overflow-y-auto\">\n          {hasChats && !isLoading ? (\n            <>\n              {/* Today */}\n              {groupedChats.today.length > 0 && (\n                <>\n                  <DropdownMenuLabel className=\"text-xs font-medium text-gray-500\">\n                    Today\n                  </DropdownMenuLabel>\n                  {groupedChats.today.map(renderChatItem)}\n                </>\n              )}\n\n              {/* This Week */}\n              {groupedChats.thisWeek.length > 0 && (\n                <>\n                  {groupedChats.today.length > 0 && <DropdownMenuSeparator />}\n                  <DropdownMenuLabel className=\"text-xs font-medium text-gray-500\">\n                    This Week\n                  </DropdownMenuLabel>\n                  {groupedChats.thisWeek.map(renderChatItem)}\n                </>\n              )}\n\n              {/* Older */}\n              {groupedChats.older.length > 0 && (\n                <>\n                  {(groupedChats.today.length > 0 ||\n                    groupedChats.thisWeek.length > 0) && (\n                    <DropdownMenuSeparator />\n                  )}\n                  <DropdownMenuLabel className=\"text-xs font-medium text-gray-500\">\n                    Older\n                  </DropdownMenuLabel>\n                  {groupedChats.older.map(renderChatItem)}\n                </>\n              )}\n            </>\n          ) : isLoading ? (\n            <div className=\"flex items-center gap-2 p-4\">\n              <Loader2 className=\"size-4 animate-spin\" />\n              <span className=\"text-sm text-gray-500\">\n                Loading chat history...\n              </span>\n            </div>\n          ) : (\n            <div className=\"px-2 py-4 text-center text-sm text-gray-500\">\n              No chat history yet\n            </div>\n          )}\n        </div>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "ee/features/ai/hooks/use-ai-indexing-status.ts",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport type { AIProcessingMetadata, AIProcessingStatus } from \"../lib/trigger/types\";\n\ninterface UseAIIndexingStatusOptions {\n  runId: string | null;\n  pollInterval?: number;\n}\n\ninterface UseAIIndexingStatusReturn {\n  isProcessing: boolean;\n  isCompleted: boolean;\n  isFailed: boolean;\n  status: AIProcessingStatus | undefined;\n  step: string | undefined;\n  progress: number;\n  error: string | undefined;\n  vectorStoreFileId: string | undefined;\n  fileId: string | undefined;\n  documentName: string | undefined;\n}\n\ninterface RunStatusResponse {\n  id: string;\n  status: string;\n  metadata?: AIProcessingMetadata;\n  isCompleted: boolean;\n  isFailed: boolean;\n  output?: {\n    vectorStoreFileId?: string;\n    fileId?: string;\n  };\n}\n\n/**\n * Custom hook for tracking AI indexing status via polling\n * Polls the run status API endpoint at regular intervals\n */\nexport function useAIIndexingStatus({\n  runId,\n  pollInterval = 2000,\n}: UseAIIndexingStatusOptions): UseAIIndexingStatusReturn {\n  const [runStatus, setRunStatus] = useState<RunStatusResponse | null>(null);\n  const [error, setError] = useState<string | undefined>();\n\n  const fetchStatus = useCallback(async () => {\n    if (!runId) return;\n\n    try {\n      const response = await fetch(`/api/ai/store/runs/${runId}`);\n      if (!response.ok) {\n        throw new Error(\"Failed to fetch run status\");\n      }\n      const data: RunStatusResponse = await response.json();\n      setRunStatus(data);\n    } catch (err) {\n      setError(err instanceof Error ? err.message : \"Failed to fetch status\");\n    }\n  }, [runId]);\n\n  useEffect(() => {\n    if (!runId) return;\n\n    // Initial fetch\n    fetchStatus();\n\n    // Poll while processing\n    const interval = setInterval(() => {\n      if (runStatus?.isCompleted || runStatus?.isFailed) {\n        clearInterval(interval);\n        return;\n      }\n      fetchStatus();\n    }, pollInterval);\n\n    return () => clearInterval(interval);\n  }, [runId, fetchStatus, pollInterval, runStatus?.isCompleted, runStatus?.isFailed]);\n\n  const metadata = runStatus?.metadata;\n\n  return {\n    isProcessing: runStatus?.status === \"EXECUTING\",\n    isCompleted: runStatus?.isCompleted ?? false,\n    isFailed: (runStatus?.isFailed ?? false) || metadata?.status === \"failed\",\n    status: metadata?.status,\n    step: metadata?.step,\n    progress: metadata?.progress ?? 0,\n    error: metadata?.error || error,\n    vectorStoreFileId: metadata?.vectorStoreFileId || runStatus?.output?.vectorStoreFileId,\n    fileId: metadata?.fileId || runStatus?.output?.fileId,\n    documentName: metadata?.documentName,\n  };\n}\n\n/**\n * Hook for tracking multiple AI indexing runs (for dataroom batch processing)\n */\nexport function useAIIndexingBatchStatus() {\n  // For batch tracking, we track individual statuses\n  // The component using this should map over runs and call useAIIndexingStatus for each\n  // This is a helper to calculate aggregate stats\n\n  const calculateAggregateStats = (\n    statuses: UseAIIndexingStatusReturn[],\n  ) => {\n    const total = statuses.length;\n    const completed = statuses.filter((s) => s.isCompleted).length;\n    const failed = statuses.filter((s) => s.isFailed).length;\n    const processing = statuses.filter((s) => s.isProcessing).length;\n    const pending = total - completed - failed - processing;\n\n    return {\n      total,\n      completed,\n      failed,\n      processing,\n      pending,\n      progress: total > 0 ? Math.round((completed / total) * 100) : 0,\n      isAllCompleted: completed === total,\n      hasErrors: failed > 0,\n    };\n  };\n\n  return { calculateAggregateStats };\n}\n"
  },
  {
    "path": "ee/features/ai/lib/chat/create-chat.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\ninterface CreateChatOptions {\n  teamId: string;\n  documentId?: string;\n  dataroomId?: string;\n  linkId?: string;\n  viewId?: string;\n  userId?: string;\n  viewerId?: string;\n  vectorStoreId?: string;\n  title?: string;\n}\n\n/**\n * Create a new chat session\n * @param options - Chat creation options\n * @returns The created chat\n */\nexport async function createChat(options: CreateChatOptions) {\n  const {\n    teamId,\n    documentId,\n    dataroomId,\n    linkId,\n    viewId,\n    userId,\n    viewerId,\n    vectorStoreId,\n    title,\n  } = options;\n\n  try {\n    const chat = await prisma.chat.create({\n      data: {\n        teamId,\n        documentId,\n        dataroomId,\n        linkId,\n        viewId,\n        userId,\n        viewerId,\n        vectorStoreId,\n        title,\n      },\n      include: {\n        messages: true,\n      },\n    });\n\n    return chat;\n  } catch (error) {\n    console.error(\"Error creating chat:\", error);\n    throw new Error(\"Failed to create chat\");\n  }\n}\n\n"
  },
  {
    "path": "ee/features/ai/lib/chat/generate-chat-title.ts",
    "content": "import { openai } from \"@ai-sdk/openai\";\nimport { generateText } from \"ai\";\n\n/**\n * Generate a chat title from the first message\n * @param firstMessage - The first user message\n * @returns Generated title\n */\nexport async function generateChatTitle(firstMessage: string): Promise<string> {\n  try {\n    // Limit message length for title generation\n    const truncatedMessage = firstMessage.slice(0, 200);\n\n    const { text } = await generateText({\n      model: openai(\"gpt-4o-mini\"),\n      prompt: `Generate a short, descriptive title (max 6 words) for a chat that starts with this message: \"${truncatedMessage}\". Only return the title, nothing else.`,\n      providerOptions: {\n        openai: {\n          maxOutputTokens: 20,\n        },\n      },\n    });\n\n    // Clean up the title\n    const title = text\n      .trim()\n      .replace(/^[\"']|[\"']$/g, \"\") // Remove quotes\n      .slice(0, 60); // Max 60 chars\n\n    return title;\n  } catch (error) {\n    console.error(\"Error generating chat title:\", error);\n    // Return a default title based on first few words\n    return firstMessage.slice(0, 50) + (firstMessage.length > 50 ? \"...\" : \"\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/chat/get-filtered-dataroom-document-ids.ts",
    "content": "import { ItemType } from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\n\n/**\n * Get filtered dataroom document IDs based on link permissions\n * @param dataroomId - The dataroom ID\n * @param linkId - The link ID (optional, for external visitors)\n * @returns Array of accessible dataroom document IDs, or undefined if unrestricted\n */\nexport async function getFilteredDataroomDocumentIds(\n  dataroomId: string,\n  linkId?: string,\n): Promise<string[] | undefined> {\n  const getDescendantFolderIds = (\n    rootFolderIds: string[],\n    folders: { id: string; parentId: string | null }[],\n    deniedFolderIds: Set<string>,\n  ): Set<string> => {\n    const folderIds = new Set(rootFolderIds);\n    const queue = [...rootFolderIds];\n\n    // Build parent -> children map for O(1) descendant traversal\n    const childFoldersByParent = new Map<string, string[]>();\n    for (const folder of folders) {\n      if (!folder.parentId) continue;\n      const children = childFoldersByParent.get(folder.parentId) ?? [];\n      children.push(folder.id);\n      childFoldersByParent.set(folder.parentId, children);\n    }\n\n    while (queue.length > 0) {\n      const currentFolderId = queue.shift();\n      if (!currentFolderId) continue;\n\n      const children = childFoldersByParent.get(currentFolderId) ?? [];\n      for (const childFolderId of children) {\n        // Explicit deny on a folder should block inherited access via parent folder.\n        if (deniedFolderIds.has(childFolderId)) {\n          continue;\n        }\n\n        if (folderIds.has(childFolderId)) continue;\n        folderIds.add(childFolderId);\n        queue.push(childFolderId);\n      }\n    }\n\n    return folderIds;\n  };\n\n  try {\n    // No link means this is an internal/team context without link-level restrictions\n    if (!linkId) {\n      return undefined;\n    }\n\n    const link = await prisma.link.findUnique({\n      where: { id: linkId, dataroomId },\n      select: {\n        permissionGroupId: true,\n        groupId: true,\n      },\n    });\n\n    if (!link) {\n      return [];\n    }\n\n    // If link has no group restrictions, treat as unrestricted dataroom access\n    if (!link.groupId && !link.permissionGroupId) {\n      return undefined;\n    }\n\n    let accessControls: {\n      itemId: string;\n      itemType: ItemType;\n      canView: boolean;\n      canDownload: boolean;\n    }[] = [];\n\n    if (link.permissionGroupId) {\n      accessControls = await prisma.permissionGroupAccessControls.findMany({\n        where: {\n          groupId: link.permissionGroupId,\n          itemType: {\n            in: [ItemType.DATAROOM_DOCUMENT, ItemType.DATAROOM_FOLDER],\n          },\n        },\n        select: {\n          itemId: true,\n          itemType: true,\n          canView: true,\n          canDownload: true,\n        },\n      });\n    }\n\n    if (link.groupId) {\n      // Legacy group permissions take precedence when present\n      accessControls = await prisma.viewerGroupAccessControls.findMany({\n        where: {\n          groupId: link.groupId,\n          itemType: {\n            in: [ItemType.DATAROOM_DOCUMENT, ItemType.DATAROOM_FOLDER],\n          },\n        },\n        select: {\n          itemId: true,\n          itemType: true,\n          canView: true,\n          canDownload: true,\n        },\n      });\n    }\n\n    const isAllowed = (control: { canView: boolean; canDownload: boolean }) =>\n      control.canView || control.canDownload;\n\n    const explicitlyAllowedDocumentIds = new Set(\n      accessControls\n        .filter(\n          (permission) =>\n            permission.itemType === ItemType.DATAROOM_DOCUMENT &&\n            isAllowed(permission),\n        )\n        .map((permission) => permission.itemId),\n    );\n\n    const explicitlyDeniedDocumentIds = new Set(\n      accessControls\n        .filter(\n          (permission) =>\n            permission.itemType === ItemType.DATAROOM_DOCUMENT &&\n            !isAllowed(permission),\n        )\n        .map((permission) => permission.itemId),\n    );\n\n    const allowedFolderIds = accessControls\n      .filter(\n        (permission) =>\n          permission.itemType === ItemType.DATAROOM_FOLDER &&\n          isAllowed(permission),\n      )\n      .map((permission) => permission.itemId);\n\n    const deniedFolderIds = new Set(\n      accessControls\n        .filter(\n          (permission) =>\n            permission.itemType === ItemType.DATAROOM_FOLDER &&\n            !isAllowed(permission),\n        )\n        .map((permission) => permission.itemId),\n    );\n\n    if (allowedFolderIds.length > 0) {\n      const folders = await prisma.dataroomFolder.findMany({\n        where: { dataroomId },\n        select: {\n          id: true,\n          parentId: true,\n        },\n      });\n\n      // Start from explicitly allowed folder IDs and include descendants,\n      // while respecting explicit folder denies.\n      const accessibleFolderIds = Array.from(\n        getDescendantFolderIds(allowedFolderIds, folders, deniedFolderIds),\n      );\n\n      if (accessibleFolderIds.length > 0) {\n        const folderDocuments = await prisma.dataroomDocument.findMany({\n          where: {\n            dataroomId,\n            folderId: { in: accessibleFolderIds },\n          },\n          select: {\n            id: true,\n          },\n        });\n\n        for (const document of folderDocuments) {\n          explicitlyAllowedDocumentIds.add(document.id);\n        }\n      }\n    }\n\n    // Explicit document denies should always override inherited folder access.\n    for (const deniedDocumentId of explicitlyDeniedDocumentIds) {\n      explicitlyAllowedDocumentIds.delete(deniedDocumentId);\n    }\n\n    return Array.from(explicitlyAllowedDocumentIds);\n  } catch (error) {\n    console.error(\"Error getting filtered file IDs:\", error);\n    throw new Error(\"Failed to get filtered file IDs\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/chat/send-message.ts",
    "content": "import { OpenAIResponsesProviderOptions, openai } from \"@ai-sdk/openai\";\nimport { generateText, streamText } from \"ai\";\n\nimport prisma from \"@/lib/prisma\";\n\ninterface SendMessageOptions {\n  chatId: string;\n  content: string;\n  vectorStoreId: string;\n  /** Permission-scoped dataroom document IDs; undefined means unrestricted */\n  filteredDataroomDocumentIds?: string[];\n  /** Filter file_search results to a specific document by its ID */\n  filterDocumentId?: string;\n  /** User-selected dataroom document IDs to filter file_search results */\n  userSelectedDataroomDocumentIds?: string[];\n  /** Dataroom ID for citation-to-document mapping */\n  dataroomId?: string;\n  /** Link ID used to generate canonical viewer URLs */\n  linkId?: string;\n}\n\ninterface CitationCandidate {\n  fileId?: string;\n  filename?: string;\n  page?: number;\n}\n\ninterface ResolvedReference {\n  dataroomDocumentId: string;\n  documentName: string;\n  url: string;\n  page?: number;\n  folderPath?: string;\n}\n\nexport const CHAT_METADATA_PREFIX = \"\\n\\n<!-- CHAT_METADATA:\";\nexport const CHAT_METADATA_SUFFIX = \" -->\";\n\nexport interface ChatStreamSource {\n  id: string;\n  name: string;\n  url: string;\n  dataroomDocumentId?: string;\n  page?: number;\n  folderPath?: string;\n}\n\nexport interface ChatStreamMetadata {\n  sources: ChatStreamSource[];\n  suggestedQuestions: string[];\n}\n\nfunction validatePageValue(value: unknown): number | undefined {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) {\n    return undefined;\n  }\n\n  const rounded = Math.round(value);\n  return rounded < 0 ? undefined : rounded;\n}\n\nfunction normalizePageNumber(value: unknown): number | undefined {\n  const validated = validatePageValue(value);\n  if (validated === undefined) return undefined;\n  // Treat 0 as first page for 1-based page_number / page fields.\n  return validated === 0 ? 1 : validated;\n}\n\nfunction extractPageNumberFromRecord(\n  record: Record<string, unknown>,\n): number | undefined {\n  const directPage = normalizePageNumber(\n    record.page_number ?? record.page ?? record.pageNumber,\n  );\n  if (directPage !== undefined) {\n    return directPage;\n  }\n\n  // Some payloads expose 0-based page index.\n  const pageIndex = validatePageValue(record.page_index ?? record.pageIndex);\n  if (pageIndex !== undefined) {\n    return pageIndex + 1;\n  }\n\n  const location =\n    typeof record.location === \"object\" && record.location\n      ? (record.location as Record<string, unknown>)\n      : null;\n  if (location) {\n    const locationPage = normalizePageNumber(\n      location.page_number ?? location.page ?? location.pageNumber,\n    );\n    if (locationPage !== undefined) {\n      return locationPage;\n    }\n\n    const locationPageIndex = validatePageValue(\n      location.page_index ?? location.pageIndex,\n    );\n    if (locationPageIndex !== undefined) {\n      return locationPageIndex + 1;\n    }\n  }\n\n  return undefined;\n}\n\nfunction extractFileCitations(response: unknown): CitationCandidate[] {\n  const citations: CitationCandidate[] = [];\n  const visited = new Set<object>();\n\n  const addCitation = (candidate: CitationCandidate) => {\n    if (!candidate.fileId && !candidate.filename) return;\n    citations.push(candidate);\n  };\n\n  const visit = (node: unknown) => {\n    if (!node || typeof node !== \"object\") return;\n\n    if (visited.has(node as object)) {\n      return;\n    }\n    visited.add(node as object);\n\n    const record = node as Record<string, unknown>;\n\n    // OpenAI often provides annotations with type=file_citation.\n    if (record.type === \"file_citation\") {\n      addCitation({\n        fileId:\n          (record.file_id as string | undefined) ||\n          (record.fileId as string | undefined),\n        filename:\n          (record.filename as string | undefined) ||\n          (record.file_name as string | undefined),\n        page: extractPageNumberFromRecord(record),\n      });\n    }\n\n    // Sometimes payloads are nested under file_citation key.\n    if (record.file_citation && typeof record.file_citation === \"object\") {\n      const nested = record.file_citation as Record<string, unknown>;\n      addCitation({\n        fileId:\n          (nested.file_id as string | undefined) ||\n          (nested.fileId as string | undefined),\n        filename:\n          (nested.filename as string | undefined) ||\n          (nested.file_name as string | undefined),\n        page: extractPageNumberFromRecord(nested),\n      });\n      visit(record.file_citation);\n    }\n\n    for (const value of Object.values(record)) {\n      if (Array.isArray(value)) {\n        for (const item of value) {\n          visit(item);\n        }\n      } else {\n        visit(value);\n      }\n    }\n  };\n\n  visit(response);\n\n  return citations;\n}\n\nfunction stripTrailingReferencesSection(text: string): string {\n  return text\n    .replace(\n      /\\n{2,}(?:#{1,6}\\s*)?References\\s*\\n(?:\\s*\\n)?(?:[-*]\\s.*(?:\\n|$))+$/i,\n      \"\",\n    )\n    .trimEnd();\n}\n\nfunction escapeMarkdownLinkText(value: string): string {\n  return value\n    .replace(/\\\\/g, \"\\\\\\\\\")\n    .replace(/\\[/g, \"\\\\[\")\n    .replace(/\\]/g, \"\\\\]\");\n}\n\nasync function resolveReferencesFromCitations({\n  dataroomId,\n  linkId,\n  citations,\n  allowedDocumentIds,\n}: {\n  dataroomId?: string;\n  linkId?: string;\n  citations: CitationCandidate[];\n  /** Permission-scoped dataroom document IDs; when set, only these documents are considered */\n  allowedDocumentIds?: string[];\n}): Promise<ResolvedReference[]> {\n  if (!dataroomId || !linkId || citations.length === 0) {\n    return [];\n  }\n\n  const link = await prisma.link.findUnique({\n    where: { id: linkId },\n    select: { domainId: true, domainSlug: true, slug: true },\n  });\n\n  const isCustomDomain = Boolean(link?.domainId && link.domainSlug && link.slug);\n  const viewerBasePath = isCustomDomain\n    ? `/${link!.slug}`\n    : `/view/${linkId}`;\n\n  const citedFileIds = Array.from(\n    new Set(\n      citations\n        .map((citation) => citation.fileId)\n        .filter((fileId): fileId is string => Boolean(fileId)),\n    ),\n  );\n\n  const fileIdToDocument = new Map<\n    string,\n    { dataroomDocumentId: string; documentName: string; folderPath?: string }\n  >();\n\n  if (citedFileIds.length > 0) {\n    const dataroomDocuments = await prisma.dataroomDocument.findMany({\n      where: {\n        dataroomId,\n        ...(allowedDocumentIds && { id: { in: allowedDocumentIds } }),\n        document: {\n          versions: {\n            some: {\n              isPrimary: true,\n              fileId: { in: citedFileIds },\n            },\n          },\n        },\n      },\n      select: {\n        id: true,\n        folder: {\n          select: {\n            name: true,\n            path: true,\n          },\n        },\n        document: {\n          select: {\n            name: true,\n            versions: {\n              where: { isPrimary: true },\n              take: 1,\n              select: {\n                fileId: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    for (const dataroomDocument of dataroomDocuments) {\n      const primaryFileId = dataroomDocument.document.versions[0]?.fileId;\n      if (!primaryFileId) continue;\n      fileIdToDocument.set(primaryFileId, {\n        dataroomDocumentId: dataroomDocument.id,\n        documentName: dataroomDocument.document.name,\n        folderPath: dataroomDocument.folder?.path,\n      });\n    }\n  }\n\n  const citedFilenames = Array.from(\n    new Set(\n      citations\n        .map((citation) => citation.filename)\n        .filter((filename): filename is string => Boolean(filename)),\n    ),\n  );\n\n  const uniqueFilenameToDocument = new Map<\n    string,\n    { dataroomDocumentId: string; documentName: string; folderPath?: string }\n  >();\n\n  if (citedFilenames.length > 0) {\n    const namedDataroomDocuments = await prisma.dataroomDocument.findMany({\n      where: {\n        dataroomId,\n        ...(allowedDocumentIds && { id: { in: allowedDocumentIds } }),\n        document: {\n          name: { in: citedFilenames },\n        },\n      },\n      select: {\n        id: true,\n        folder: {\n          select: {\n            name: true,\n            path: true,\n          },\n        },\n        document: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n\n    const groupedByName = new Map<\n      string,\n      { dataroomDocumentId: string; documentName: string; folderPath?: string }[]\n    >();\n    for (const item of namedDataroomDocuments) {\n      const current = groupedByName.get(item.document.name) ?? [];\n      current.push({\n        dataroomDocumentId: item.id,\n        documentName: item.document.name,\n        folderPath: item.folder?.path,\n      });\n      groupedByName.set(item.document.name, current);\n    }\n\n    for (const [filename, docs] of groupedByName.entries()) {\n      if (docs.length === 1) {\n        uniqueFilenameToDocument.set(filename, docs[0]);\n      }\n    }\n  }\n\n  const references: ResolvedReference[] = [];\n  const seen = new Set<string>();\n\n  for (const citation of citations) {\n    const mappedByFileId = citation.fileId\n      ? fileIdToDocument.get(citation.fileId)\n      : undefined;\n    const mappedByFilename = citation.filename\n      ? uniqueFilenameToDocument.get(citation.filename)\n      : undefined;\n\n    const mapped = mappedByFileId ?? mappedByFilename;\n    if (!mapped) continue;\n\n    const dedupeKey = `${mapped.dataroomDocumentId}:${citation.page ?? \"\"}`;\n    if (seen.has(dedupeKey)) continue;\n    seen.add(dedupeKey);\n\n    references.push({\n      dataroomDocumentId: mapped.dataroomDocumentId,\n      documentName: mapped.documentName,\n      url: `${viewerBasePath}/d/${mapped.dataroomDocumentId}${\n        citation.page ? `?p=${citation.page}` : \"\"\n      }`,\n      page: citation.page,\n      folderPath: mapped.folderPath,\n    });\n  }\n\n  return references;\n}\n\nfunction buildReferencesSection(references: ResolvedReference[]): string {\n  const lines = [\"\", \"\", \"References\", \"\"];\n\n  if (references.length === 0) {\n    lines.push(\"- None\");\n    return lines.join(\"\\n\");\n  }\n\n  for (const reference of references) {\n    const documentLabel = escapeMarkdownLinkText(reference.documentName);\n    const pageSuffix = reference.page ? ` - p. ${reference.page}` : \"\";\n    lines.push(`- [${documentLabel}](${reference.url})${pageSuffix}`);\n  }\n\n  return lines.join(\"\\n\");\n}\n\nasync function generateSuggestedQuestions(\n  userQuestion: string,\n  assistantResponse: string,\n): Promise<string[]> {\n  try {\n    const { text } = await generateText({\n      model: openai(\"gpt-4o-mini\"),\n      maxOutputTokens: 200,\n      messages: [\n        {\n          role: \"system\",\n          content:\n            \"Generate exactly 3 concise follow-up questions a user might ask based on this conversation. Output only the questions, one per line. No numbering, no bullets, no prefixes.\",\n        },\n        {\n          role: \"user\",\n          content: `User asked: \"${userQuestion}\"\\n\\nAssistant answered: \"${assistantResponse.slice(0, 500)}\"`,\n        },\n      ],\n    });\n\n    return text\n      .split(\"\\n\")\n      .map((q) => q.trim())\n      .filter((q) => q.length > 0 && q.endsWith(\"?\"))\n      .slice(0, 3);\n  } catch {\n    return [];\n  }\n}\n\nfunction buildStreamMetadataTail(\n  references: ResolvedReference[],\n  suggestedQuestions: string[],\n): string {\n  const metadata: ChatStreamMetadata = {\n    sources: references.map((ref, idx) => ({\n      id: `D-${idx + 1}`,\n      name: ref.documentName,\n      url: ref.url,\n      dataroomDocumentId: ref.dataroomDocumentId,\n      page: ref.page,\n      folderPath: ref.folderPath,\n    })),\n    suggestedQuestions,\n  };\n  return `${CHAT_METADATA_PREFIX}${JSON.stringify(metadata)}${CHAT_METADATA_SUFFIX}`;\n}\n\n/**\n * Send a message and get streaming response\n * Uses AI SDK with OpenAI file_search tool\n */\nexport async function sendMessage({\n  chatId,\n  content,\n  vectorStoreId,\n  filteredDataroomDocumentIds,\n  filterDocumentId,\n  userSelectedDataroomDocumentIds,\n  dataroomId,\n  linkId,\n}: SendMessageOptions) {\n  // Get conversation history from database\n  const history = await prisma.chatMessage.findMany({\n    where: { chatId },\n    orderBy: { createdAt: \"desc\" },\n    take: 10,\n  });\n\n  const hasPriorAssistantReply = history.some((m) => m.role === \"assistant\");\n  const reasoningEffort = \"minimal\";\n  const maxOutputTokens = hasPriorAssistantReply ? 420 : 220;\n\n  // Save user message\n  await prisma.chatMessage.create({\n    data: { chatId, role: \"user\", content },\n  });\n\n  let resolveReferencesForStream: (value: string) => void = () => {};\n  const referencesForStream = new Promise<string>((resolve) => {\n    resolveReferencesForStream = resolve;\n  });\n\n  // Build messages for AI SDK\n  const messages = [\n    {\n      role: \"system\" as const,\n      content: `You are a helpful AI assistant answering questions about documents.\nUse the file_search tool to find relevant information from the documents.\nAlways cite sources with document names and page numbers when providing information.\nDo not include a \"References\" section in your answer. References are appended automatically by the system.\nKeep answers concise, direct, and non-repetitive.\nStart with the direct answer in the first sentence.\nDefault format: 1 short paragraph or up to 2 bullets.\nAvoid long document lists unless the user explicitly asks for a full list.\nOnly mention documents that are directly needed for the user's question.\n${\n  !hasPriorAssistantReply\n    ? `This is the first assistant reply in the conversation.\nPrioritize speed:\n- Start with a direct answer in 1-2 sentences.\n- Add at most 2 short bullets only if needed.\n- Keep it under 80 words before references.\nIf deeper analysis might help, add one short sentence offering a deeper follow-up.`\n    : \"\"\n}\nIf you cannot find the answer in the documents, say so clearly.`,\n    },\n    ...history.map((m) => ({\n      role: m.role as \"user\" | \"assistant\",\n      content:\n        m.role === \"assistant\"\n          ? stripTrailingReferencesSection(m.content)\n          : m.content,\n    })),\n    { role: \"user\" as const, content },\n  ];\n\n  // Build file_search tool options with optional document filter\n  const fileSearchOptions: Parameters<typeof openai.tools.fileSearch>[0] = {\n    vectorStoreIds: [vectorStoreId],\n  };\n\n  const NO_ACCESS_SENTINEL = \"__no_access__\";\n  let effectiveDataroomDocumentIds: string[] | undefined;\n\n  // Add document filter when viewing a specific document in a dataroom\n  if (filterDocumentId) {\n    fileSearchOptions.filters = {\n      type: \"eq\",\n      key: \"documentId\",\n      value: filterDocumentId,\n    };\n  } else if (\n    userSelectedDataroomDocumentIds &&\n    userSelectedDataroomDocumentIds.length > 0\n  ) {\n    // User explicitly selected documents to filter to\n    // Intersect with permission-based filter if it exists\n    let effectiveIds = userSelectedDataroomDocumentIds;\n    if (filteredDataroomDocumentIds !== undefined) {\n      // Only include user-selected IDs that are also in the permission-filtered list\n      effectiveIds = userSelectedDataroomDocumentIds.filter((id) =>\n        filteredDataroomDocumentIds.includes(id),\n      );\n      // Security: If intersection is empty (user sent unauthorized IDs),\n      // fall back to the permission-based filter\n      if (effectiveIds.length === 0) {\n        effectiveIds = filteredDataroomDocumentIds;\n      }\n    }\n    effectiveDataroomDocumentIds = effectiveIds;\n  } else if (filteredDataroomDocumentIds !== undefined) {\n    // Permission-restricted dataroom chat scope\n    effectiveDataroomDocumentIds = filteredDataroomDocumentIds;\n  }\n\n  if (!filterDocumentId && effectiveDataroomDocumentIds !== undefined) {\n    if (effectiveDataroomDocumentIds.length > 0) {\n      fileSearchOptions.filters = {\n        type: \"in\",\n        key: \"dataroomDocumentId\",\n        value: effectiveDataroomDocumentIds,\n      };\n    } else {\n      // Keep retrieval restricted when the viewer has no accessible documents\n      // by applying a guaranteed-empty metadata filter.\n      fileSearchOptions.filters = {\n        type: \"eq\",\n        key: \"dataroomDocumentId\",\n        value: NO_ACCESS_SENTINEL,\n      };\n    }\n  }\n\n  const latestMessage = history.at(0);\n  const previousResponseId =\n    (latestMessage?.metadata as { responseId?: string } | null)?.responseId ??\n    null;\n\n  // Use AI SDK streamText with file_search tool\n  const result = streamText({\n    model: openai.responses(\"gpt-4o-mini\"),\n    maxOutputTokens,\n    messages,\n    tools: {\n      file_search: openai.tools.fileSearch(fileSearchOptions),\n    },\n    providerOptions: {\n      openai: {\n        previousResponseId,\n      } as OpenAIResponsesProviderOptions,\n    },\n    onFinish: async ({ text, usage, response }) => {\n      try {\n        const shouldAppendReferences = Boolean(dataroomId && linkId);\n        const citations = extractFileCitations(response);\n\n        const [resolvedReferences, suggestedQuestions] = await Promise.all([\n          resolveReferencesFromCitations({\n            dataroomId,\n            linkId,\n            citations,\n            allowedDocumentIds: filteredDataroomDocumentIds,\n          }),\n          generateSuggestedQuestions(content, text),\n        ]);\n\n        const maxReferences = hasPriorAssistantReply ? 6 : 3;\n        const limitedReferences = resolvedReferences.slice(0, maxReferences);\n\n        const referencesSection = shouldAppendReferences\n          ? buildReferencesSection(limitedReferences)\n          : \"\";\n\n        const strippedText = stripTrailingReferencesSection(text).trim();\n        const rawTrimmedText = text.trim();\n        const hasRawText = rawTrimmedText.length > 0;\n        const resolvedText =\n          strippedText ||\n          rawTrimmedText ||\n          \"I couldn't find that in the indexed documents.\";\n\n        const metadataTail = buildStreamMetadataTail(\n          shouldAppendReferences ? limitedReferences : [],\n          suggestedQuestions,\n        );\n\n        const streamTail = hasRawText\n          ? metadataTail\n          : `${resolvedText}${metadataTail}`;\n\n        resolveReferencesForStream(streamTail);\n\n        const finalContent = shouldAppendReferences\n          ? `${resolvedText}${referencesSection}`\n          : resolvedText;\n\n        await Promise.all([\n          prisma.chatMessage.create({\n            data: {\n              chatId,\n              role: \"assistant\",\n              content: finalContent,\n              metadata: {\n                usage: usage as any,\n                vectorStoreId,\n                responseId: response.id,\n                modelId: response.modelId,\n                filters: fileSearchOptions.filters as any,\n                ...(shouldAppendReferences && {\n                  citations: citations as any,\n                  references: limitedReferences as any,\n                }),\n                suggestedQuestions,\n                reasoningEffort,\n              },\n            },\n          }),\n          prisma.chat.update({\n            where: { id: chatId },\n            data: { lastMessageAt: new Date() },\n          }),\n        ]);\n      } catch (error) {\n        resolveReferencesForStream(\"\");\n        console.error(\"Error finalizing AI response references:\", error);\n\n        await Promise.all([\n          prisma.chatMessage.create({\n            data: {\n              chatId,\n              role: \"assistant\",\n              content: text,\n              metadata: {\n                usage,\n                vectorStoreId,\n                responseId: response.id,\n                modelId: response.modelId,\n                filters: fileSearchOptions.filters,\n                referenceError:\n                  error instanceof Error ? error.message : \"Unknown error\",\n              },\n            },\n          }),\n          prisma.chat.update({\n            where: { id: chatId },\n            data: { lastMessageAt: new Date() },\n          }),\n        ]);\n      }\n    },\n  });\n\n  return {\n    result,\n    referencesForStream,\n  };\n}\n"
  },
  {
    "path": "ee/features/ai/lib/file-processing/extract-document-metadata.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\ninterface DocumentMetadata {\n  documentId: string;\n  documentName: string;\n  versionId: string;\n  dataroomId?: string;\n  folderId?: string;\n  teamId: string;\n}\n\n/**\n * Extract metadata from a document for vector store tagging\n * @param documentId - The document ID\n * @param versionId - The document version ID\n * @returns Document metadata\n */\nexport async function extractDocumentMetadata(\n  documentId: string,\n  versionId: string,\n): Promise<DocumentMetadata> {\n  try {\n    const documentVersion = await prisma.documentVersion.findUnique({\n      where: { id: versionId },\n      include: {\n        document: {\n          include: {\n            datarooms: {\n              include: {\n                dataroom: true,\n                folder: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!documentVersion) {\n      throw new Error(\"Document version not found\");\n    }\n\n    const document = documentVersion.document;\n\n    // Get dataroom and folder info if document is in a dataroom\n    const dataroomDocument = document.datarooms[0]; // Get first dataroom association\n\n    const metadata: DocumentMetadata = {\n      documentId: document.id,\n      documentName: document.name,\n      versionId: documentVersion.id,\n      teamId: document.teamId,\n      dataroomId: dataroomDocument?.dataroomId,\n      folderId: dataroomDocument?.folderId || document.folderId || undefined,\n    };\n\n    return metadata;\n  } catch (error) {\n    console.error(\"Error extracting document metadata:\", error);\n    throw new Error(\"Failed to extract document metadata\");\n  }\n}\n\n"
  },
  {
    "path": "ee/features/ai/lib/file-processing/process-document-for-vector-store.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { toFile } from \"openai\";\nimport path from \"path\";\n\nimport { getFile } from \"@/lib/files/get-file\";\n\n/**\n * Process a document and prepare it for vector store upload\n * Downloads the file from storage and returns it as a buffer\n * @param filePath - The file path/key in storage\n * @param storageType - The storage type (S3_PATH or VERCEL_BLOB)\n * @returns File ID\n */\nexport async function processDocumentForVectorStore(\n  filePath: string,\n  storageType: DocumentStorageType,\n): Promise<{ fileId: string }> {\n  try {\n    // Get the file URL\n    const fileUrl = await getFile({\n      type: storageType,\n      data: filePath,\n      isDownload: true,\n    });\n\n    // Fetch the file\n    const response = await fetch(fileUrl);\n    if (!response.ok) {\n      throw new Error(`Failed to fetch file: ${response.statusText}`);\n    }\n\n    // Extract filename from the original file path (not the presigned URL)\n    const fileName = path.basename(filePath);\n\n    // Convert response to buffer and create a properly named file\n    // This prevents OpenAI from inferring extension from URL query params\n    // const buffer = await response.arrayBuffer();\n    const file = await toFile(response, fileName, { type: \"application/pdf\" });\n\n    const fileResponse = await openai.files.create({\n      file,\n      purpose: \"assistants\",\n    });\n\n    return { fileId: fileResponse.id };\n  } catch (error) {\n    console.error(\"Error processing document for vector store:\", error);\n    throw new Error(\"Failed to process document for vector store\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/models/google.ts",
    "content": "import { createVertex } from \"@ai-sdk/google-vertex\";\n\nconst vertex = createVertex({\n  apiKey: process.env.GOOGLE_VERTEX_API_KEY,\n});\n\nexport { vertex };\n"
  },
  {
    "path": "ee/features/ai/lib/models/openai.ts",
    "content": "import { OpenAI } from \"openai\";\n\nexport const openai = new OpenAI({\n  apiKey: process.env.OPENAI_API_KEY,\n});\n"
  },
  {
    "path": "ee/features/ai/lib/permissions/validate-chat-access.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\ninterface ValidateChatAccessOptions {\n  chatId: string;\n  userId?: string;\n  viewerId?: string;\n}\n\n/**\n * Validate if a user or viewer has access to a chat\n * @param options - Chat ID and user/viewer ID\n * @returns Boolean indicating if access is granted\n */\nexport async function validateChatAccess({\n  chatId,\n  userId,\n  viewerId,\n}: ValidateChatAccessOptions): Promise<boolean> {\n  try {\n    const chat = await prisma.chat.findUnique({\n      where: { id: chatId },\n      select: {\n        teamId: true,\n        userId: true,\n        viewerId: true,\n      },\n    });\n\n    if (!chat) {\n      return false;\n    }\n\n    // Internal user access\n    if (userId) {\n      // Check if user is member of the team\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId: chat.teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return false;\n      }\n\n      // Additional check: if chat has a specific userId, it must match\n      if (chat.userId && chat.userId !== userId) {\n        return false;\n      }\n\n      return true;\n    }\n\n    // External viewer access\n    if (viewerId) {\n      // Check if viewer is associated with this chat\n      if (chat.viewerId !== viewerId) {\n        return false;\n      }\n\n      // Verify viewer exists and is associated with the team\n      const viewer = await prisma.viewer.findUnique({\n        where: { id: viewerId },\n      });\n\n      if (!viewer || viewer.teamId !== chat.teamId) {\n        return false;\n      }\n\n      return true;\n    }\n\n    return false;\n  } catch (error) {\n    console.error(\"Error validating chat access:\", error);\n    return false;\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/stream/parse-text-stream.ts",
    "content": "/**\n * Parser for AI SDK's toTextStreamResponse() format\n * Handles plain text stream (no SSE, no JSON structure)\n */\n\nexport interface TextStreamCallbacks {\n  onTextDelta?: (delta: string, accumulated: string) => void;\n  onTextEnd?: (content: string) => void;\n  onError?: (error: Error) => void;\n}\n\nexport interface TextStreamResult {\n  content: string;\n}\n\n/**\n * Parse the text stream from toTextStreamResponse()\n * This is a simple text-only stream with no structured events\n */\nexport async function parseTextStream(\n  reader: ReadableStreamDefaultReader<Uint8Array>,\n  callbacks: TextStreamCallbacks,\n): Promise<TextStreamResult> {\n  const decoder = new TextDecoder();\n  let content = \"\";\n\n  try {\n    while (true) {\n      const { done, value } = await reader.read();\n      if (done) break;\n\n      const chunk = decoder.decode(value, { stream: true });\n      content += chunk;\n      callbacks.onTextDelta?.(chunk, content);\n    }\n\n    // Flush decoder\n    const remaining = decoder.decode();\n    if (remaining) {\n      content += remaining;\n      callbacks.onTextDelta?.(remaining, content);\n    }\n  } catch (error) {\n    callbacks.onError?.(error as Error);\n    throw error;\n  }\n\n  callbacks.onTextEnd?.(content);\n\n  return { content };\n}\n\n"
  },
  {
    "path": "ee/features/ai/lib/trigger/add-file-to-vector-store.ts",
    "content": "import { logger, metadata, task } from \"@trigger.dev/sdk/v3\";\n\nimport { openai } from \"@/ee/features/ai/lib/models/openai\";\n\nimport type { AddToVectorStorePayload } from \"./types\";\n\n/**\n * Add an existing OpenAI file to a vector store\n * This is used when a document has already been processed and we just need to add it\n * to a new vector store (e.g., when adding to a dataroom)\n */\nexport const addFileToVectorStoreTask = task({\n  id: \"add-file-to-vector-store\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 10,\n  },\n  run: async (\n    payload: AddToVectorStorePayload,\n  ): Promise<{ vectorStoreFileId: string }> => {\n    const { fileId, vectorStoreId, metadata: fileMetadata } = payload;\n\n    logger.info(\"Adding file to vector store\", {\n      fileId,\n      vectorStoreId,\n      documentId: fileMetadata.documentId,\n    });\n\n    metadata.set(\"status\", \"indexing\");\n    metadata.set(\"step\", \"Adding to vector store...\");\n    metadata.set(\"progress\", 80);\n\n    // Add file to vector store with metadata\n    const vectorStoreFile = await openai.vectorStores.files.create(\n      vectorStoreId,\n      {\n        file_id: fileId,\n        attributes: fileMetadata,\n      },\n    );\n\n    metadata.set(\"status\", \"completed\");\n    metadata.set(\"step\", \"Indexed successfully\");\n    metadata.set(\"progress\", 100);\n    metadata.set(\"vectorStoreFileId\", vectorStoreFile.id);\n\n    logger.info(\"File added to vector store successfully\", {\n      fileId,\n      vectorStoreFileId: vectorStoreFile.id,\n      documentId: fileMetadata.documentId,\n    });\n\n    return { vectorStoreFileId: vectorStoreFile.id };\n  },\n});\n"
  },
  {
    "path": "ee/features/ai/lib/trigger/index.ts",
    "content": "// Export all AI processing tasks\nexport { addFileToVectorStoreTask } from \"./add-file-to-vector-store\";\nexport { processDocumentForAITask } from \"./process-document-for-ai\";\nexport { processExcelForAITask } from \"./process-excel-for-ai\";\nexport { processImageForAITask } from \"./process-image-for-ai\";\nexport { processPdfForAITask } from \"./process-pdf-for-ai\";\n\n// Export types\nexport * from \"./types\";\n"
  },
  {
    "path": "ee/features/ai/lib/trigger/process-document-for-ai.ts",
    "content": "import { logger, metadata, task } from \"@trigger.dev/sdk/v3\";\n\nimport prisma from \"@/lib/prisma\";\n\nimport { addFileToVectorStoreTask } from \"./add-file-to-vector-store\";\nimport { processExcelForAITask } from \"./process-excel-for-ai\";\nimport { processImageForAITask } from \"./process-image-for-ai\";\nimport { processPdfForAITask } from \"./process-pdf-for-ai\";\nimport {\n  EXCEL_CONTENT_TYPES,\n  IMAGE_CONTENT_TYPES,\n  PDF_CONTENT_TYPES,\n  SUPPORTED_AI_CONTENT_TYPES,\n  type ProcessDocumentPayload,\n} from \"./types\";\n\n/**\n * Main orchestrator task for processing documents for AI\n * Routes to appropriate subtask based on content type, then adds to vector store\n */\nexport const processDocumentForAITask = task({\n  id: \"process-document-for-ai\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 10,\n  },\n  run: async (\n    payload: ProcessDocumentPayload,\n  ): Promise<{ vectorStoreFileId: string; fileId: string }> => {\n    const {\n      documentId,\n      documentVersionId,\n      teamId,\n      vectorStoreId,\n      documentName,\n      filePath,\n      storageType,\n      contentType,\n      metadata: fileMetadata,\n    } = payload;\n\n    logger.info(\"Starting document processing for AI\", {\n      documentId,\n      documentVersionId,\n      teamId,\n      contentType,\n    });\n\n    // Initialize metadata for real-time tracking\n    metadata.set(\"status\", \"pending\");\n    metadata.set(\"documentName\", documentName);\n    metadata.set(\"documentId\", documentId);\n    metadata.set(\"step\", \"Initializing...\");\n    metadata.set(\"progress\", 0);\n\n    // Validate content type\n    if (!SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {\n      metadata.set(\"status\", \"failed\");\n      metadata.set(\"step\", \"Unsupported file type\");\n      metadata.set(\"error\", `Unsupported content type: ${contentType}`);\n      throw new Error(`Unsupported content type: ${contentType}`);\n    }\n\n    // Check if fileId already exists\n    const version = await prisma.documentVersion.findUnique({\n      where: { id: documentVersionId },\n      select: { fileId: true },\n    });\n\n    let fileId = version?.fileId;\n\n    // Process document if no fileId exists\n    if (!fileId) {\n      metadata.set(\"status\", \"processing\");\n      metadata.set(\"step\", \"Processing document...\");\n      metadata.set(\"progress\", 10);\n\n      // Route to appropriate processing task based on content type\n      if (PDF_CONTENT_TYPES.includes(contentType)) {\n        const result = await processPdfForAITask.triggerAndWait({\n          documentId,\n          documentVersionId,\n          teamId,\n          documentName,\n          filePath,\n          storageType,\n          contentType,\n        });\n\n        if (!result.ok) {\n          metadata.set(\"status\", \"failed\");\n          metadata.set(\"step\", \"PDF processing failed\");\n          metadata.set(\"error\", \"Failed to process PDF\");\n          throw new Error(\"Failed to process PDF\");\n        }\n\n        fileId = result.output.fileId;\n      } else if (EXCEL_CONTENT_TYPES.includes(contentType)) {\n        const result = await processExcelForAITask.triggerAndWait({\n          documentId,\n          documentVersionId,\n          teamId,\n          documentName,\n          filePath,\n          storageType,\n          contentType,\n        });\n\n        if (!result.ok) {\n          metadata.set(\"status\", \"failed\");\n          metadata.set(\"step\", \"Excel processing failed\");\n          metadata.set(\"error\", \"Failed to process Excel file\");\n          throw new Error(\"Failed to process Excel file\");\n        }\n\n        fileId = result.output.fileId;\n      } else if (IMAGE_CONTENT_TYPES.includes(contentType)) {\n        const result = await processImageForAITask.triggerAndWait({\n          documentId,\n          documentVersionId,\n          teamId,\n          documentName,\n          filePath,\n          storageType,\n          contentType,\n        });\n\n        if (!result.ok) {\n          metadata.set(\"status\", \"failed\");\n          metadata.set(\"step\", \"Image processing failed\");\n          metadata.set(\"error\", \"Failed to process image\");\n          throw new Error(\"Failed to process image\");\n        }\n\n        fileId = result.output.fileId;\n      }\n    } else {\n      logger.info(\"Document already processed, reusing fileId\", {\n        documentId,\n        fileId,\n      });\n      metadata.set(\"step\", \"Using existing processed file...\");\n      metadata.set(\"progress\", 50);\n    }\n\n    if (!fileId) {\n      metadata.set(\"status\", \"failed\");\n      metadata.set(\"step\", \"Processing failed\");\n      metadata.set(\"error\", \"No file ID returned from processing\");\n      throw new Error(\"No file ID returned from processing\");\n    }\n\n    metadata.set(\"fileId\", fileId);\n\n    // Add file to vector store\n    metadata.set(\"status\", \"indexing\");\n    metadata.set(\"step\", \"Adding to vector store...\");\n    metadata.set(\"progress\", 80);\n\n    const vsResult = await addFileToVectorStoreTask.triggerAndWait({\n      fileId,\n      vectorStoreId,\n      metadata: fileMetadata,\n    });\n\n    if (!vsResult.ok) {\n      metadata.set(\"status\", \"failed\");\n      metadata.set(\"step\", \"Failed to add to vector store\");\n      metadata.set(\"error\", \"Failed to add file to vector store\");\n      throw new Error(\"Failed to add file to vector store\");\n    }\n\n    const { vectorStoreFileId } = vsResult.output;\n\n    // Update document version with vectorStoreFileId\n    await prisma.documentVersion.update({\n      where: { id: documentVersionId },\n      data: { vectorStoreFileId },\n    });\n\n    metadata.set(\"status\", \"completed\");\n    metadata.set(\"step\", \"Indexed successfully\");\n    metadata.set(\"progress\", 100);\n    metadata.set(\"vectorStoreFileId\", vectorStoreFileId);\n\n    logger.info(\"Document processed and indexed successfully\", {\n      documentId,\n      fileId,\n      vectorStoreFileId,\n    });\n\n    return { vectorStoreFileId, fileId };\n  },\n});\n"
  },
  {
    "path": "ee/features/ai/lib/trigger/process-excel-for-ai.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\nimport { logger, metadata, task } from \"@trigger.dev/sdk/v3\";\nimport path from \"path\";\nimport * as XLSX from \"xlsx\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport { putFileServer } from \"@/lib/files/put-file-server\";\nimport prisma from \"@/lib/prisma\";\n\nimport type { ProcessFilePayload } from \"./types\";\n\n/**\n * Convert Excel workbook to Markdown format\n */\nfunction excelToMarkdown(workbook: XLSX.WorkBook): string {\n  const markdown: string[] = [];\n\n  for (const sheetName of workbook.SheetNames) {\n    const sheet = workbook.Sheets[sheetName];\n    if (!sheet) continue;\n\n    // Add sheet header\n    markdown.push(`## ${sheetName}\\n`);\n\n    // Convert sheet to array of arrays\n    const data: unknown[][] = XLSX.utils.sheet_to_json(sheet, {\n      header: 1,\n      defval: \"\",\n    });\n\n    if (data.length === 0) {\n      markdown.push(\"*Empty sheet*\\n\");\n      continue;\n    }\n\n    // Get headers (first row)\n    const headers = data[0] as string[];\n    if (!headers || headers.length === 0) continue;\n\n    // Create markdown table header\n    markdown.push(\n      \"| \" + headers.map((h) => String(h || \"\")).join(\" | \") + \" |\",\n    );\n    markdown.push(\"| \" + headers.map(() => \"---\").join(\" | \") + \" |\");\n\n    // Add data rows\n    for (let i = 1; i < data.length; i++) {\n      const row = data[i] as unknown[];\n      if (!row) continue;\n\n      const cells = headers.map((_, idx) => {\n        const cell = row[idx];\n        // Handle different cell types\n        if (cell === null || cell === undefined) return \"\";\n        if (typeof cell === \"object\") return JSON.stringify(cell);\n        return String(cell);\n      });\n\n      markdown.push(\"| \" + cells.join(\" | \") + \" |\");\n    }\n\n    markdown.push(\"\"); // Empty line between sheets\n  }\n\n  return markdown.join(\"\\n\");\n}\n\n/**\n * Process an Excel file for AI indexing\n * Converts Excel to Markdown, saves to S3, then uploads to OpenAI\n */\nexport const processExcelForAITask = task({\n  id: \"process-excel-for-ai\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 5,\n  },\n  run: async (\n    payload: ProcessFilePayload,\n  ): Promise<{ fileId: string; markdownPath?: string }> => {\n    const {\n      documentId,\n      documentVersionId,\n      teamId,\n      documentName,\n      filePath,\n      storageType,\n    } = payload;\n\n    logger.info(\"Processing Excel for AI\", {\n      documentId,\n      documentVersionId,\n      teamId,\n    });\n\n    // Check if fileId already exists\n    const version = await prisma.documentVersion.findUnique({\n      where: { id: documentVersionId },\n      select: { fileId: true },\n    });\n\n    if (version?.fileId) {\n      logger.info(\"Excel already processed, reusing fileId\", {\n        documentId,\n        fileId: version.fileId,\n      });\n      return { fileId: version.fileId };\n    }\n\n    metadata.set(\"status\", \"retrieving\");\n    metadata.set(\"step\", \"Retrieving Excel file...\");\n    metadata.set(\"progress\", 10);\n\n    // Get file URL\n    const fileUrl = await getFile({\n      type: storageType,\n      data: filePath,\n      isDownload: true,\n    });\n\n    // Fetch file\n    const response = await fetch(fileUrl);\n    if (!response.ok) {\n      throw new Error(`Failed to fetch Excel file: ${response.statusText}`);\n    }\n\n    const buffer = Buffer.from(await response.arrayBuffer());\n\n    metadata.set(\"status\", \"processing\");\n    metadata.set(\"step\", \"Converting Excel to Markdown...\");\n    metadata.set(\"progress\", 30);\n\n    // Parse Excel file\n    const workbook = XLSX.read(buffer, { type: \"buffer\" });\n\n    // Convert to markdown\n    const markdown = excelToMarkdown(workbook);\n\n    // Add document metadata to markdown\n    const fullMarkdown = `# ${documentName}\\n\\n${markdown}`;\n\n    metadata.set(\"step\", \"Saving processed content...\");\n    metadata.set(\"progress\", 50);\n\n    // Save markdown to S3 alongside original file\n    // Derive markdown path from original file path\n    const markdownPath = filePath.replace(/\\.[^.]+$/, \".ai.md\");\n    const markdownBuffer = Buffer.from(fullMarkdown, \"utf-8\");\n\n    // Extract docId from file path\n    const match = filePath.match(/(doc_[^\\/]+)\\//);\n    const docId = match ? match[1] : undefined;\n\n    // Save markdown file to S3\n    await putFileServer({\n      file: {\n        name: path.basename(markdownPath),\n        type: \"text/markdown\",\n        buffer: markdownBuffer,\n      },\n      teamId,\n      docId,\n      restricted: false,\n    });\n\n    metadata.set(\"status\", \"uploading\");\n    metadata.set(\"step\", \"Uploading to OpenAI...\");\n    metadata.set(\"progress\", 70);\n\n    // Upload markdown to OpenAI Files\n    const file = new File([markdownBuffer], `${documentName}.md`, {\n      type: \"text/markdown\",\n    });\n    const fileResponse = await openai.files.create({\n      file,\n      purpose: \"assistants\",\n    });\n\n    // Update document version with fileId\n    await prisma.documentVersion.update({\n      where: { id: documentVersionId },\n      data: { fileId: fileResponse.id },\n    });\n\n    logger.info(\"Excel processed successfully\", {\n      documentId,\n      fileId: fileResponse.id,\n      markdownPath,\n    });\n\n    return { fileId: fileResponse.id, markdownPath };\n  },\n});\n"
  },
  {
    "path": "ee/features/ai/lib/trigger/process-image-for-ai.ts",
    "content": "import { vertex } from \"@/ee/features/ai/lib/models/google\";\nimport { openai } from \"@/ee/features/ai/lib/models/openai\";\nimport { logger, metadata, task } from \"@trigger.dev/sdk/v3\";\nimport { generateText } from \"ai\";\nimport path from \"path\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport { putFileServer } from \"@/lib/files/put-file-server\";\nimport prisma from \"@/lib/prisma\";\n\nimport type { ProcessFilePayload } from \"./types\";\n\nconst IMAGE_ANALYSIS_PROMPT = `Analyze this image thoroughly and provide a detailed description for document search purposes.\n\nInclude the following in your analysis:\n1. **Type of Image**: What kind of image is this? (e.g., diagram, chart, photo, screenshot, infographic, flowchart, etc.)\n2. **Main Content**: What is the primary subject or content of the image?\n3. **Text Content**: Extract and transcribe any visible text, labels, titles, or captions.\n4. **Data & Numbers**: If there are charts, graphs, or tables, describe the data being presented.\n5. **Key Details**: Note any important visual elements, colors, symbols, or indicators that convey meaning.\n6. **Context**: What is the likely purpose or context of this image? What document or presentation might it belong to?\n\nFormat your response as clear, searchable text that would help someone find this image when searching for related topics.`;\n\n/**\n * Process an image file for AI indexing\n * Uses Google Vertex AI (Gemini Flash) to analyze the image and generate a description\n */\nexport const processImageForAITask = task({\n  id: \"process-image-for-ai\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 5,\n  },\n  run: async (\n    payload: ProcessFilePayload,\n  ): Promise<{ fileId: string; markdownPath?: string }> => {\n    const {\n      documentId,\n      documentVersionId,\n      teamId,\n      documentName,\n      filePath,\n      storageType,\n      contentType,\n    } = payload;\n\n    logger.info(\"Processing image for AI\", {\n      documentId,\n      documentVersionId,\n      teamId,\n      contentType,\n    });\n\n    // Check if fileId already exists\n    const version = await prisma.documentVersion.findUnique({\n      where: { id: documentVersionId },\n      select: { fileId: true },\n    });\n\n    if (version?.fileId) {\n      logger.info(\"Image already processed, reusing fileId\", {\n        documentId,\n        fileId: version.fileId,\n      });\n      return { fileId: version.fileId };\n    }\n\n    metadata.set(\"status\", \"retrieving\");\n    metadata.set(\"step\", \"Retrieving image...\");\n    metadata.set(\"progress\", 10);\n\n    // Get file URL\n    const fileUrl = await getFile({\n      type: storageType,\n      data: filePath,\n      isDownload: true,\n    });\n\n    // Fetch file\n    const response = await fetch(fileUrl);\n    if (!response.ok) {\n      throw new Error(`Failed to fetch image: ${response.statusText}`);\n    }\n\n    const imageBuffer = Buffer.from(await response.arrayBuffer());\n\n    metadata.set(\"status\", \"processing\");\n    metadata.set(\"step\", \"Analyzing image with AI...\");\n    metadata.set(\"progress\", 30);\n\n    // Use Gemini Flash to analyze the image\n    const { text: imageDescription } = await generateText({\n      model: vertex(\"gemini-3-flash-preview\"),\n      messages: [\n        {\n          role: \"user\",\n          content: [\n            {\n              type: \"image\",\n              image: imageBuffer,\n            },\n            {\n              type: \"text\",\n              text: IMAGE_ANALYSIS_PROMPT,\n            },\n          ],\n        },\n      ],\n    });\n\n    metadata.set(\"step\", \"Saving processed content...\");\n    metadata.set(\"progress\", 50);\n\n    // Create markdown content with image analysis\n    const fullMarkdown = `# ${documentName}\n\n## Image Analysis\n\n${imageDescription}\n\n---\n*This content was automatically generated from image analysis.*\n`;\n\n    // Save markdown to S3 alongside original file\n    const markdownPath = filePath.replace(/\\.[^.]+$/, \".ai.md\");\n    const markdownBuffer = Buffer.from(fullMarkdown, \"utf-8\");\n\n    // Extract docId from file path\n    const match = filePath.match(/(doc_[^\\/]+)\\//);\n    const docId = match ? match[1] : undefined;\n\n    // Save markdown file to S3\n    await putFileServer({\n      file: {\n        name: path.basename(markdownPath),\n        type: \"text/markdown\",\n        buffer: markdownBuffer,\n      },\n      teamId,\n      docId,\n      restricted: false,\n    });\n\n    metadata.set(\"status\", \"uploading\");\n    metadata.set(\"step\", \"Uploading to OpenAI...\");\n    metadata.set(\"progress\", 70);\n\n    // Upload markdown to OpenAI Files\n    const file = new File([markdownBuffer], `${documentName}.md`, {\n      type: \"text/markdown\",\n    });\n    const fileResponse = await openai.files.create({\n      file,\n      purpose: \"assistants\",\n    });\n\n    // Update document version with fileId\n    await prisma.documentVersion.update({\n      where: { id: documentVersionId },\n      data: { fileId: fileResponse.id },\n    });\n\n    logger.info(\"Image processed successfully\", {\n      documentId,\n      fileId: fileResponse.id,\n      markdownPath,\n    });\n\n    return { fileId: fileResponse.id, markdownPath };\n  },\n});\n"
  },
  {
    "path": "ee/features/ai/lib/trigger/process-pdf-for-ai.ts",
    "content": "import { logger, metadata, task } from \"@trigger.dev/sdk/v3\";\nimport path from \"path\";\n\nimport { openai } from \"@/ee/features/ai/lib/models/openai\";\nimport { getFile } from \"@/lib/files/get-file\";\nimport prisma from \"@/lib/prisma\";\n\nimport type { ProcessFilePayload } from \"./types\";\n\n/**\n * Process a PDF file for AI indexing\n * PDFs can be uploaded directly to OpenAI without conversion\n */\nexport const processPdfForAITask = task({\n  id: \"process-pdf-for-ai\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 5,\n  },\n  run: async (payload: ProcessFilePayload): Promise<{ fileId: string }> => {\n    const { documentId, documentVersionId, teamId, documentName, filePath, storageType } =\n      payload;\n\n    logger.info(\"Processing PDF for AI\", {\n      documentId,\n      documentVersionId,\n      teamId,\n    });\n\n    // Check if fileId already exists\n    const version = await prisma.documentVersion.findUnique({\n      where: { id: documentVersionId },\n      select: { fileId: true },\n    });\n\n    if (version?.fileId) {\n      logger.info(\"PDF already processed, reusing fileId\", {\n        documentId,\n        fileId: version.fileId,\n      });\n      return { fileId: version.fileId };\n    }\n\n    metadata.set(\"status\", \"retrieving\");\n    metadata.set(\"step\", \"Retrieving PDF...\");\n    metadata.set(\"progress\", 20);\n\n    // Get file URL\n    const fileUrl = await getFile({\n      type: storageType,\n      data: filePath,\n      isDownload: true,\n    });\n\n    // Fetch file\n    const response = await fetch(fileUrl);\n    if (!response.ok) {\n      throw new Error(`Failed to fetch PDF: ${response.statusText}`);\n    }\n\n    const buffer = Buffer.from(await response.arrayBuffer());\n    const fileName = path.basename(filePath);\n\n    metadata.set(\"status\", \"uploading\");\n    metadata.set(\"step\", \"Uploading to OpenAI...\");\n    metadata.set(\"progress\", 60);\n\n    // Upload to OpenAI Files\n    const file = new File([buffer], fileName, { type: \"application/pdf\" });\n    const fileResponse = await openai.files.create({\n      file,\n      purpose: \"assistants\",\n    });\n\n    // Update document version with fileId\n    await prisma.documentVersion.update({\n      where: { id: documentVersionId },\n      data: { fileId: fileResponse.id },\n    });\n\n    logger.info(\"PDF processed successfully\", {\n      documentId,\n      fileId: fileResponse.id,\n    });\n\n    return { fileId: fileResponse.id };\n  },\n});\n"
  },
  {
    "path": "ee/features/ai/lib/trigger/types.ts",
    "content": "import { DocumentStorageType } from \"@prisma/client\";\n\nexport type AIProcessingStatus =\n  | \"pending\"\n  | \"retrieving\"\n  | \"processing\"\n  | \"uploading\"\n  | \"indexing\"\n  | \"completed\"\n  | \"failed\";\n\nexport type AIProcessingMetadata = {\n  status: AIProcessingStatus;\n  documentName: string;\n  documentId: string;\n  step: string;\n  progress: number;\n  error?: string;\n  vectorStoreFileId?: string;\n  fileId?: string;\n};\n\nexport type ProcessDocumentPayload = {\n  documentId: string;\n  documentVersionId: string;\n  teamId: string;\n  vectorStoreId: string;\n  documentName: string;\n  filePath: string;\n  storageType: DocumentStorageType;\n  contentType: string;\n  metadata: {\n    teamId: string;\n    documentId: string;\n    documentName: string;\n    versionId: string;\n    folderId?: string;\n    dataroomId?: string;\n    dataroomDocumentId?: string;\n    dataroomFolderId?: string;\n  };\n};\n\nexport type ProcessFilePayload = {\n  documentId: string;\n  documentVersionId: string;\n  teamId: string;\n  documentName: string;\n  filePath: string;\n  storageType: DocumentStorageType;\n  contentType: string;\n};\n\nexport type AddToVectorStorePayload = {\n  fileId: string;\n  vectorStoreId: string;\n  metadata: {\n    teamId: string;\n    documentId: string;\n    documentName: string;\n    versionId: string;\n    folderId?: string;\n    dataroomId?: string;\n    dataroomDocumentId?: string;\n    dataroomFolderId?: string;\n  };\n};\n\n// Supported content types\nexport const PDF_CONTENT_TYPES = [\"application/pdf\"];\n\nexport const EXCEL_CONTENT_TYPES = [\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n  \"application/vnd.ms-excel\",\n  \"text/csv\",\n];\n\nexport const IMAGE_CONTENT_TYPES = [\"image/jpeg\", \"image/png\", \"image/webp\"];\n\nexport const SUPPORTED_AI_CONTENT_TYPES = [\n  ...PDF_CONTENT_TYPES,\n  ...EXCEL_CONTENT_TYPES,\n  ...IMAGE_CONTENT_TYPES,\n];\n"
  },
  {
    "path": "ee/features/ai/lib/vector-stores/create-dataroom-vector-store.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\n\n/**\n * Create a vector store for a dataroom\n * @param dataroomId - The dataroom ID\n * @param teamId - The team ID\n * @param name - The name of the vector store\n * @returns The vector store ID\n */\nexport async function createDataroomVectorStore({\n  dataroomId,\n  teamId,\n  name,\n}: {\n  dataroomId: string;\n  teamId: string;\n  name: string;\n}): Promise<string> {\n  try {\n    const vectorStore = await openai.vectorStores.create({\n      name: `Dataroom: ${name}`,\n      metadata: {\n        dataroomId,\n        teamId,\n        type: \"dataroom\",\n      },\n    });\n\n    return vectorStore.id;\n  } catch (error) {\n    console.error(\"Error creating dataroom vector store:\", error);\n    throw new Error(\"Failed to create dataroom vector store\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/vector-stores/create-team-vector-store.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\n\n/**\n * Create a vector store for a team\n * @param teamId - The team ID\n * @param name - The team name\n * @returns The vector store ID\n */\nexport async function createTeamVectorStore(\n  teamId: string,\n  name: string,\n): Promise<string> {\n  try {\n    const vectorStore = await openai.vectorStores.create({\n      name: `Team: ${name}`,\n      metadata: {\n        teamId,\n        type: \"team\",\n      },\n    });\n\n    return vectorStore.id;\n  } catch (error) {\n    console.error(\"Error creating team vector store:\", error);\n    throw new Error(\"Failed to create team vector store\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/vector-stores/delete-vector-store.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\n\n/**\n * Delete a vector store\n * @param vectorStoreId - The vector store ID to delete\n */\nexport async function deleteVectorStore(vectorStoreId: string): Promise<void> {\n  try {\n    await openai.vectorStores.delete(vectorStoreId);\n  } catch (error) {\n    console.error(\"Error deleting vector store:\", error);\n    throw new Error(\"Failed to delete vector store\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/vector-stores/get-vector-store-info.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\nimport { type OpenAI } from \"openai\";\n\n/**\n * Get information about a vector store\n * @param vectorStoreId - The vector store ID\n * @returns Vector store information\n */\nexport async function getVectorStoreInfo(\n  vectorStoreId: string,\n): Promise<OpenAI.VectorStores.VectorStore> {\n  try {\n    const vectorStore = await openai.vectorStores.retrieve(vectorStoreId);\n    return vectorStore;\n  } catch (error) {\n    console.error(\"Error getting vector store info:\", error);\n    throw new Error(\"Failed to get vector store info\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/vector-stores/remove-file-from-vector-store.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\n\n/**\n * Remove a file from a vector store\n * @param vectorStoreId - The vector store ID\n * @param fileId - The file ID to remove\n */\nexport async function removeFileFromVectorStore(\n  vectorStoreId: string,\n  fileId: string,\n): Promise<void> {\n  try {\n    await openai.vectorStores.files.delete(fileId, {\n      vector_store_id: vectorStoreId,\n    });\n  } catch (error) {\n    console.error(\"Error removing file from vector store:\", error);\n    throw new Error(\"Failed to remove file from vector store\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/lib/vector-stores/upload-file-to-vector-store.ts",
    "content": "import { openai } from \"@/ee/features/ai/lib/models/openai\";\n\ninterface VectorStoreFileOptions {\n  vectorStoreId: string;\n  fileId: string;\n  metadata: {\n    teamId: string;\n    documentId: string;\n    documentName: string;\n    versionId: string;\n    folderId?: string;\n    dataroomId?: string;\n    dataroomDocumentId?: string;\n    dataroomFolderId?: string;\n  };\n}\n\n/**\n * Upload a file to a vector store\n * @param options - Vector store file options including vector store ID, file ID, and metadata\n * @returns The vector store file ID\n */\nexport async function addFileToVectorStore({\n  vectorStoreId,\n  fileId,\n  metadata,\n}: VectorStoreFileOptions): Promise<string> {\n  try {\n    // Add file to vector store with metadata\n    const vectorStoreFile = await openai.vectorStores.files.create(\n      vectorStoreId,\n      {\n        file_id: fileId,\n        attributes: metadata,\n      },\n    );\n\n    return vectorStoreFile.id;\n  } catch (error) {\n    console.error(\"Error adding file to vector store:\", error);\n    throw new Error(\"Failed to add file to vector store\");\n  }\n}\n"
  },
  {
    "path": "ee/features/ai/schemas/chat.ts",
    "content": "import { z } from \"zod\";\n\n/**\n * Schema for creating a new chat\n */\nexport const createChatSchema = z.object({\n  documentId: z.string().cuid().optional(),\n  dataroomId: z.string().cuid().optional(),\n  linkId: z.string().cuid().optional(),\n  viewId: z.string().cuid().optional(),\n  title: z.string().max(100).optional(),\n  viewerId: z.string().cuid().optional(),\n});\n\n/**\n * Schema for sending a message\n */\nexport const sendMessageSchema = z.object({\n  content: z.string().min(1).max(10000),\n  /** Optional document ID to filter file_search results to a specific document */\n  filterDocumentId: z.string().cuid().optional(),\n  /** Optional array of dataroom document IDs to filter file_search results */\n  filterDataroomDocumentIds: z.array(z.string().cuid()).optional(),\n});\n\n/**\n * Schema for indexing a document\n */\nexport const indexDocumentSchema = z.object({\n  documentId: z.string().cuid(),\n  versionId: z.string().cuid().optional(),\n});\n\n/**\n * Schema for indexing a dataroom\n */\nexport const indexDataroomSchema = z.object({\n  dataroomId: z.string().cuid(),\n  documentIds: z.array(z.string().cuid()).optional(),\n});\n\n/**\n * Schema for chat query parameters\n */\nexport const chatQuerySchema = z.object({\n  teamId: z.string().cuid().optional(),\n  documentId: z.string().cuid().optional(),\n  dataroomId: z.string().cuid().optional(),\n  userId: z.string().cuid().optional(),\n  viewerId: z.string().cuid().optional(),\n  limit: z.coerce.number().int().min(1).max(100).optional().default(20),\n  offset: z.coerce.number().int().min(0).optional().default(0),\n});\n\n/**\n * Schema for enabling agents on a document\n */\nexport const enableDocumentAgentsSchema = z.object({\n  agentsEnabled: z.boolean(),\n});\n\n/**\n * Schema for enabling agents on a dataroom\n */\nexport const enableDataroomAgentsSchema = z.object({\n  agentsEnabled: z.boolean(),\n});\n\n/**\n * Schema for enabling agents on a team\n */\nexport const enableTeamAgentsSchema = z.object({\n  agentsEnabled: z.boolean(),\n});\n\nexport type CreateChatInput = z.infer<typeof createChatSchema>;\nexport type SendMessageInput = z.infer<typeof sendMessageSchema>;\nexport type IndexDocumentInput = z.infer<typeof indexDocumentSchema>;\nexport type IndexDataroomInput = z.infer<typeof indexDataroomSchema>;\nexport type ChatQueryInput = z.infer<typeof chatQuerySchema>;\nexport type EnableDocumentAgentsInput = z.infer<\n  typeof enableDocumentAgentsSchema\n>;\nexport type EnableDataroomAgentsInput = z.infer<\n  typeof enableDataroomAgentsSchema\n>;\nexport type EnableTeamAgentsInput = z.infer<typeof enableTeamAgentsSchema>;\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/automatic-unpause-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { isOldAccount } from \"@/ee/stripe/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { timingSafeEqual } from \"crypto\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    res.status(405).end(`Method ${req.method} Not Allowed`);\n    return;\n  }\n\n  // Extract the API Key from the Authorization header with strict Bearer parsing\n  const authHeader = req.headers.authorization;\n\n  // Verify Authorization header exists and starts with \"Bearer \"\n  if (!authHeader || !authHeader.startsWith(\"Bearer \")) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  // Extract token after \"Bearer \" prefix\n  const token = authHeader.substring(7); // \"Bearer \".length === 7\n  const envKey = process.env.INTERNAL_API_KEY;\n\n  // Verify both token and environment key are present and have equal length\n  if (!token || !envKey || token.length !== envKey.length) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  // Perform timing-safe comparison using Buffers\n  try {\n    const tokenBuffer = Buffer.from(token, \"utf8\");\n    const envKeyBuffer = Buffer.from(envKey, \"utf8\");\n\n    if (!timingSafeEqual(tokenBuffer, envKeyBuffer)) {\n      res.status(401).json({ message: \"Unauthorized\" });\n      return;\n    }\n  } catch (error) {\n    // Handle any Buffer creation errors\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  const { teamId } = req.body;\n\n  if (!teamId) {\n    return res.status(400).json({ error: \"teamId is required\" });\n  }\n\n  try {\n    // Get team details and verify it's still paused\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n      },\n      select: {\n        id: true,\n        name: true,\n        plan: true,\n        subscriptionId: true,\n        pauseStartsAt: true,\n        pauseEndsAt: true,\n        pausedAt: true,\n      },\n    });\n\n    if (!team) {\n      return res.status(404).json({ error: \"Team not found\" });\n    }\n\n    // Check if team is still paused\n    if (!team.pausedAt || !team.pauseStartsAt || !team.pauseEndsAt) {\n      return res.status(400).json({\n        error: \"Team is no longer paused\",\n        teamId,\n        pausedAt: team.pausedAt,\n        pauseStartsAt: team.pauseStartsAt,\n        pauseEndsAt: team.pauseEndsAt,\n      });\n    }\n\n    // Check if we're past the pause end date\n    const now = new Date();\n    if (now < team.pauseEndsAt) {\n      return res.status(400).json({\n        error: \"Pause period has not ended yet\",\n        teamId,\n        pauseEndsAt: team.pauseEndsAt,\n        currentTime: now,\n      });\n    }\n\n    if (!team.subscriptionId) {\n      return res.status(400).json({\n        error: \"No subscription ID found\",\n        teamId,\n      });\n    }\n\n    // Perform the automatic unpause\n    const isOld = isOldAccount(team.plan);\n    const stripe = stripeInstance(isOld);\n\n    // First, check the subscription to determine if it was paused with old method or new method\n    const subscription = await stripe.subscriptions.retrieve(\n      team.subscriptionId,\n    );\n\n    // Check if this subscription was paused using the old pause_collection method\n    const isOldPauseMethod = subscription.pause_collection !== null;\n\n    if (isOldPauseMethod) {\n      // Handle old pause_collection method\n      // For automatic unpause (3 months passed), we always want to reset billing cycle\n      await stripe.subscriptions.update(team.subscriptionId, {\n        pause_collection: \"\", // Remove pause_collection (unpause)\n      });\n      await stripe.subscriptions.update(team.subscriptionId, {\n        proration_behavior: \"create_prorations\", // Create prorations for immediate billing\n        billing_cycle_anchor: \"now\", // Reset billing cycle to start immediately\n      });\n    } else {\n      // Handle new coupon-based method\n      // Since 3 months have passed, we definitely need to reset the billing cycle\n      // Need to do this in two steps to avoid Stripe proration calculation issues\n\n      // Step 1: Remove the discount first\n      await stripe.subscriptions.deleteDiscount(team.subscriptionId);\n\n      // Step 2: Reset billing cycle to charge immediately (now that discount is removed)\n      await stripe.subscriptions.update(team.subscriptionId, {\n        proration_behavior: \"create_prorations\", // Create prorations for immediate billing\n        billing_cycle_anchor: \"now\", // Reset billing cycle to start immediately\n      });\n    }\n\n    // Update database to clear pause status\n    await prisma.team.update({\n      where: { id: teamId },\n      data: {\n        pausedAt: null,\n        pauseStartsAt: null,\n        pauseEndsAt: null,\n      },\n    });\n\n    waitUntil(\n      log({\n        message: `Team ${teamId} (${team.plan}) automatically unpaused after 3-month pause period ended using ${isOldPauseMethod ? \"pause_collection method\" : \"coupon method\"}.`,\n        type: \"info\",\n      }),\n    );\n\n    return res.status(200).json({\n      success: true,\n      message: \"Subscription automatically unpaused successfully\",\n      teamId,\n      teamName: team.name,\n    });\n  } catch (error) {\n    console.error(\"Error automatically unpausing subscription:\", error);\n    await log({\n      message: `Error automatically unpausing subscription for team ${teamId}: ${error}`,\n      type: \"error\",\n    });\n\n    return res.status(500).json({\n      error: \"Failed to automatically unpause subscription\",\n      teamId,\n    });\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/cancel-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { isOldAccount } from \"@/ee/stripe/utils\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/billing/cancel – cancel a user's subscription\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n              role: {\n                in: [\"ADMIN\", \"MANAGER\"],\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          stripeId: true,\n          subscriptionId: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exist\" });\n      }\n\n      if (!team.stripeId) {\n        return res.status(400).json({ error: \"No Stripe customer ID\" });\n      }\n\n      if (!team.subscriptionId) {\n        return res.status(400).json({ error: \"No subscription ID\" });\n      }\n\n      const stripe = stripeInstance(isOldAccount(team.plan));\n\n      waitUntil(\n        Promise.all([\n          stripe.subscriptions.update(team.subscriptionId, {\n            cancel_at_period_end: true,\n          }),\n          // Delete discount if one exists - catch errors since subscription may not have a discount\n          stripe.subscriptions.deleteDiscount(team.subscriptionId).catch(() => {\n            // Ignore error - subscription may not have a discount applied\n          }),\n          prisma.team.update({\n            where: { id: teamId },\n            data: {\n              cancelledAt: new Date(),\n            },\n          }),\n          log({\n            message: `Team ${teamId} cancelled their subscription.`,\n            type: \"info\",\n          }),\n        ]),\n      );\n\n      return res.status(200).json({ success: true });\n    } catch (error) {\n      console.error(\"Error cancelling subscription:\", error);\n      await log({\n        message: `Error cancelling subscription for team ${teamId}: ${error}`,\n        type: \"error\",\n      });\n      res.status(500).json({ error: \"Failed to cancel subscription\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/cancellation-feedback-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { CancellationReason } from \"@/ee/features/billing/cancellation/lib/constants\";\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { isOldAccount } from \"@/ee/stripe/utils\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/billing/cancellation-feedback – submit cancellation feedback\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { reason, feedback } = req.body as {\n      reason: CancellationReason;\n      feedback: string;\n    };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n              role: {\n                in: [\"ADMIN\", \"MANAGER\"],\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          stripeId: true,\n          subscriptionId: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exist\" });\n      }\n\n      const reasonLabels: Record<CancellationReason, string> = {\n        too_expensive: \"Too expensive\",\n        unused: \"Not used enough\",\n        missing_features: \"Missing features\",\n        switched_service: \"Switched to another service\",\n        other: \"Other reason\",\n      };\n\n      // Prepare feedback data for logging and analytics\n      const feedbackData = {\n        reason,\n        reasonLabel: reasonLabels[reason],\n        feedback: feedback || \"\",\n        timestamp: new Date().toISOString(),\n      };\n\n      // Update the subscription cancellation details\n      if (team.stripeId && team.subscriptionId) {\n        const stripe = stripeInstance(isOldAccount(team.plan));\n\n        waitUntil(\n          stripe.subscriptions.update(team.subscriptionId, {\n            cancellation_details: {\n              feedback: reason,\n              comment: feedback || \"\",\n            },\n          }),\n        );\n      }\n\n      // Log to Slack\n      waitUntil(\n        log({\n          message: `💔 **Cancellation Feedback Received**\\n\\n**Team:** ${teamId} (${team.plan})\\n**Reason:** ${reasonLabels[reason]}\\n**Feedback:** ${feedback || \"No additional feedback provided\"}\\n\\nTime: ${new Date().toLocaleString()}`,\n          type: \"info\",\n        }),\n      );\n\n      return res.status(200).json({\n        success: true,\n        feedbackData, // Return this for PostHog tracking in the frontend\n      });\n    } catch (error) {\n      console.error(\"Error submitting cancellation feedback:\", error);\n      await log({\n        message: `Error submitting cancellation feedback for team ${teamId}: ${error}`,\n        type: \"error\",\n      });\n      res.status(500).json({ error: \"Failed to submit feedback\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/pause-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { PAUSE_COUPON_ID } from \"@/ee/features/billing/cancellation/constants\";\nimport { sendPauseResumeNotificationTask } from \"@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification\";\nimport { automaticUnpauseTask } from \"@/ee/features/billing/cancellation/lib/trigger/unpause-task\";\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/billing/pause – pause a user's subscription\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          stripeId: true,\n          subscriptionId: true,\n          endsAt: true,\n          plan: true,\n          limits: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exist\" });\n      }\n\n      if (!team.stripeId) {\n        return res.status(400).json({ error: \"No Stripe customer ID\" });\n      }\n\n      if (!team.subscriptionId) {\n        return res.status(400).json({ error: \"No subscription ID\" });\n      }\n\n      const isOldAccount = team.plan.includes(\"+old\");\n      const stripe = stripeInstance(isOldAccount);\n\n      // Fetch the subscription from Stripe to get the actual current_period_end\n      // This ensures we don't use stale data from the database\n      const subscription = await stripe.subscriptions.retrieve(\n        team.subscriptionId,\n      );\n\n      // Use the actual current_period_end from Stripe, ensuring pause starts at next billing cycle\n      // Never allow retroactive pausing - pauseStartsAt must be in the future\n      const now = new Date();\n      const stripePeriodEnd = new Date(subscription.current_period_end * 1000);\n      const pauseStartsAt = stripePeriodEnd > now ? stripePeriodEnd : now;\n\n      const pauseEndsAt = new Date(pauseStartsAt);\n      // Use 3 calendar months instead of 90 days to properly align with billing cycles\n      pauseEndsAt.setMonth(pauseStartsAt.getMonth() + 3);\n      const reminderAt = new Date(pauseEndsAt);\n      reminderAt.setDate(pauseEndsAt.getDate() - 3);\n\n      // Pause the subscription in Stripe\n      await stripe.subscriptions.update(team.subscriptionId, {\n        discounts: [\n          {\n            coupon:\n              PAUSE_COUPON_ID[isOldAccount ? \"old\" : \"new\"][\n                process.env.VERCEL_ENV === \"production\" ? \"prod\" : \"test\"\n              ],\n          },\n        ],\n        metadata: {\n          pause_starts_at: pauseStartsAt.toISOString(),\n          pause_ends_at: pauseEndsAt.toISOString(),\n          paused_reason: \"user_request\",\n          original_plan: team.plan,\n          pause_coupon_id:\n            PAUSE_COUPON_ID[isOldAccount ? \"old\" : \"new\"][\n              process.env.VERCEL_ENV === \"production\" ? \"prod\" : \"test\"\n            ],\n        },\n      });\n\n      await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          pausedAt: new Date(),\n          pauseStartsAt,\n          pauseEndsAt,\n        },\n      });\n\n      waitUntil(\n        Promise.all([\n          // Schedule the pause resume notifications\n          sendPauseResumeNotificationTask.trigger(\n            { teamId },\n            {\n              delay: reminderAt, // 3 days before pause ends\n              tags: [`team_${teamId}`],\n              idempotencyKey: `pause-resume-${teamId}-${new Date().getTime()}`,\n            },\n          ),\n          // Schedule automatic unpause when the 3-month pause period ends\n          automaticUnpauseTask.trigger(\n            { teamId },\n            {\n              delay: pauseEndsAt, // Exactly when pause period ends\n              tags: [`team_${teamId}`],\n              idempotencyKey: `automatic-unpause-${teamId}-${new Date().getTime()}`,\n            },\n          ),\n\n          log({\n            message: `Team ${teamId} (${team.plan}) paused their subscription for 3 months.`,\n            type: \"info\",\n          }),\n        ]),\n      );\n\n      res.status(200).json({\n        success: true,\n        message: \"Subscription paused successfully\",\n        pauseStartsAt: pauseStartsAt.toISOString(),\n        pauseEndsAt: pauseEndsAt.toISOString(),\n      });\n    } catch (error) {\n      console.error(\"Error pausing subscription:\", error);\n      await log({\n        message: `Error pausing subscription for team ${teamId}: ${error}`,\n        type: \"error\",\n      });\n      res.status(500).json({ error: \"Failed to pause subscription\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/reactivate-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { isOldAccount } from \"@/ee/stripe/utils\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/billing/reactivate – reactivate a user's subscription\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n              role: {\n                in: [\"ADMIN\", \"MANAGER\"],\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          stripeId: true,\n          subscriptionId: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exist\" });\n      }\n\n      if (!team.stripeId) {\n        return res.status(400).json({ error: \"No Stripe customer ID\" });\n      }\n\n      if (!team.subscriptionId) {\n        return res.status(400).json({ error: \"No subscription ID\" });\n      }\n\n      const stripe = stripeInstance(isOldAccount(team.plan));\n\n      const subscription = await stripe.subscriptions.update(\n        team.subscriptionId,\n        {\n          cancel_at_period_end: false,\n        },\n      );\n\n      await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          cancelledAt: null,\n          pauseStartsAt: null,\n        },\n      });\n\n      waitUntil(\n        log({\n          message: `Team ${teamId} reactivated their subscription.`,\n          type: \"info\",\n        }),\n      );\n\n      return res.status(200).json({ success: true });\n    } catch (error) {\n      console.error(\"Error reactivating subscription:\", error);\n      await log({\n        message: `Error reactivating subscription for team ${teamId}: ${error}`,\n        type: \"error\",\n      });\n      res.status(500).json({ error: \"Failed to reactivate subscription\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/retention-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { getCouponFromPlan } from \"@/ee/stripe/functions/get-coupon-from-plan\";\nimport { isOldAccount } from \"@/ee/stripe/utils\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/billing/retention-offer – apply retention offer\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n              role: {\n                in: [\"ADMIN\", \"MANAGER\"],\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          stripeId: true,\n          subscriptionId: true,\n          plan: true,\n          pausedAt: true,\n          startsAt: true,\n          endsAt: true,\n          limits: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exist\" });\n      }\n\n      if (!team.stripeId) {\n        return res.status(400).json({ error: \"No Stripe customer ID\" });\n      }\n\n      if (!team.subscriptionId) {\n        return res.status(400).json({ error: \"No subscription ID\" });\n      }\n\n      let isAnnualPlan = false;\n      if (team?.startsAt && team?.endsAt) {\n        const durationInDays = Math.round(\n          (team.endsAt.getTime() - team.startsAt.getTime()) /\n            (1000 * 60 * 60 * 24),\n        );\n        // If duration is more than 31 days, consider it yearly\n        isAnnualPlan = durationInDays > 31;\n      }\n\n      const stripe = stripeInstance(isOldAccount(team.plan));\n      const couponId = getCouponFromPlan(team.plan, isAnnualPlan);\n\n      await stripe.subscriptions.update(team.subscriptionId, {\n        discounts: [\n          {\n            coupon: couponId,\n          },\n        ],\n      });\n\n      waitUntil(\n        log({\n          message: `Retention offer applied to team ${teamId}: 30% off for ${\n            isAnnualPlan ? \"12 months\" : \"3 months\"\n          }`,\n          type: \"info\",\n        }),\n      );\n\n      return res.status(200).json({ success: true });\n    } catch (error) {\n      console.error(\"Error applying retention offer:\", error);\n      await log({\n        message: `Error applying retention offer for team ${teamId}: ${error}`,\n        type: \"error\",\n      });\n      res.status(500).json({ error: \"Failed to apply retention offer\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/send-pause-resume-notification.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getDisplayNameFromPlan } from \"@/ee/stripe/functions/get-display-name-from-plan\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nimport { sendEmailPauseResumeReminder } from \"../emails/lib/send-email-pause-resume-reminder\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  // Define validation schema\n  const requestSchema = z.object({\n    teamId: z\n      .string()\n      .min(1, \"teamId is required and must be a non-empty string\"),\n  });\n\n  // Validate request body\n  const validationResult = requestSchema.safeParse(req.body);\n\n  if (!validationResult.success) {\n    const firstError = validationResult.error.errors[0];\n    res.status(400).json({\n      message: firstError.message,\n      field: firstError.path[0],\n    });\n    return;\n  }\n\n  const { teamId } = validationResult.data;\n\n  try {\n    // Get all team members (ADMIN/MANAGER) for this team\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      select: {\n        name: true,\n        plan: true,\n        pauseEndsAt: true,\n        users: {\n          where: {\n            role: {\n              in: [\"ADMIN\", \"MANAGER\"],\n            },\n            blockedAt: null, // Only active team members (not blocked)\n          },\n          select: {\n            user: {\n              select: {\n                id: true,\n                email: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    const teamMembers = team?.users;\n\n    if (!teamMembers || teamMembers.length === 0) {\n      res.status(200).json({\n        message: \"No team members found for notifications\",\n        notified: 0,\n      });\n      return;\n    }\n\n    if (!team.pauseEndsAt) {\n      res.status(200).json({\n        message: \"Team is not paused\",\n        notified: 0,\n      });\n      return;\n    }\n\n    // Get all team member emails\n    const teamMemberEmails = teamMembers\n      .map((tm) => tm.user.email)\n      .filter((email): email is string => !!email);\n\n    // Send email to all team members at once\n    await sendEmailPauseResumeReminder({\n      teamName: team.name,\n      plan: getDisplayNameFromPlan(team.plan),\n      resumeDate: team.pauseEndsAt.toLocaleDateString(\"en-US\", {\n        month: \"long\",\n        day: \"numeric\",\n        year: \"numeric\",\n      }),\n      teamMemberEmails,\n    });\n\n    res.status(200).json({\n      message: \"Successfully sent pause resume reminder to team members\",\n      notified: teamMemberEmails.length,\n    });\n    return;\n  } catch (error) {\n    log({\n      message: `Failed to send pause resume reminder for team ${teamId} to team members. \\n\\n Error: ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}}\\``,\n      type: \"error\",\n      mention: true,\n    });\n    return res\n      .status(500)\n      .json({ message: \"Failed to send pause resume reminder\" });\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/api/unpause-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { runs } from \"@trigger.dev/sdk/v3\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/billing/unpause – unpause a user's subscription\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          stripeId: true,\n          subscriptionId: true,\n          endsAt: true,\n          plan: true,\n          pauseStartsAt: true,\n          pauseEndsAt: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exist\" });\n      }\n\n      if (!team.stripeId) {\n        return res.status(400).json({ error: \"No Stripe customer ID\" });\n      }\n\n      if (!team.subscriptionId) {\n        return res.status(400).json({ error: \"No subscription ID\" });\n      }\n\n      if (!team.pauseStartsAt || !team.pauseEndsAt) {\n        return res.status(400).json({ error: \"Subscription is not paused\" });\n      }\n\n      const isOldAccount = team.plan.includes(\"+old\");\n      const stripe = stripeInstance(isOldAccount);\n\n      // First, check the subscription to determine if it was paused with old method or new method\n      const subscription = await stripe.subscriptions.retrieve(\n        team.subscriptionId,\n      );\n\n      const now = new Date();\n      const originalPauseStart = team.pauseStartsAt;\n\n      // Determine if we're still in the original billing cycle or have moved to the next one\n      const isInOriginalBillingCycle = now <= originalPauseStart;\n\n      // Check if this subscription was paused using the old pause_collection method\n      const isOldPauseMethod = subscription.pause_collection !== null;\n\n      if (isOldPauseMethod) {\n        if (isInOriginalBillingCycle) {\n          await stripe.subscriptions.update(team.subscriptionId, {\n            pause_collection: \"\", // Remove pause_collection (unpause)\n          });\n        } else {\n          await stripe.subscriptions.update(team.subscriptionId, {\n            pause_collection: \"\", // Remove pause_collection (unpause)\n          });\n          await stripe.subscriptions.update(team.subscriptionId, {\n            proration_behavior: \"create_prorations\", // Create prorations for immediate billing\n            billing_cycle_anchor: \"now\", // Reset billing cycle to start immediately\n          });\n        }\n      } else {\n        // Handle new coupon-based method\n        if (isInOriginalBillingCycle) {\n          // Scenario 1: Still within the original billing cycle where user paused\n          // Just remove the coupon, no billing cycle reset needed\n          await stripe.subscriptions.deleteDiscount(team.subscriptionId);\n        } else {\n          // Scenario 2: We're already in the next billing cycle (or beyond)\n          // Remove coupon and reset billing cycle to charge immediately\n          await stripe.subscriptions.deleteDiscount(team.subscriptionId);\n          await stripe.subscriptions.update(team.subscriptionId, {\n            proration_behavior: \"create_prorations\", // Create prorations for immediate billing\n            billing_cycle_anchor: \"now\", // Reset billing cycle to start immediately\n          });\n        }\n      }\n\n      await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          pausedAt: null,\n          pauseStartsAt: null,\n          pauseEndsAt: null,\n        },\n      });\n\n      // Get all delayed and queued runs for this team (both notification and automatic unpause)\n      const allRuns = await runs.list({\n        taskIdentifier: [\n          \"send-pause-resume-notification\",\n          \"automatic-unpause-subscription\",\n        ],\n        tag: [`team_${teamId}`],\n        status: [\"DELAYED\", \"QUEUED\"],\n        period: \"90d\",\n      });\n\n      // Cancel any existing unsent notification and automatic unpause runs\n      waitUntil(\n        Promise.all([\n          allRuns.data.map((run) => runs.cancel(run.id)),\n          log({\n            message: `Team ${teamId} (${team.plan}) manually unpaused their subscription using ${isOldPauseMethod ? \"pause_collection method\" : \"coupon method\"}${!isOldPauseMethod ? (isInOriginalBillingCycle ? \" within original billing cycle\" : \" with billing cycle reset\") : \"\"}.`,\n            type: \"info\",\n          }),\n        ]),\n      );\n\n      res.status(200).json({\n        success: true,\n        message: \"Subscription unpaused successfully\",\n      });\n    } catch (error) {\n      console.error(\"Error unpausing subscription:\", error);\n      await log({\n        message: `Error unpausing subscription for team ${teamId}: ${error}`,\n        type: \"error\",\n      });\n      res.status(500).json({ error: \"Failed to unpause subscription\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/cancellation-modal.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport { type CancellationReason } from \"../lib/constants\";\nimport { ConfirmCancellationModal } from \"./confirm-cancellation-modal\";\nimport { FeedbackModal } from \"./feedback-modal\";\nimport { PauseSubscriptionModal } from \"./pause-subscription-modal\";\nimport { CancellationBaseModal } from \"./reason-base-modal\";\nimport { RetentionOfferModal } from \"./retention-offer-modal\";\nimport { ScheduleCallModal } from \"./schedule-call-modal\";\n\ntype CancellationStep =\n  | \"reason\"\n  | \"retention-offer\"\n  | \"pause-offer\"\n  | \"feedback\"\n  | \"confirm\"\n  | \"schedule-call\"\n  | \"final-feedback\";\n\ninterface CancellationModalProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport function CancellationModal({\n  open,\n  onOpenChange,\n}: CancellationModalProps) {\n  // Start with pause offer - this should be the first thing users see\n  const [currentStep, setCurrentStep] =\n    useState<CancellationStep>(\"pause-offer\");\n  const [selectedReason, setSelectedReason] =\n    useState<CancellationReason | null>(null);\n  const [feedback, setFeedback] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n\n  const { currentTeamId } = useTeam();\n  const analytics = useAnalytics();\n\n  const reasons: { value: CancellationReason; label: string }[] = [\n    { value: \"too_expensive\", label: \"It's too expensive\" },\n    { value: \"unused\", label: \"I don't use the service enough\" },\n    { value: \"missing_features\", label: \"Some features are missing\" },\n    {\n      value: \"switched_service\",\n      label: \"I'm switching to a different service\",\n    },\n    { value: \"other\", label: \"Other reason\" },\n  ];\n\n  // Reset state when modal closes\n  useEffect(() => {\n    if (!open) {\n      const timeoutId = setTimeout(() => {\n        setCurrentStep(\"pause-offer\");\n        setSelectedReason(null);\n        setFeedback(\"\");\n        setLoading(false);\n      }, 150);\n      return () => clearTimeout(timeoutId);\n    }\n  }, [open]);\n\n  const handleReasonClick = (reason: CancellationReason) => {\n    setSelectedReason(reason);\n    // Route based on reason - only \"other\" goes to feedback first\n    switch (reason) {\n      case \"too_expensive\":\n        setCurrentStep(\"retention-offer\");\n        break;\n      case \"unused\":\n        setCurrentStep(\"confirm\"); // Go directly to cancellation flow for unused\n        break;\n      case \"missing_features\":\n        setCurrentStep(\"schedule-call\");\n        break;\n      case \"switched_service\":\n        setCurrentStep(\"retention-offer\");\n        break;\n      case \"other\":\n        setCurrentStep(\"feedback\"); // Only \"other\" goes to feedback first\n        break;\n      default:\n        setCurrentStep(\"confirm\");\n    }\n  };\n\n  const handleBack = () => {\n    switch (currentStep) {\n      case \"reason\":\n        // From reason selection, go back to pause offer\n        setCurrentStep(\"pause-offer\");\n        break;\n      case \"retention-offer\":\n      case \"schedule-call\":\n        setCurrentStep(\"reason\");\n        break;\n      case \"feedback\":\n        // From \"other\" reason feedback, go back to reason selection\n        setCurrentStep(\"reason\");\n        break;\n      case \"confirm\":\n        // Go back to the appropriate step based on reason\n        if (selectedReason === \"missing_features\") {\n          setCurrentStep(\"schedule-call\");\n        } else if (selectedReason === \"unused\") {\n          setCurrentStep(\"reason\"); // Go back to reason selection for unused\n        } else if (selectedReason === \"other\") {\n          setCurrentStep(\"feedback\"); // Go back to \"other\" feedback\n        } else {\n          setCurrentStep(\"retention-offer\");\n        }\n        break;\n      case \"final-feedback\":\n        // From final feedback, close modal\n        onOpenChange(false);\n        break;\n      case \"pause-offer\":\n      default:\n        onOpenChange(false);\n    }\n  };\n\n  const handleClose = () => {\n    onOpenChange(false);\n  };\n\n  const handleFinalFeedbackSubmit = async () => {\n    if (!currentTeamId || !selectedReason) return;\n\n    setLoading(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${currentTeamId}/billing/cancellation-feedback`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            reason: selectedReason,\n            feedback: feedback,\n          }),\n        },\n      );\n\n      if (response.ok) {\n        const data = await response.json();\n\n        // Track in PostHog\n        analytics.capture(\"Cancellation Feedback Submitted\", {\n          teamId: currentTeamId,\n          reason: selectedReason,\n          reasonLabel: data.feedbackData?.reasonLabel || selectedReason,\n          feedback: feedback || \"\",\n          hasCustomFeedback: !!feedback,\n        });\n\n        toast.success(\"Thank you for your feedback!\");\n        mutate(`/api/teams/${currentTeamId}/billing/plan`);\n        mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);\n        onOpenChange(false);\n      } else {\n        throw new Error(\"Failed to submit feedback\");\n      }\n    } catch (error) {\n      console.error(\"Error submitting feedback:\", error);\n      toast.error(\"Sorry, we couldn't submit your feedback. Please try again.\");\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  if (!open) {\n    return null;\n  }\n\n  if (currentStep === \"pause-offer\") {\n    return (\n      <PauseSubscriptionModal\n        open={open}\n        onOpenChange={onOpenChange}\n        onBack={handleBack}\n        onDecline={() => setCurrentStep(\"reason\")} // Declining pause leads to reason selection\n        onClose={handleClose}\n      />\n    );\n  }\n\n  if (currentStep === \"reason\" || !selectedReason) {\n    return (\n      <CancellationBaseModal\n        open={open}\n        onOpenChange={onOpenChange}\n        title=\"Why do you want to cancel?\"\n        description=\"Help us understand what we could improve\"\n        showKeepButton={true}\n        onBack={handleBack}\n      >\n        <div className=\"space-y-3\">\n          {reasons.map((reason) => (\n            <button\n              key={reason.value}\n              onClick={() => handleReasonClick(reason.value)}\n              className=\"w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:ring-1 hover:ring-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:hover:ring-gray-200\"\n            >\n              <div className=\"text-sm font-medium\">{reason.label}</div>\n            </button>\n          ))}\n        </div>\n      </CancellationBaseModal>\n    );\n  }\n\n  if (currentStep === \"retention-offer\") {\n    return (\n      <RetentionOfferModal\n        open={open}\n        onOpenChange={onOpenChange}\n        reason={selectedReason}\n        onBack={handleBack}\n        onDecline={() => setCurrentStep(\"confirm\")}\n        onClose={handleClose}\n      />\n    );\n  }\n\n  if (currentStep === \"schedule-call\") {\n    return (\n      <ScheduleCallModal\n        open={open}\n        onOpenChange={onOpenChange}\n        reason={selectedReason}\n        onDecline={() => setCurrentStep(\"confirm\")}\n      />\n    );\n  }\n\n  if (currentStep === \"feedback\") {\n    return (\n      <FeedbackModal\n        open={open}\n        onOpenChange={onOpenChange}\n        reason={selectedReason}\n        feedback={feedback}\n        onFeedbackChange={setFeedback}\n        onBack={handleBack}\n        onContinue={() => {\n          // For \"other\" reason, continue to confirmation\n          setCurrentStep(\"confirm\");\n        }}\n        isFinalStep={false} // This is the \"other\" reason feedback, not final\n      />\n    );\n  }\n\n  if (currentStep === \"confirm\") {\n    return (\n      <ConfirmCancellationModal\n        open={open}\n        onOpenChange={onOpenChange}\n        onConfirmCancellation={() => {\n          // After cancellation is confirmed, show feedback modal\n          setCurrentStep(\"final-feedback\");\n        }}\n      />\n    );\n  }\n\n  if (currentStep === \"final-feedback\") {\n    return (\n      <FeedbackModal\n        open={open}\n        onOpenChange={onOpenChange}\n        reason={selectedReason}\n        feedback={feedback}\n        onFeedbackChange={setFeedback}\n        onBack={handleBack}\n        onContinue={handleFinalFeedbackSubmit}\n        isFinalStep={true} // This is the final feedback\n        loading={loading}\n      />\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/confirm-cancellation-modal.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport { CancellationBaseModal } from \"./reason-base-modal\";\n\ninterface ConfirmCancellationModalProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onConfirmCancellation: () => void;\n}\n\nexport function ConfirmCancellationModal({\n  open,\n  onOpenChange,\n  onConfirmCancellation,\n}: ConfirmCancellationModalProps) {\n  const [loading, setLoading] = useState(false);\n  const { currentTeamId } = useTeam();\n  const { plan: currentPlan, endsAt } = usePlan();\n  const analytics = useAnalytics();\n\n  const handleConfirmCancellation = async () => {\n    if (!currentTeamId) return;\n\n    setLoading(true);\n\n    // If we have a callback, call it instead of redirecting\n\n    try {\n      // Still make the API call to get the cancellation URL for future use\n      const response = await fetch(\n        `/api/teams/${currentTeamId}/billing/cancel`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (response.ok) {\n        // track the event\n        analytics.capture(\"Subscription Cancelled\", {\n          teamId: currentTeamId,\n          plan: currentPlan,\n          endsAt: endsAt,\n        });\n\n        // Call the callback instead of redirecting\n        onConfirmCancellation();\n        mutate(`/api/teams/${currentTeamId}/billing/plan`);\n        mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);\n      } else {\n        toast.error(\"Something went wrong\");\n      }\n    } catch (err) {\n      toast.error(\"Something went wrong\");\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const getDescription = `Your subscription will end on ${new Date(\n    endsAt!,\n  ).toLocaleDateString(\"en-US\", {\n    month: \"long\",\n    day: \"numeric\",\n    year: \"numeric\",\n  })}`;\n\n  return (\n    <CancellationBaseModal\n      open={open}\n      onOpenChange={onOpenChange}\n      title=\"Confirm cancellation\"\n      description={getDescription}\n      showKeepButton={true}\n      confirmButton={\n        <Button\n          variant=\"destructive\"\n          onClick={handleConfirmCancellation}\n          loading={loading}\n        >\n          Confirm cancellation\n        </Button>\n      }\n    >\n      <div>\n        <h3 className=\"mb-4 text-lg font-semibold\">\n          After cancellation, you'll lose access to:\n        </h3>\n        <ul className=\"space-y-2 text-muted-foreground\">\n          <li className=\"flex items-center\">\n            <span className=\"mr-3 h-2 w-2 rounded-full bg-muted-foreground\"></span>\n            All premium features\n          </li>\n          <li className=\"flex items-center\">\n            <span className=\"mr-3 h-2 w-2 rounded-full bg-muted-foreground\"></span>\n            Priority customer support\n          </li>\n          <li className=\"flex items-center\">\n            <span className=\"mr-3 h-2 w-2 rounded-full bg-muted-foreground\"></span>\n            Advanced analytics\n          </li>\n          <li className=\"flex items-center\">\n            <span className=\"mr-3 h-2 w-2 rounded-full bg-muted-foreground\"></span>\n            Team collaboration tools\n          </li>\n        </ul>\n      </div>\n    </CancellationBaseModal>\n  );\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/feedback-modal.tsx",
    "content": "\"use client\";\n\nimport { ArrowLeftIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { CancellationReason } from \"../lib/constants\";\nimport { CancellationBaseModal } from \"./reason-base-modal\";\n\ninterface FeedbackModalProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  reason: CancellationReason;\n  feedback: string;\n  onFeedbackChange: (feedback: string) => void;\n  onBack?: () => void;\n  onContinue: () => void;\n  isFinalStep?: boolean;\n  loading?: boolean;\n}\n\nexport function FeedbackModal({\n  open,\n  onOpenChange,\n  reason,\n  feedback,\n  onFeedbackChange,\n  onBack,\n  onContinue,\n  isFinalStep = false,\n  loading = false,\n}: FeedbackModalProps) {\n  const getTitle = () => {\n    if (isFinalStep) {\n      return \"Sorry to see you go!\";\n    }\n\n    switch (reason) {\n      case \"too_expensive\":\n        return \"Tell us about pricing\";\n      case \"unused\":\n        return \"Help us understand your usage\";\n      case \"missing_features\":\n        return \"What features do you need?\";\n      case \"switched_service\":\n        return \"What's driving your decision?\";\n      case \"other\":\n        return \"Tell us more\";\n      default:\n        return \"Help us understand what we could improve\";\n    }\n  };\n\n  const getSubtitle = () => {\n    if (isFinalStep) {\n      return \"Please share your feedback to help us improve\";\n    }\n\n    switch (reason) {\n      case \"too_expensive\":\n        return \"What would make the pricing work better for your budget?\";\n      case \"unused\":\n        return \"Help us understand how we can make Papermark more valuable for your workflow.\";\n      case \"missing_features\":\n        return \"What features are you missing? This helps us prioritize our roadmap.\";\n      case \"switched_service\":\n        return \"What does the competitor offer that we don't? This helps us improve.\";\n      case \"other\":\n        return \"We'd love to hear your specific feedback.\";\n      default:\n        return \"Help us understand what we could improve\";\n    }\n  };\n\n  const getPlaceholder = () => {\n    if (isFinalStep) {\n      return \"Help us understand what we could improve...\";\n    }\n\n    switch (reason) {\n      case \"too_expensive\":\n        return \"Tell us about your budget constraints or what pricing would work...\";\n      case \"unused\":\n        return \"Share how you use Papermark and what would make it more valuable...\";\n      case \"missing_features\":\n        return \"Tell us about the features you need...\";\n      case \"switched_service\":\n        return \"Tell us about the competitor and what attracted you to them...\";\n      case \"other\":\n        return \"Share your thoughts...\";\n      default:\n        return \"Help us understand what we could improve...\";\n    }\n  };\n\n  const cancelButton = (isFinalStep: boolean) => {\n    if (isFinalStep) {\n      return null;\n    } else {\n      return (\n        <Button\n          variant=\"ghost\"\n          onClick={() => onOpenChange(false)}\n          className=\"flex items-center gap-2\"\n        >\n          <ArrowLeftIcon className=\"h-4 w-4\" />\n          Back\n        </Button>\n      );\n    }\n  };\n\n  const proceedButton = (isFinalStep: boolean) => {\n    if (isFinalStep) {\n      return (\n        <Button className=\"ml-auto\" onClick={onContinue} loading={loading}>\n          Submit\n        </Button>\n      );\n    } else {\n      return <Button onClick={onContinue}>Submit</Button>;\n    }\n  };\n\n  return (\n    <CancellationBaseModal\n      open={open}\n      onOpenChange={onOpenChange}\n      title={getTitle()}\n      description={getSubtitle()}\n      cancelButton={cancelButton(isFinalStep)}\n      proceedButton={proceedButton(isFinalStep)}\n    >\n      <Textarea\n        placeholder={getPlaceholder()}\n        value={feedback}\n        onChange={(e) => onFeedbackChange(e.target.value)}\n        rows={6}\n        className=\"resize-none bg-white dark:bg-gray-800\"\n      />\n    </CancellationBaseModal>\n  );\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/index.ts",
    "content": "export { PauseSubscriptionModal } from \"./pause-subscription-modal\";\nexport { RetentionOfferModal } from \"./retention-offer-modal\";\nexport { FeedbackModal } from \"./feedback-modal\";\nexport { ConfirmCancellationModal } from \"./confirm-cancellation-modal\";\nexport { ScheduleCallModal } from \"./schedule-call-modal\";\nexport { CancellationModal } from \"./cancellation-modal\";\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/pause-subscription-modal.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ArrowLeft, CheckCircle2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { timeIn } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\nimport { Modal } from \"@/components/ui/modal\";\n\ninterface PauseSubscriptionModalProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onBack: () => void;\n  onDecline: () => void;\n  onClose: () => void;\n}\n\nexport function PauseSubscriptionModal({\n  open,\n  onOpenChange,\n  onBack,\n  onDecline,\n  onClose,\n}: PauseSubscriptionModalProps) {\n  const [loading, setLoading] = useState(false);\n  const { currentTeamId } = useTeam();\n  const { endsAt, plan } = usePlan();\n  const analytics = useAnalytics();\n\n  const handlePauseSubscription = async () => {\n    if (!currentTeamId) return;\n    setLoading(true);\n\n    try {\n      const response = await fetch(\n        `/api/teams/${currentTeamId}/billing/pause`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to pause subscription\");\n      }\n\n      const data = await response.json();\n\n      // Track the pause event for analytics using actual dates from API\n      analytics.capture(\"Subscription Paused\", {\n        teamId: currentTeamId,\n        plan: plan,\n        pauseStartsAt: data.pauseStartsAt,\n        pauseEndsAt: data.pauseEndsAt,\n        pauseDurationDays: 90,\n      });\n\n      toast.success(\"Subscription paused successfully!\");\n      mutate(`/api/teams/${currentTeamId}/billing/plan`);\n      mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);\n      onClose();\n    } catch (error) {\n      console.error(\"Error pausing subscription:\", error);\n      toast.error(\"Failed to pause subscription. Please try again.\");\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  // Ensure pauseStartsAt is never in the past - pause should start at next billing cycle\n  const now = new Date();\n  const endsAtDate = endsAt ? new Date(endsAt) : now;\n  const pauseStartsAt = endsAtDate > now ? endsAtDate : now;\n  const pauseEndsAt = new Date(pauseStartsAt);\n  // Use 3 calendar months instead of 90 days to properly align with billing cycles\n  pauseEndsAt.setMonth(pauseStartsAt.getMonth() + 3);\n\n  const pauseStartsAtString = new Date(pauseStartsAt).toLocaleDateString(\n    \"en-US\",\n    {\n      month: \"long\",\n      day: \"numeric\",\n      year:\n        new Date(pauseStartsAt).getFullYear() === new Date().getFullYear()\n          ? undefined\n          : \"numeric\",\n    },\n  );\n\n  const pauseEndsAtString = new Date(pauseEndsAt).toLocaleDateString(\"en-US\", {\n    month: \"long\",\n    day: \"numeric\",\n    year:\n      new Date(pauseEndsAt).getFullYear() === new Date().getFullYear()\n        ? undefined\n        : \"numeric\",\n  });\n\n  return (\n    <Modal\n      showModal={open}\n      setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {\n        if (typeof show === \"function\") {\n          onOpenChange(show(open));\n        } else {\n          onOpenChange(show);\n        }\n      }}\n      className=\"max-w-lg\"\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <DialogTitle className=\"text-2xl font-semibold\">\n          Pause your subscription\n        </DialogTitle>\n        <DialogDescription className=\"text-center text-base text-muted-foreground\">\n          Take a break for 3 months and resume when you're ready\n        </DialogDescription>\n      </div>\n\n      <div className=\"px-4 py-4 dark:bg-gray-900 sm:px-8\">\n        <div className=\"space-y-6\">\n          <div className=\"rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-900/10\">\n            <div className=\"mb-4 text-base font-medium text-green-800 dark:text-green-700\">\n              What pausing means for you?\n            </div>\n            <div className=\"space-y-3\">\n              <div className=\"flex items-center gap-3\">\n                <CheckCircle2 className=\"h-5 w-5 text-green-600 dark:text-green-500\" />\n                <span className=\"text-sm font-medium text-green-800 dark:text-green-700\">\n                  You pay $0 for 3 months\n                </span>\n              </div>\n              <div className=\"flex items-center gap-3\">\n                <CheckCircle2 className=\"h-5 w-5 text-green-600 dark:text-green-500\" />\n                <span className=\"text-sm font-medium text-green-800 dark:text-green-700\">\n                  All your links continue to work\n                </span>\n              </div>\n              <div className=\"flex items-center gap-3\">\n                <CheckCircle2 className=\"h-5 w-5 text-green-600 dark:text-green-500\" />\n                <span className=\"text-sm font-medium text-green-800 dark:text-green-700\">\n                  All your datarooms are accessible\n                </span>\n              </div>\n            </div>\n          </div>\n\n          <Button\n            onClick={handlePauseSubscription}\n            className=\"w-full\"\n            size=\"lg\"\n            loading={loading}\n          >\n            Pause for 3 months\n          </Button>\n\n          {/* Timeline information */}\n          <div className=\"space-y-3 border-t pt-4\">\n            <div className=\"space-y-2 text-sm text-muted-foreground\">\n              <div className=\"flex items-center justify-between\">\n                <span>Pause starts:</span>\n                <span className=\"font-medium\">\n                  {pauseStartsAtString}{\" \"}\n                  <span className=\"italic\">({timeIn(pauseStartsAt)})</span>\n                </span>\n              </div>\n              <div className=\"flex items-center justify-between\">\n                <span>Reminder email:</span>\n                <span className=\"font-medium\">3 days before resume</span>\n              </div>\n              <div className=\"flex items-center justify-between\">\n                <span>Auto-resume date:</span>\n                <span className=\"font-medium\">{pauseEndsAtString}</span>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"flex items-center justify-between border-t pt-4\">\n            <Button\n              variant=\"ghost\"\n              onClick={onBack}\n              className=\"flex items-center gap-2\"\n            >\n              <ArrowLeft className=\"h-4 w-4\" />\n              Back\n            </Button>\n            <Button variant=\"outline\" onClick={onDecline}>\n              Continue to cancellation\n            </Button>\n          </div>\n        </div>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/reason-base-modal.tsx",
    "content": "import { ArrowLeftIcon } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { DialogDescription, DialogTitle } from \"@/components/ui/dialog\";\nimport { Modal } from \"@/components/ui/modal\";\n\nexport function CancellationBaseModal({\n  open,\n  onOpenChange,\n  children,\n  title,\n  description,\n  onBack,\n  onDecline,\n  onConfirm,\n  confirmButton,\n  showKeepButton,\n  cancelButton,\n  proceedButton,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  children: React.ReactNode;\n  title: string;\n  description: string;\n  onBack?: () => void;\n  onDecline?: () => void;\n  onConfirm?: () => void;\n  confirmButton?: React.ReactNode;\n  showKeepButton?: boolean;\n  cancelButton?: React.ReactNode;\n  proceedButton?: React.ReactNode;\n}) {\n  return (\n    <Modal\n      showModal={open}\n      setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {\n        if (typeof show === \"function\") {\n          onOpenChange(show(open));\n        } else {\n          onOpenChange(show);\n        }\n      }}\n      className=\"max-w-lg\"\n    >\n      <div className=\"flex flex-col items-center justify-center space-y-2 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8\">\n        <DialogTitle className=\"text-2xl font-semibold\">{title}</DialogTitle>\n        <DialogDescription className=\"max-w-md text-center text-base text-muted-foreground\">\n          {description}\n        </DialogDescription>\n      </div>\n      <div className=\"space-y-6 bg-white px-4 py-6 dark:bg-gray-900 sm:px-8\">\n        <div className=\"space-y-6\">{children}</div>\n        <div className=\"flex items-center justify-between border-t pt-4\">\n          {cancelButton}\n          {proceedButton}\n          {onBack && (\n            <Button\n              variant=\"ghost\"\n              onClick={onBack}\n              className=\"flex items-center gap-2\"\n            >\n              <ArrowLeftIcon className=\"h-4 w-4\" />\n              Back\n            </Button>\n          )}\n          {showKeepButton && (\n            <Button\n              variant=\"outline\"\n              onClick={() => onOpenChange(false)}\n              className=\"flex items-center gap-2\"\n            >\n              Stay on Papermark\n            </Button>\n          )}\n          {onDecline && (\n            <Button variant=\"outline\" onClick={onDecline}>\n              Decline offer\n            </Button>\n          )}\n          {confirmButton}\n        </div>\n      </div>\n    </Modal>\n  );\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/retention-offer-modal.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PLANS } from \"@/ee/stripe/utils\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { CancellationReason } from \"../lib/constants\";\nimport { CancellationBaseModal } from \"./reason-base-modal\";\n\ninterface RetentionOfferModalProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  reason: CancellationReason;\n  onBack: () => void;\n  onDecline: () => void;\n  onClose: () => void;\n}\n\nexport function RetentionOfferModal({\n  open,\n  onOpenChange,\n  reason,\n  onBack,\n  onDecline,\n  onClose,\n}: RetentionOfferModalProps) {\n  const [loading, setLoading] = useState(false);\n  const { currentTeamId } = useTeam();\n  const router = useRouter();\n  const { plan: userPlan, isAnnualPlan } = usePlan();\n  const { limits } = useLimits();\n\n  const currentQuantity = limits?.users ?? 1;\n\n  const calculateSavings = () => {\n    // Find current plan pricing\n    const currentPlan = PLANS.find((p) => p.slug === userPlan);\n    if (!currentPlan) return { savings: \"€0\" };\n\n    const monthlyPrice = currentPlan.price.monthly.unitPrice;\n    const yearlyPrice = currentPlan.price.yearly.unitPrice;\n\n    // Simple logic: 30% discount for 3 months (monthly) or 12 months (annual)\n    const discountPercent = 0.3;\n    const durationMonths = isAnnualPlan ? 12 : 3;\n    const basePrice = isAnnualPlan ? yearlyPrice : monthlyPrice;\n\n    // Calculate savings\n    const totalSavings = Math.round(\n      (basePrice * durationMonths * discountPercent * currentQuantity) / 100,\n    );\n\n    return { savings: `€${totalSavings}` };\n  };\n\n  const getOfferDetails = () => {\n    const { savings } = calculateSavings();\n\n    if (isAnnualPlan) {\n      return {\n        title: \"Special offer just for you\",\n        subtitle: \"Let us make this work for your budget\",\n        discount: \"30% off your next year\",\n        savings,\n        duration: \"12 months\",\n      };\n    } else {\n      return {\n        title: \"Special offer just for you\",\n        subtitle: \"Let us make this work for your budget\",\n        discount: \"30% off for the next 3 months\",\n        savings,\n        duration: \"3 months\",\n      };\n    }\n  };\n\n  const offerDetails = getOfferDetails();\n\n  const handleAcceptOffer = async () => {\n    if (!currentTeamId) return;\n\n    setLoading(true);\n\n    await fetch(`/api/teams/${currentTeamId}/billing/retention-offer`, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        isAnnualPlan,\n      }),\n    })\n      .then(async (res) => {\n        const data = await res.json();\n        if (data.success) {\n          mutate(`/api/teams/${currentTeamId}/billing/plan`);\n          mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);\n          onClose();\n          toast.success(\n            `30% discount applied for ${isAnnualPlan ? \"12 months\" : \"3 months\"}!`,\n          );\n        }\n      })\n      .catch((err) => {\n        toast.error(\"Something went wrong\");\n      })\n      .finally(() => {\n        setLoading(false);\n      });\n  };\n\n  return (\n    <CancellationBaseModal\n      open={open}\n      onOpenChange={onOpenChange}\n      title={offerDetails.title}\n      description={offerDetails.subtitle}\n      showKeepButton={true}\n      // onBack={onBack}\n      onDecline={onDecline}\n    >\n      <div className=\"flex items-center justify-between\">\n        <Badge\n          variant=\"secondary\"\n          className=\"border-blue-200 bg-blue-50 text-blue-700\"\n        >\n          Special offer\n        </Badge>\n        <span className=\"text-sm text-muted-foreground\">Limited time</span>\n      </div>\n\n      <div className=\"text-center\">\n        <h3 className=\"mb-2 text-xl font-semibold\">{offerDetails.discount}</h3>\n      </div>\n\n      <div className=\"rounded-lg border border-green-200 bg-green-50 p-6 text-center\">\n        <div className=\"mb-2 text-2xl font-bold text-green-800\">\n          You save {offerDetails.savings}\n        </div>\n        <div className=\"text-sm font-medium text-green-700\">\n          Over {offerDetails.duration}\n        </div>\n      </div>\n\n      <Button\n        onClick={handleAcceptOffer}\n        className=\"w-full\"\n        size=\"lg\"\n        loading={loading}\n      >\n        Accept this offer\n      </Button>\n    </CancellationBaseModal>\n  );\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/components/schedule-call-modal.tsx",
    "content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\nimport Cal, { getCalApi } from \"@calcom/embed-react\";\nimport { useSession } from \"next-auth/react\";\n\nimport { CancellationReason } from \"../lib/constants\";\nimport { CancellationBaseModal } from \"./reason-base-modal\";\n\ninterface ScheduleCallModalProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  reason: CancellationReason;\n  onDecline: () => void;\n}\n\nexport function ScheduleCallModal({\n  open,\n  onOpenChange,\n  reason,\n  onDecline,\n}: ScheduleCallModalProps) {\n  const [calLoaded, setCalLoaded] = useState(false);\n  const { data: session } = useSession();\n\n  useEffect(() => {\n    if (open) {\n      (async function () {\n        try {\n          const cal = await getCalApi({ namespace: \"papermark-support\" });\n          cal(\"ui\", { hideEventTypeDetails: true, layout: \"month_view\" });\n          setCalLoaded(true);\n        } catch (error) {\n          console.error(\"Error loading Cal.com:\", error);\n          setCalLoaded(true); // Set to true anyway to show fallback\n        }\n      })();\n    }\n  }, [open]);\n\n  const getTitle = () => {\n    switch (reason) {\n      case \"missing_features\":\n        return \"Let's talk about what you need\";\n      default:\n        return \"Schedule a consultation call\";\n    }\n  };\n\n  const getSubtitle = () => {\n    switch (reason) {\n      case \"missing_features\":\n        return \"Our team will reach out to understand your requirements\";\n      default:\n        return \"We'd love to understand how we can better serve you\";\n    }\n  };\n\n  return (\n    <CancellationBaseModal\n      open={open}\n      onOpenChange={onOpenChange}\n      title={getTitle()}\n      description={getSubtitle()}\n      showKeepButton={true}\n      onDecline={onDecline}\n    >\n      <div className=\"rounded-lg border border-green-200 bg-green-50 p-4\">\n        <div className=\"mb-2 text-lg font-semibold text-green-800\">\n          Free consultation\n        </div>\n        <div className=\"text-sm text-green-700\">\n          30-minute call • within 24 hours\n        </div>\n      </div>\n\n      {/* Cal.com embed container */}\n      <div className=\"max-h-[540px] rounded-lg border bg-white dark:bg-gray-800\">\n        {calLoaded ? (\n          <Cal\n            namespace=\"papermark-support\"\n            calLink={`marcseitz/papermark-support?email=${session?.user?.email || \"\"}&name=${session?.user?.name || \"\"}`}\n            style={{\n              width: \"100%\",\n              height: \"540px\",\n              overflow: \"scroll\",\n            }}\n            config={{ layout: \"month_view\" }}\n          />\n        ) : (\n          <div className=\"flex h-[500px] items-center justify-center\">\n            <div className=\"text-center\">\n              <div className=\"mb-2 text-lg font-semibold\">\n                Loading calendar...\n              </div>\n              <div className=\"text-sm text-muted-foreground\">\n                Please wait while we load the booking calendar\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    </CancellationBaseModal>\n  );\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/constants.ts",
    "content": "export const PAUSE_COUPON_ID = {\n  old: { prod: \"VT9t1aRY\", test: \"3Fs2L1Tq\" },\n  new: { prod: \"V1uIzDQU\", test: \"3Fs2L1Tq\" },\n};\n"
  },
  {
    "path": "ee/features/billing/cancellation/emails/components/pause-resume-reminder.tsx",
    "content": "import {\n  Body,\n  Button,\n  Container,\n  Head,\n  Heading,\n  Hr,\n  Html,\n  Img,\n  Link,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\ninterface PauseResumeReminderEmailProps {\n  teamName?: string;\n  userName?: string;\n  resumeDate?: string;\n  plan?: string;\n  userRole?: string;\n}\n\nconst baseUrl = process.env.NEXT_PUBLIC_BASE_URL || \"https://app.papermark.com\";\n\nexport default function PauseResumeReminderEmail({\n  teamName = \"Your Team\",\n  resumeDate = \"March 15, 2024\",\n  plan = \"Pro\",\n}: PauseResumeReminderEmailProps) {\n  const previewText = `${teamName}'s paused subscription will resume billing soon`;\n\n  return (\n    <Html>\n      <Head />\n      <Preview>{previewText}</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-xl font-semibold\">\n              Subscription Resume Reminder\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              This is a friendly reminder that your{\" \"}\n              <span className=\"font-semibold\">{teamName}</span> team's paused\n              subscription will automatically resume billing in{\" \"}\n              <span className=\"font-semibold\">3 days</span>.\n            </Text>\n\n            <Text className=\"text-sm font-semibold leading-6 text-black\">\n              What happens next?\n            </Text>\n            <Text className=\"break-all text-sm leading-6 text-black\">\n              <ul>\n                <li className=\"text-sm leading-6 text-black\">\n                  Your subscription will resume on{\" \"}\n                  <span className=\"font-semibold\">{resumeDate}</span>\n                </li>\n                <li className=\"text-sm leading-6 text-black\">\n                  Billing will restart at your{\" \"}\n                  <span className=\"font-semibold\">{plan}</span> plan rate\n                </li>\n                <li className=\"text-sm leading-6 text-black\">\n                  All features will be fully restored\n                </li>\n                <li className=\"text-sm leading-6 text-black\">\n                  Your existing data and links remain unchanged\n                </li>\n              </ul>\n            </Text>\n\n            <Text className=\"text-sm font-semibold leading-6 text-black\">\n              Need to make changes?\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              If you'd like to cancel your subscription or need to update your\n              billing information, you can manage your subscription in your{\" \"}\n              <span className=\"font-semibold\">account settings</span>.\n            </Text>\n\n            <Section className=\"my-8 text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${baseUrl}/settings/billing`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                Manage Subscription\n              </Button>\n            </Section>\n\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc.\n              </Text>\n              <Text className=\"text-xs\">\n                If you have any feedback or questions about this email, simply\n                reply to it.\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/emails/lib/send-email-pause-resume-reminder.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport PauseResumeReminderEmail from \"../components/pause-resume-reminder\";\n\nexport const sendEmailPauseResumeReminder = async ({\n  teamName,\n  plan,\n  resumeDate,\n  teamMemberEmails,\n}: {\n  teamName: string;\n  plan: string;\n  resumeDate: string;\n  teamMemberEmails: string[];\n}) => {\n  try {\n    if (!teamMemberEmails || teamMemberEmails.length === 0) {\n      console.log(\"No team member emails provided\");\n      return;\n    }\n\n    await sendEmail({\n      to: teamMemberEmails[0], // Send to first team member\n      cc: teamMemberEmails.slice(1).join(\",\"), // Send to all other team members\n      subject: \"Your Papermark subscription will resume in 3 days\",\n      react: PauseResumeReminderEmail({\n        teamName,\n        plan,\n        resumeDate,\n      }),\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(\"Failed to send team member notification:\", e);\n  }\n};\n"
  },
  {
    "path": "ee/features/billing/cancellation/lib/constants.ts",
    "content": "export type CancellationReason =\n  | \"too_expensive\"\n  | \"unused\"\n  | \"missing_features\"\n  | \"switched_service\"\n  | \"other\";\n"
  },
  {
    "path": "ee/features/billing/cancellation/lib/is-team-paused.ts",
    "content": "import { Team } from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\n\n/**\n * Check if a team is currently paused\n * A team is considered paused if:\n * 1. pausedAt is set (team was paused)\n * 2. Current time is within the pause period (between pauseStartsAt and pauseEndsAt)\n */\nexport function isTeamPaused(\n  team: Pick<Team, \"pausedAt\" | \"pauseStartsAt\" | \"pauseEndsAt\">,\n): boolean {\n  if (!team.pausedAt || !team.pauseStartsAt || !team.pauseEndsAt) {\n    return false;\n  }\n\n  const now = new Date();\n  const pauseStartsAt = new Date(team.pauseStartsAt);\n  const pauseEndsAt = new Date(team.pauseEndsAt);\n\n  // Team is paused if current time is within the pause period\n  return now >= pauseStartsAt && now <= pauseEndsAt;\n}\n\n/**\n * Check if a team is currently paused (async version that fetches team data)\n */\nexport async function isTeamPausedById(teamId: string): Promise<boolean> {\n  const team = await prisma.team.findUnique({\n    where: { id: teamId },\n    select: {\n      pausedAt: true,\n      pauseStartsAt: true,\n      pauseEndsAt: true,\n    },\n  });\n\n  if (!team) {\n    return false;\n  }\n\n  return isTeamPaused(team);\n}\n"
  },
  {
    "path": "ee/features/billing/cancellation/lib/trigger/pause-resume-notification.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\n\nimport prisma from \"@/lib/prisma\";\n\ntype PauseResumeNotificationPayload = {\n  teamId: string;\n};\n\nexport const sendPauseResumeNotificationTask = task({\n  id: \"send-pause-resume-notification\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: PauseResumeNotificationPayload) => {\n    logger.info(\"Starting pause resume notification\", {\n      teamId: payload.teamId,\n    });\n\n    // Verify the team is still paused and get team details\n    const team = await prisma.team.findUnique({\n      where: {\n        id: payload.teamId,\n      },\n      select: {\n        id: true,\n        name: true,\n        plan: true,\n        pauseEndsAt: true,\n        pausedAt: true,\n        users: {\n          where: {\n            role: {\n              in: [\"ADMIN\", \"MANAGER\"],\n            },\n            // Only active team members (not blocked)\n            blockedAt: null,\n          },\n          select: {\n            userId: true,\n            role: true,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      logger.error(\"Team not found for pause resume notification\", {\n        teamId: payload.teamId,\n      });\n      return;\n    }\n\n    // Check if team is still paused\n    if (!team.pausedAt || !team.pauseEndsAt) {\n      logger.info(\"Team is no longer paused, skipping notification\", {\n        teamId: payload.teamId,\n        pausedAt: team.pausedAt,\n        pauseEndsAt: team.pauseEndsAt,\n      });\n      return;\n    }\n\n    if (team.users.length === 0) {\n      logger.info(\"No admin/manager users found for team\", {\n        teamId: payload.teamId,\n        teamName: team.name,\n      });\n      return;\n    }\n\n    try {\n      const response = await fetch(\n        `${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-pause-resume-notification`,\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            teamId: payload.teamId,\n          }),\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n          },\n        },\n      );\n\n      if (!response.ok) {\n        logger.error(\"Failed to send pause resume notification\", {\n          teamId: payload.teamId,\n          error: await response.text(),\n        });\n      }\n\n      const { message } = (await response.json()) as { message: string };\n      logger.info(\"Pause resume notification sent successfully\", {\n        teamId: payload.teamId,\n        message,\n      });\n    } catch (error) {\n      logger.error(\"Error sending pause resume notification\", {\n        teamId: payload.teamId,\n        error,\n      });\n    }\n\n    logger.info(\"Completed pause resume notifications\", {\n      teamId: payload.teamId,\n    });\n\n    return;\n  },\n});\n"
  },
  {
    "path": "ee/features/billing/cancellation/lib/trigger/unpause-task.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\n\nexport const automaticUnpauseTask = task({\n  id: \"automatic-unpause-subscription\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: { teamId: string }) => {\n    logger.info(\"Starting automatic unpause\", {\n      teamId: payload.teamId,\n    });\n\n    const internalApiKey = process.env.INTERNAL_API_KEY;\n    if (!internalApiKey) {\n      logger.error(\"INTERNAL_API_KEY environment variable not set\");\n      throw new Error(\"Internal API key not configured\");\n    }\n\n    try {\n      // Call the internal API endpoint to perform the automatic unpause\n      const response = await fetch(\n        `${process.env.NEXTAUTH_URL}/api/internal/billing/automatic-unpause`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${internalApiKey}`,\n          },\n          body: JSON.stringify({\n            teamId: payload.teamId,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({}));\n        logger.error(\"Internal API call failed\", {\n          teamId: payload.teamId,\n          status: response.status,\n          statusText: response.statusText,\n          errorData,\n        });\n\n        // Don't retry if it's a client error (4xx) - these are usually permanent\n        if (response.status >= 400 && response.status < 500) {\n          logger.info(\"Skipping retry for client error\", {\n            teamId: payload.teamId,\n            status: response.status,\n          });\n          return;\n        }\n\n        throw new Error(\n          `Internal API call failed: ${response.status} ${response.statusText}`,\n        );\n      }\n\n      const result = await response.json();\n      logger.info(\"Successfully automatically unpaused subscription\", {\n        teamId: payload.teamId,\n        teamName: result.teamName,\n      });\n    } catch (error) {\n      logger.error(\"Failed to automatically unpause subscription\", {\n        teamId: payload.teamId,\n        error: error instanceof Error ? error.message : String(error),\n      });\n\n      throw error; // Re-throw to trigger retry\n    }\n  },\n});\n"
  },
  {
    "path": "ee/features/billing/renewal-reminder/emails/subscription-renewal-reminder.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"@/components/emails/shared/footer\";\n\ninterface SubscriptionRenewalReminderEmailProps {\n  renewalDate: string;\n  isOldAccount: boolean;\n}\n\nconst SubscriptionRenewalReminderEmail = ({\n  renewalDate,\n  isOldAccount,\n}: SubscriptionRenewalReminderEmailProps) => {\n  return (\n    <Html>\n      <Head />\n      <Preview>Your Papermark subscription renews soon</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans text-sm\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"text-2xl font-bold\">\n              Is your payment information up to date?\n            </Text>\n            <Text>\n              Another year has come and gone, which means your annual Papermark\n              subscription will automatically renew on {renewalDate}.\n            </Text>\n            <Text>\n              We know many things can change in a year, so if you could ask your\n              team admin to make sure the payment method listed on your account\n              is up to date, that would be great.\n            </Text>\n            <Text>\n              If you need to update your billing details or have any questions,\n              please visit your{\" \"}\n              <a\n                href=\"https://app.papermark.com/settings/billing\"\n                className=\"underline\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n              >\n                billing settings\n              </a>\n              .\n            </Text>\n            {isOldAccount ? (\n              <>\n                <Hr />\n                <Text className=\"text-gray-400\">Papermark Team</Text>\n              </>\n            ) : (\n              <Footer />\n            )}\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n};\n\nexport default SubscriptionRenewalReminderEmail;\n"
  },
  {
    "path": "ee/features/billing/renewal-reminder/lib/send-subscription-renewal-reminder.ts",
    "content": "import SubscriptionRenewalReminderEmail from \"@/ee/features/billing/renewal-reminder/emails/subscription-renewal-reminder\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendSubscriptionRenewalReminderEmail = async (params: {\n  customerEmail: string;\n  renewalDate: string;\n  isOldAccount: boolean;\n}) => {\n  const { customerEmail, renewalDate, isOldAccount } = params;\n\n  const emailTemplate = SubscriptionRenewalReminderEmail({\n    renewalDate,\n    isOldAccount,\n  });\n\n  try {\n    await sendEmail({\n      to: customerEmail,\n      subject: \"Is your payment information up to date?\",\n      react: emailTemplate,\n      system: true,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(\"Error sending subscription renewal reminder email:\", e);\n    throw e;\n  }\n};\n"
  },
  {
    "path": "ee/features/conversations/api/conversations-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { runs } from \"@trigger.dev/sdk/v3\";\nimport { waitUntil } from \"@vercel/functions\";\n\nimport prisma from \"@/lib/prisma\";\n\nimport {\n  CreateConversationInput,\n  conversationService,\n} from \"../lib/api/conversations\";\nimport { messageService } from \"../lib/api/messages\";\nimport { notificationService } from \"../lib/api/notifications\";\nimport { sendConversationTeamMemberNotificationTask } from \"../lib/trigger/conversation-message-notification\";\n\n// Route mapping object to handle different paths\nconst routeHandlers = {\n  // GET /api/conversations\n  \"GET /\": async (req: NextApiRequest, res: NextApiResponse) => {\n    // Handle listing conversations\n    const { dataroomId, viewerId } = req.query as {\n      dataroomId?: string;\n      viewerId?: string;\n    };\n\n    if (!dataroomId || !viewerId) {\n      return res.status(400).json({ error: \"Missing dataroomId or viewerId\" });\n    }\n\n    const conversations = await prisma.conversation.findMany({\n      where: {\n        dataroomId,\n        participants: {\n          some: {\n            viewerId,\n          },\n        },\n      },\n      include: {\n        messages: {\n          orderBy: {\n            createdAt: \"asc\",\n          },\n        },\n        dataroom: true,\n        dataroomDocument: {\n          include: {\n            document: true,\n          },\n        },\n        participants: {\n          where: {\n            viewerId,\n          },\n          select: {\n            receiveNotifications: true,\n          },\n          take: 1,\n        },\n      },\n      orderBy: {\n        updatedAt: \"desc\",\n      },\n    });\n\n    const conversationsWithNotifications = conversations.map(\n      (conversation) => ({\n        ...conversation,\n        receiveNotifications: conversation.participants[0].receiveNotifications,\n      }),\n    );\n\n    return res.status(200).json(conversationsWithNotifications);\n  },\n\n  // POST /api/conversations\n  \"POST /\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const { dataroomId, viewId, viewerId, documentId, pageNumber, ...data } =\n      req.body as CreateConversationInput & {\n        dataroomId: string;\n        viewId: string;\n        viewerId?: string;\n        documentId?: string;\n        pageNumber?: number;\n      };\n\n    // Check if conversations are allowed\n    const areAllowed = await conversationService.areConversationsAllowed(\n      dataroomId,\n      data.linkId,\n    );\n\n    if (!areAllowed) {\n      return res.status(403).json({\n        error: \"Conversations are disabled for this dataroom or link\",\n      });\n    }\n\n    // Check if viewerId is provided\n    if (!viewerId) {\n      return res.status(400).json({ error: \"Viewer is required\" });\n    }\n\n    const team = await prisma.team.findFirst({\n      where: {\n        datarooms: {\n          some: {\n            id: dataroomId,\n          },\n        },\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!team) {\n      return res.status(400).json({ error: \"Team not found\" });\n    }\n\n    // Map documentId to dataroomDocumentId and get version info if provided\n    let enhancedData = { ...data };\n    if (documentId) {\n      const dataroomDocument = await prisma.dataroomDocument.findFirst({\n        where: {\n          dataroomId,\n          documentId,\n        },\n        include: {\n          document: {\n            include: {\n              versions: {\n                where: { isPrimary: true },\n                select: { versionNumber: true },\n              },\n            },\n          },\n        },\n      });\n\n      if (dataroomDocument) {\n        enhancedData.dataroomDocumentId = dataroomDocument.id;\n\n        // Set page number if provided\n        if (pageNumber) {\n          enhancedData.documentPageNumber = pageNumber;\n        }\n\n        // Set document version number from the primary version\n        if (dataroomDocument.document.versions[0]?.versionNumber) {\n          enhancedData.documentVersionNumber =\n            dataroomDocument.document.versions[0].versionNumber;\n        }\n      }\n    }\n\n    // Create the conversation\n    const conversation = await conversationService.createConversation({\n      dataroomId,\n      viewerId,\n      viewId,\n      teamId: team.id,\n      data: enhancedData,\n    });\n\n    // Get all delayed and queued runs for this dataroom\n    const allRuns = await runs.list({\n      taskIdentifier: [\"send-conversation-team-member-notification\"],\n      tag: [`conversation_${conversation.id}`],\n      status: [\"DELAYED\", \"QUEUED\"],\n      period: \"5m\",\n    });\n\n    // Cancel any existing unsent notification runs for this dataroom\n    await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));\n\n    waitUntil(\n      sendConversationTeamMemberNotificationTask.trigger(\n        {\n          dataroomId,\n          messageId: conversation.messages[0].id,\n          conversationId: conversation.id,\n          senderUserId: viewerId,\n          teamId: team.id,\n        },\n        {\n          idempotencyKey: `conversation-notification-${team.id}-${dataroomId}-${conversation.id}-${conversation.messages[0].id}`,\n          tags: [\n            `team_${team.id}`,\n            `dataroom_${dataroomId}`,\n            `conversation_${conversation.id}`,\n          ],\n          delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay\n        },\n      ),\n    );\n\n    return res.status(201).json(conversation);\n  },\n\n  // POST /api/conversations/messages\n  \"POST /messages\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const { content, viewId, viewerId, conversationId } = req.body as {\n      content: string;\n      viewId: string;\n      viewerId: string;\n      conversationId: string;\n    };\n\n    if (!content || content.trim() === \"\") {\n      return res.status(400).json({ error: \"Message content is required\" });\n    }\n\n    // Create the message\n    const message = await messageService.addMessage({\n      conversationId,\n      content,\n      viewId,\n      viewerId,\n    });\n\n    // Get all delayed and queued runs for this dataroom\n    const allRuns = await runs.list({\n      taskIdentifier: [\"send-conversation-team-member-notification\"],\n      tag: [`conversation_${message.conversationId}`],\n      status: [\"DELAYED\", \"QUEUED\"],\n      period: \"5m\",\n    });\n\n    // Cancel any existing unsent notification runs for this dataroom\n    await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));\n\n    waitUntil(\n      sendConversationTeamMemberNotificationTask.trigger(\n        {\n          dataroomId: message.conversation.dataroomId,\n          messageId: message.id,\n          conversationId: message.conversationId,\n          senderUserId: viewerId,\n          teamId: message.conversation.teamId,\n        },\n        {\n          idempotencyKey: `conversation-notification-${message.conversation.teamId}-${message.conversation.dataroomId}-${message.conversationId}-${message.id}`,\n          tags: [\n            `team_${message.conversation.teamId}`,\n            `dataroom_${message.conversation.dataroomId}`,\n            `conversation_${message.conversationId}`,\n          ],\n          delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay\n        },\n      ),\n    );\n\n    return res.status(201).json(message);\n  },\n\n  // POST /api/conversations/notifications\n  \"POST /notifications\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const { enabled, viewerId, conversationId } = req.body as {\n      enabled: boolean;\n      viewerId: string;\n      conversationId: string;\n    };\n\n    await notificationService.toggleNotificationsForConversation({\n      conversationId,\n      viewerId,\n      enabled,\n    });\n\n    return res.status(200).json({ success: true });\n  },\n};\n\n// Main handler that will be imported by the catchall route\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  const { method, query } = req;\n\n  // Construct route key from method and path\n  const path = Array.isArray(query.conversations)\n    ? query.conversations.join(\"/\")\n    : \"\";\n  const routeKey = `${method} /${path}`;\n\n  // Find matching handler\n  const handler = routeHandlers[routeKey as keyof typeof routeHandlers];\n\n  if (!handler) {\n    return res.status(404).json({ error: \"API endpoint not found\" });\n  }\n\n  try {\n    return await handler(req, res);\n  } catch (error) {\n    console.error(\"API Error:\", error);\n    return res.status(500).json({\n      error: error instanceof Error ? error.message : \"Internal server error\",\n    });\n  }\n}\n"
  },
  {
    "path": "ee/features/conversations/api/send-conversation-new-message-notification.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { sendConversationNotification } from \"@/ee/features/conversations/emails/lib/send-conversation-notification\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\nimport { generateUnsubscribeUrl } from \"@/lib/utils/unsubscribe\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  const {\n    linkUrl,\n    conversationId,\n    dataroomId,\n    viewerId,\n    senderUserId,\n    teamId,\n  } = req.body as {\n    linkUrl: string;\n    conversationId: string;\n    dataroomId: string;\n    viewerId: string;\n    senderUserId: string;\n    teamId: string;\n  };\n\n  let viewer: { email: string } | null = null;\n\n  try {\n    // Find the viewer\n    viewer = await prisma.viewer.findUnique({\n      where: {\n        id: viewerId,\n        teamId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!viewer) {\n      res.status(404).json({ message: \"Viewer not found.\" });\n      return;\n    }\n  } catch (error) {\n    log({\n      message: `Failed to find viewer for viewerId: ${viewerId}. \\n\\n Error: ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    res.status(500).json({ message: (error as Error).message });\n    return;\n  }\n\n  // POST /api/jobs/send-conversation-new-message-notification\n  try {\n    // Fetch the conversation to verify the settings\n    const conversation = await prisma.conversation.findUnique({\n      where: {\n        id: conversationId,\n        dataroomId: dataroomId,\n        teamId: teamId,\n      },\n      select: {\n        title: true,\n        dataroom: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n\n    const user = await prisma.user.findUnique({\n      where: { id: senderUserId },\n      select: { email: true },\n    });\n\n    if (!user) {\n      res.status(404).json({ message: \"Sender not found.\" });\n      return;\n    }\n\n    const unsubscribeUrl = generateUnsubscribeUrl({\n      viewerId,\n      dataroomId,\n      teamId,\n    });\n\n    await sendConversationNotification({\n      conversationTitle: conversation?.title || \"\",\n      dataroomName: conversation?.dataroom?.name || \"\",\n      senderEmail: user.email!,\n      to: viewer.email!,\n      url: linkUrl,\n      unsubscribeUrl,\n    });\n\n    res.status(200).json({\n      message: \"Successfully sent dataroom change notification\",\n      viewerId,\n    });\n    return;\n  } catch (error) {\n    log({\n      message: `Failed to send invite email for dataroom ${dataroomId} to viewer: ${viewerId}. \\n\\n Error: ${error} \\n\\n*Metadata*: \\`{dataroomId: ${dataroomId}, viewerId: ${viewerId}}\\``,\n      type: \"error\",\n      mention: true,\n    });\n    return res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "ee/features/conversations/api/send-conversation-team-member-notification.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { sendConversationTeamNotification } from \"@/ee/features/conversations/emails/lib/send-conversation-team-notification\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  // Define validation schema\n  const requestSchema = z.object({\n    conversationId: z\n      .string()\n      .min(1, \"conversationId is required and must be a non-empty string\"),\n    dataroomId: z\n      .string()\n      .min(1, \"dataroomId is required and must be a non-empty string\"),\n    senderUserId: z\n      .string()\n      .min(1, \"senderUserId is required and must be a non-empty string\"),\n    teamId: z\n      .string()\n      .min(1, \"teamId is required and must be a non-empty string\"),\n  });\n\n  // Validate request body\n  const validationResult = requestSchema.safeParse(req.body);\n\n  if (!validationResult.success) {\n    const firstError = validationResult.error.errors[0];\n    res.status(400).json({\n      message: firstError.message,\n      field: firstError.path[0],\n    });\n    return;\n  }\n\n  const { conversationId, dataroomId, senderUserId, teamId } =\n    validationResult.data;\n\n  try {\n    // Get all team members (ADMIN/MANAGER) for this team\n    const teamMembers = await prisma.userTeam.findMany({\n      where: {\n        teamId,\n        role: {\n          in: [\"ADMIN\", \"MANAGER\"],\n        },\n        // Only active team members (not blocked)\n        blockedAt: null,\n      },\n      select: {\n        userId: true,\n        notificationPreferences: true,\n        user: {\n          select: {\n            id: true,\n            email: true,\n          },\n        },\n      },\n    });\n\n    if (!teamMembers || teamMembers.length === 0) {\n      res.status(200).json({\n        message: \"No team members found for notifications\",\n        notified: 0,\n      });\n      return;\n    }\n\n    // Filter out team members who have disabled conversation notifications\n    const eligibleTeamMembers = teamMembers.filter((teamMember) => {\n      if (teamMember.notificationPreferences) {\n        try {\n          const preferences = teamMember.notificationPreferences as {\n            conversations?: boolean;\n          };\n          if (preferences.conversations === false) {\n            return false;\n          }\n        } catch (error) {\n          // If preferences parsing fails, include the team member\n        }\n      }\n      return teamMember.user.email; // Only include if they have an email\n    });\n\n    if (eligibleTeamMembers.length === 0) {\n      res.status(200).json({\n        message: \"No eligible team members for notifications\",\n        notified: 0,\n      });\n      return;\n    }\n\n    // Fetch the conversation and dataroom information\n    const conversation = await prisma.conversation.findUnique({\n      where: {\n        id: conversationId,\n        dataroomId: dataroomId,\n        teamId: teamId,\n      },\n      select: {\n        title: true,\n        dataroom: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n\n    if (!conversation) {\n      res.status(404).json({ message: \"Conversation not found.\" });\n      return;\n    }\n\n    // First try to find as a user\n    const userSender = await prisma.viewer.findUnique({\n      where: { id: senderUserId },\n      select: { email: true },\n    });\n\n    let senderEmail = \"\";\n    if (userSender) {\n      senderEmail = userSender.email;\n    } else {\n      senderEmail = \"Unknown sender\";\n    }\n\n    // Generate the URL for team members to access the conversation\n    const conversationUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/datarooms/${dataroomId}/conversations/${conversationId}`;\n\n    // For team members, provide a generic unsubscribe URL to team settings\n\n    // Get all team member emails\n    const teamMemberEmails = eligibleTeamMembers\n      .map((tm) => tm.user.email)\n      .filter((email): email is string => !!email);\n\n    // Send email to all team members at once\n    await sendConversationTeamNotification({\n      conversationTitle: conversation.title || \"Untitled Conversation\",\n      dataroomName: conversation.dataroom?.name || \"Untitled Dataroom\",\n      senderEmail,\n      teamMemberEmails,\n      url: conversationUrl,\n    });\n\n    res.status(200).json({\n      message: \"Successfully sent conversation notification to team members\",\n      notified: teamMemberEmails.length,\n      teamMemberEmails,\n    });\n    return;\n  } catch (error) {\n    log({\n      message: `Failed to send conversation notification for dataroom ${dataroomId} to team members. \\n\\n Error: ${error} \\n\\n*Metadata*: \\`{dataroomId: ${dataroomId}, teamId: ${teamId}}\\``,\n      type: \"error\",\n      mention: true,\n    });\n    return res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "ee/features/conversations/api/team-conversations-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { runs } from \"@trigger.dev/sdk/v3\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { sendConversationMessageNotificationTask } from \"@/lib/trigger/conversation-message-notification\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport {\n  CreateConversationInput,\n  conversationService,\n} from \"../lib/api/conversations\";\nimport { messageService } from \"../lib/api/messages\";\n\n// Route mapping object to handle different paths\nconst routeHandlers = {\n  // GET /api/teams/[teamId]/datarooms/[dataroomId]/conversations\n  \"GET /\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    try {\n      // For team member/admin view\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            id: teamId,\n            users: { some: { userId } },\n          },\n        },\n        select: {\n          teamId: true,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      const conversations = await prisma.conversation.findMany({\n        where: {\n          dataroomId,\n        },\n        include: {\n          participants: true,\n          messages: {\n            orderBy: {\n              createdAt: \"desc\",\n            },\n            take: 1,\n            select: {\n              content: true,\n              createdAt: true,\n            },\n          },\n          dataroomDocument: {\n            include: {\n              document: true,\n            },\n          },\n          _count: {\n            select: {\n              messages: {\n                where: {\n                  isRead: false,\n                  viewerId: {\n                    not: null,\n                  },\n                },\n              },\n            },\n          },\n        },\n        orderBy: {\n          updatedAt: \"desc\",\n        },\n      });\n\n      const viewerIds = conversations.flatMap((conv: any) =>\n        conv.participants\n          .map((p: any) => p.viewerId)\n          .filter((id: any): id is string => id !== null),\n      );\n\n      const viewers = await prisma.viewer.findMany({\n        where: {\n          id: {\n            in: viewerIds,\n          },\n        },\n        select: {\n          id: true,\n          email: true,\n        },\n      });\n\n      const formattedConversations = conversations.map((conversation: any) => {\n        const participants = conversation.participants.map(\n          (p: any) => p.viewerId,\n        );\n\n        const viewer = viewers.find((v: any) => participants.includes(v.id));\n\n        return {\n          id: conversation.id,\n          title: conversation.title,\n          createdAt: conversation.createdAt,\n          updatedAt: conversation.updatedAt,\n          viewerId: viewer?.id,\n          viewerEmail: viewer?.email,\n          unreadCount: conversation._count.messages,\n          lastMessage: conversation.messages[0] || null,\n          dataroomDocumentName: conversation.dataroomDocumentId\n            ? conversation.dataroomDocument.document.name\n            : undefined,\n          documentPageNumber: conversation.dataroomDocumentId\n            ? conversation.documentPageNumber\n            : undefined,\n          documentVersionNumber: conversation.dataroomDocumentId\n            ? conversation.documentVersionNumber\n            : undefined,\n        };\n      });\n\n      return res.status(200).json(formattedConversations);\n    } catch (error) {\n      console.error(\"Error getting dataroom conversations:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // POST /api/teams/[teamId]/datarooms/[dataroomId]/conversations\n  \"POST /\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const { viewId, viewerId, ...data } =\n      req.body as CreateConversationInput & {\n        viewId: string;\n        viewerId?: string;\n      };\n\n    const userId = (session?.user as CustomUser).id;\n\n    try {\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            id: teamId,\n            users: { some: { userId } },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Check if conversations are allowed\n      const areAllowed = await conversationService.areConversationsAllowed(\n        dataroomId,\n        data.linkId,\n      );\n\n      if (!areAllowed) {\n        return res.status(403).json({\n          error: \"Conversations are disabled for this dataroom or link\",\n        });\n      }\n\n      // Create the conversation\n      const conversation = await conversationService.createConversation({\n        dataroomId,\n        viewId,\n        userId,\n        data,\n        teamId,\n      });\n\n      return res.status(201).json(conversation);\n    } catch (error) {\n      console.error(\"Error creating conversation:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // GET /api/teams/[teamId]/datarooms/[dataroomId]/conversations/summaries\n  \"GET /summaries\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    if (!teamId || !dataroomId) {\n      return res.status(400).json({ message: \"Missing required parameters\" });\n    }\n\n    try {\n      // Check if user has access to the dataroom\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            id: teamId,\n            users: {\n              some: {\n                userId,\n              },\n            },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ message: \"Dataroom not found\" });\n      }\n\n      // Fetch all conversations for the dataroom with minimal data\n      const conversations = await prisma.conversation.findMany({\n        where: {\n          dataroomId: dataroomId as string,\n        },\n        select: {\n          id: true,\n          title: true,\n          createdAt: true,\n          updatedAt: true,\n          participants: {\n            select: {\n              viewerId: true,\n            },\n          },\n          documentPageNumber: true,\n          dataroomDocument: {\n            select: {\n              document: {\n                select: {\n                  name: true,\n                },\n              },\n            },\n          },\n          messages: {\n            orderBy: {\n              createdAt: \"desc\",\n            },\n            take: 1,\n            select: {\n              content: true,\n              createdAt: true,\n            },\n          },\n          _count: {\n            select: {\n              messages: {\n                where: {\n                  isRead: false,\n                  viewerId: {\n                    not: null,\n                  },\n                },\n              },\n            },\n          },\n        },\n        orderBy: {\n          updatedAt: \"desc\",\n        },\n      });\n\n      // Get viewer emails\n      const viewerIds = conversations.flatMap((conv: any) =>\n        conv.participants.map((p: any) => p.viewerId),\n      );\n\n      const viewers = await prisma.viewer.findMany({\n        where: {\n          id: {\n            in: viewerIds,\n          },\n        },\n        select: {\n          id: true,\n          email: true,\n        },\n      });\n\n      // Format the response\n      const formattedConversations = conversations.map((conversation: any) => {\n        const viewer = viewers.find((v: any) => v.id === conversation.viewerId);\n\n        return {\n          id: conversation.id,\n          title: conversation.title,\n          createdAt: conversation.createdAt,\n          updatedAt: conversation.updatedAt,\n          viewerId: conversation.viewerId,\n          viewerEmail: viewer?.email,\n          documentPageNumber: conversation.documentPageNumber,\n          dataroomDocument: conversation.dataroomDocument,\n          unreadCount: conversation._count.messages,\n          lastMessage: conversation.messages[0] || null,\n        };\n      });\n\n      return res.status(200).json(formattedConversations);\n    } catch (error) {\n      console.error(\"Error in conversations/summaries API:\", error);\n      return res.status(500).json({ message: \"Internal server error\" });\n    }\n  },\n\n  // GET /api/teams/[teamId]/datarooms/[dataroomId]/conversations/[conversationId]\n  \"GET /[conversationId]\": async (\n    req: NextApiRequest,\n    res: NextApiResponse,\n  ) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      conversations: [conversationId],\n    } = req.query as {\n      teamId: string;\n      id: string;\n      conversations: string[];\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            users: { some: { userId } },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      const conversation = await prisma.conversation.findUnique({\n        where: {\n          id: conversationId,\n        },\n        include: {\n          participants: true,\n          messages: {\n            orderBy: {\n              createdAt: \"asc\",\n            },\n            include: {\n              user: true,\n              viewer: true,\n            },\n          },\n          dataroomDocument: {\n            include: {\n              document: true,\n            },\n          },\n        },\n      });\n\n      if (!conversation) {\n        return res.status(404).json({ error: \"Conversation not found\" });\n      }\n\n      let viewers: { id: string; email: string | null }[] = [];\n      let users: { id: string; email: string | null }[] = [];\n      if (conversation.participants) {\n        const viewerIds = conversation.participants\n          .map((p) => p.viewerId)\n          .filter((id): id is string => id !== null);\n\n        viewers = await prisma.viewer.findMany({\n          where: {\n            id: { in: viewerIds },\n          },\n          select: {\n            id: true,\n            email: true,\n          },\n        });\n\n        const userIds = conversation.participants\n          .map((p) => p.userId)\n          .filter((id): id is string => id !== null);\n\n        users = await prisma.user.findMany({\n          where: {\n            id: { in: userIds },\n          },\n          select: {\n            id: true,\n            email: true,\n          },\n        });\n      }\n\n      const formattedConversation = {\n        ...conversation,\n        participants: [...viewers, ...users],\n      };\n\n      return res.status(200).json(formattedConversation);\n    } catch (error) {\n      console.error(\"Error getting conversation:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // POST /api/teams/[teamId]/datarooms/[dataroomId]/conversations/[conversationId]/read\n  \"POST /[conversationId]/read\": async (\n    req: NextApiRequest,\n    res: NextApiResponse,\n  ) => {\n    // Check authentication\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    try {\n      const {\n        teamId,\n        id: dataroomId,\n        conversations: [conversationId],\n      } = req.query as {\n        teamId: string;\n        id: string;\n        conversations: string[];\n      };\n\n      const userId = (session.user as CustomUser).id;\n\n      // Mark all messages as read\n      await conversationService.markConversationAsRead(conversationId, userId);\n\n      return res.status(200).json({ success: true });\n    } catch (error) {\n      console.error(\"Error marking conversation as read:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // POST /api/teams/[teamId]/datarooms/[dataroomId]/conversations/[conversationId]/messages\n  \"POST /[conversationId]/messages\": async (\n    req: NextApiRequest,\n    res: NextApiResponse,\n  ) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      conversations: [conversationId],\n    } = req.query as {\n      teamId: string;\n      id: string;\n      conversations: string[];\n    };\n\n    const { content } = req.body as {\n      content: string;\n    };\n\n    if (!content || content.trim() === \"\") {\n      return res.status(400).json({ error: \"Message content is required\" });\n    }\n\n    const userId = (session?.user as CustomUser).id;\n\n    try {\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            id: teamId,\n            users: { some: { userId } },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Create the message\n      const message = await messageService.addMessage({\n        conversationId,\n        content,\n        userId,\n      });\n\n      // Get all delayed and queued runs for this dataroom\n      const allRuns = await runs.list({\n        taskIdentifier: [\"send-conversation-message-notification\"],\n        tag: [`conversation_${conversationId}`],\n        status: [\"DELAYED\", \"QUEUED\"],\n        period: \"5m\",\n      });\n\n      // Cancel any existing unsent notification runs for this dataroom\n      await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));\n\n      waitUntil(\n        sendConversationMessageNotificationTask.trigger(\n          {\n            dataroomId,\n            messageId: message.id,\n            conversationId,\n            senderUserId: userId,\n            teamId,\n          },\n          {\n            idempotencyKey: `conversation-notification-${teamId}-${dataroomId}-${conversationId}-${message.id}`,\n            tags: [\n              `team_${teamId}`,\n              `dataroom_${dataroomId}`,\n              `conversation_${conversationId}`,\n            ],\n            delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay\n          },\n        ),\n      );\n\n      return res.status(201).json(message);\n    } catch (error) {\n      console.error(\"Error adding message:\", error);\n      if (\n        error instanceof Error &&\n        error.message.startsWith(\"Content cannot be\")\n      ) {\n        return res.status(400).json({ error: error.message });\n      }\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // PUT /api/teams/[teamId]/datarooms/[dataroomId]/conversations/[conversationId]/messages/[messageId]/read\n  \"PUT /[conversationId]/messages/[messageId]/read\": async (\n    req: NextApiRequest,\n    res: NextApiResponse,\n  ) => {\n    try {\n      const {\n        teamId,\n        id: dataroomId,\n        conversations: [conversationId],\n        messageId,\n      } = req.query as {\n        teamId: string;\n        id: string;\n        conversations: string[];\n        messageId: string;\n      };\n\n      const message = await messageService.markMessageAsRead(messageId);\n\n      return res.status(200).json(message);\n    } catch (error) {\n      console.error(\"Error marking message as read:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // PATCH /api/teams/[teamId]/datarooms/[dataroomId]/conversations/[conversationId]/toggle\n  \"PATCH /[conversationId]/toggle\": async (\n    req: NextApiRequest,\n    res: NextApiResponse,\n  ) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session?.user) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    try {\n      const {\n        teamId,\n        id: dataroomId,\n        conversations: [conversationId],\n      } = req.query as {\n        teamId: string;\n        id: string;\n        conversations: string[];\n      };\n\n      const { enabled } = req.body;\n\n      // TODO: Check if user has permission to update this dataroom\n\n      const dataroom = await conversationService.toggleDataroomConversations(\n        dataroomId,\n        enabled,\n      );\n\n      return res.status(200).json(dataroom);\n    } catch (error) {\n      console.error(\"Error toggling dataroom conversations:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // DELETE /api/teams/[teamId]/datarooms/[dataroomId]/conversations/[conversationId]\n  \"DELETE /[conversationId]\": async (\n    req: NextApiRequest,\n    res: NextApiResponse,\n  ) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      conversations: [conversationId],\n    } = req.query as {\n      teamId: string;\n      id: string;\n      conversations: string[];\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify user has access to the dataroom\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            id: teamId,\n            users: { some: { userId } },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Delete the conversation using the service\n      await conversationService.deleteConversation(\n        conversationId,\n        userId,\n        dataroomId,\n        teamId,\n      );\n\n      return res.status(200).json({ success: true });\n    } catch (error) {\n      console.error(\"Error deleting conversation:\", error);\n      if (error instanceof Error) {\n        if (error.message === \"Conversation not found\") {\n          return res.status(404).json({ error: \"Conversation not found\" });\n        }\n        if (error.message === \"Unauthorized to delete this conversation\") {\n          return res\n            .status(403)\n            .json({ error: \"Unauthorized to delete this conversation\" });\n        }\n      }\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n};\n\n// Main handler that will be imported by the catchall route\nexport async function handleRoute(req: NextApiRequest, res: NextApiResponse) {\n  const { method, query } = req;\n\n  // Normalize path - if first segment isn't 'summaries', treat it as conversationId\n  let path = \"\";\n  if (Array.isArray(query.conversations)) {\n    if (query.conversations[0] === \"summaries\") {\n      path = \"summaries\";\n    } else {\n      // Replace the ID with [conversationId]\n      path =\n        \"[conversationId]\" +\n        (query.conversations.slice(1).length > 0\n          ? \"/\" + query.conversations.slice(1).join(\"/\")\n          : \"\");\n    }\n  }\n  const routeKey = `${method} /${path}`;\n\n  // Find matching handler\n  const handler = routeHandlers[routeKey as keyof typeof routeHandlers];\n\n  if (!handler) {\n    return res.status(404).json({ error: \"API endpoint not found\" });\n  }\n\n  try {\n    return await handler(req, res);\n  } catch (error) {\n    console.error(\"API Error:\", error);\n    return res.status(500).json({\n      error: error instanceof Error ? error.message : \"Internal server error\",\n    });\n  }\n}\n"
  },
  {
    "path": "ee/features/conversations/api/team-faqs-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport {\n  PublishFAQInput,\n  faqParamSchema,\n  publishFAQSchema,\n  updateFAQSchema,\n} from \"@/ee/features/conversations/lib/schemas/faq\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { validateContent } from \"@/lib/utils/sanitize-html\";\n\n// Route mapping object to handle different paths\nconst routeHandlers = {\n  // POST /api/teams/[teamId]/datarooms/[dataroomId]/faqs\n  \"POST /\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    try {\n      // Validate URL parameters\n      const paramValidation = faqParamSchema.safeParse({\n        teamId: req.query.teamId,\n        id: req.query.id,\n      });\n\n      if (!paramValidation.success) {\n        return res.status(400).json({\n          error: \"Invalid parameters\",\n          details: paramValidation.error.errors[0]?.message,\n        });\n      }\n\n      const { teamId, id: dataroomId } = paramValidation.data;\n\n      // Validate request body\n      const bodyValidation = publishFAQSchema.safeParse(req.body);\n\n      if (!bodyValidation.success) {\n        return res.status(400).json({\n          error: \"Invalid request data\",\n          details: bodyValidation.error.errors[0]?.message,\n        });\n      }\n\n      const data = bodyValidation.data;\n      const userId = (session.user as CustomUser).id;\n\n      // Sanitize content for creation\n      const createSanitizedQuestion = validateContent(data.editedQuestion);\n      const createSanitizedAnswer = validateContent(data.answer);\n\n      // Verify team access to dataroom\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            id: teamId,\n            users: { some: { userId } },\n          },\n        },\n        select: {\n          id: true,\n          teamId: true,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Validate visibility mode and related fields\n      if (data.visibilityMode === \"PUBLIC_LINK\" && !data.linkId) {\n        return res.status(400).json({\n          error: \"Link ID is required for link visibility\",\n        });\n      }\n\n      if (\n        data.visibilityMode === \"PUBLIC_DOCUMENT\" &&\n        !data.dataroomDocumentId\n      ) {\n        return res.status(400).json({\n          error: \"Document ID is required for document visibility\",\n        });\n      }\n\n      // Verify link belongs to dataroom if specified\n      if (data.linkId) {\n        const link = await prisma.link.findFirst({\n          where: {\n            id: data.linkId,\n            dataroomId: dataroomId,\n          },\n        });\n\n        if (!link) {\n          return res.status(400).json({\n            error: \"Invalid link for this dataroom\",\n          });\n        }\n      }\n\n      // Verify document belongs to dataroom if specified\n      if (data.dataroomDocumentId) {\n        const dataroomDocument = await prisma.dataroomDocument.findFirst({\n          where: {\n            id: data.dataroomDocumentId,\n            dataroomId: dataroomId,\n          },\n        });\n\n        if (!dataroomDocument) {\n          return res.status(400).json({\n            error: \"Invalid document for this dataroom\",\n          });\n        }\n      }\n\n      // Validate that referenced messages (if any) belong to this dataroom (and conversation, if provided)\n      const messageIds = [data.questionMessageId, data.answerMessageId].filter(\n        Boolean,\n      ) as string[];\n\n      if (messageIds.length > 0) {\n        const msgs = await prisma.message.findMany({\n          where: {\n            id: { in: messageIds },\n            conversation: {\n              dataroomId,\n              ...(data.sourceConversationId\n                ? { id: data.sourceConversationId }\n                : {}),\n            },\n          },\n          select: { id: true },\n        });\n\n        if (msgs.length !== messageIds.length) {\n          return res.status(400).json({\n            error:\n              \"Message references must belong to the dataroom\" +\n              (data.sourceConversationId\n                ? \" and the specified conversation\"\n                : \"\"),\n          });\n        }\n      }\n\n      // Create the published FAQ\n      const publishedFAQ = await prisma.dataroomFaqItem.create({\n        data: {\n          editedQuestion: createSanitizedQuestion,\n          originalQuestion: data.originalQuestion\n            ? validateContent(data.originalQuestion)\n            : null,\n          answer: createSanitizedAnswer,\n          dataroomId,\n          linkId: data.linkId,\n          dataroomDocumentId: data.dataroomDocumentId,\n          sourceConversationId: data.sourceConversationId,\n          questionMessageId: data.questionMessageId,\n          answerMessageId: data.answerMessageId,\n          teamId,\n          publishedByUserId: userId,\n          visibilityMode: data.visibilityMode,\n          isAnonymized: data.isAnonymized ?? true,\n          documentPageNumber: data.documentPageNumber,\n          documentVersionNumber: data.documentVersionNumber,\n        },\n        include: {\n          dataroom: {\n            select: { name: true },\n          },\n          link: {\n            select: { name: true },\n          },\n          dataroomDocument: {\n            include: {\n              document: {\n                select: { name: true },\n              },\n            },\n          },\n          publishedByUser: {\n            select: { name: true, email: true },\n          },\n        },\n      });\n\n      return res.status(201).json(publishedFAQ);\n    } catch (error) {\n      console.error(\"Error publishing FAQ:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // GET /api/teams/[teamId]/datarooms/[id]/faqs\n  \"GET /\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    try {\n      // Validate URL parameters\n      const paramValidation = faqParamSchema.safeParse({\n        teamId: req.query.teamId,\n        id: req.query.id,\n      });\n\n      if (!paramValidation.success) {\n        return res.status(400).json({\n          error: \"Invalid parameters\",\n          details: paramValidation.error.errors[0]?.message,\n        });\n      }\n\n      const { teamId, id: dataroomId } = paramValidation.data;\n      const userId = (session.user as CustomUser).id;\n      // Verify team access to dataroom\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          team: {\n            id: teamId,\n            users: { some: { userId } },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Get published FAQs for this dataroom\n      const faqs = await prisma.dataroomFaqItem.findMany({\n        where: {\n          dataroomId,\n        },\n        include: {\n          dataroom: {\n            select: { name: true },\n          },\n          link: {\n            select: { name: true },\n          },\n          dataroomDocument: {\n            include: {\n              document: {\n                select: { name: true },\n              },\n            },\n          },\n          publishedByUser: {\n            select: { name: true, email: true },\n          },\n          sourceConversation: {\n            select: { id: true },\n          },\n          questionMessage: {\n            select: { id: true, content: true },\n          },\n          answerMessage: {\n            select: { id: true, content: true },\n          },\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      return res.status(200).json(faqs);\n    } catch (error) {\n      console.error(\"Error fetching FAQs:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // PUT /api/teams/[teamId]/datarooms/[id]/faqs/[faqId]\n  \"PUT /[faqId]\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    try {\n      // Validate URL parameters\n      const paramValidation = faqParamSchema.safeParse({\n        teamId: req.query.teamId,\n        id: req.query.id,\n        faqId: req.query.faqId,\n      });\n\n      if (!paramValidation.success) {\n        return res.status(400).json({\n          error: \"Invalid parameters\",\n          details: paramValidation.error.errors[0]?.message,\n        });\n      }\n\n      const { teamId, id: dataroomId, faqId } = paramValidation.data;\n\n      // Validate request body\n      const bodyValidation = updateFAQSchema.safeParse(req.body);\n\n      if (!bodyValidation.success) {\n        return res.status(400).json({\n          error: \"Invalid request data\",\n          details: bodyValidation.error.errors[0]?.message,\n        });\n      }\n\n      const data = bodyValidation.data;\n      const userId = (session.user as CustomUser).id;\n      // Verify team access and FAQ ownership\n      const existingFAQ = await prisma.dataroomFaqItem.findFirst({\n        where: {\n          id: faqId,\n          dataroomId,\n          dataroom: {\n            team: {\n              id: teamId,\n              users: { some: { userId } },\n            },\n          },\n        },\n      });\n\n      if (!existingFAQ) {\n        return res.status(404).json({ error: \"FAQ not found\" });\n      }\n\n      // Prepare update data\n      const updateData: any = {};\n\n      if (data.editedQuestion)\n        updateData.editedQuestion = validateContent(data.editedQuestion);\n      if (data.answer) updateData.answer = validateContent(data.answer);\n\n      if (data.status !== undefined) updateData.status = data.status;\n      if (data.visibilityMode !== undefined)\n        updateData.visibilityMode = data.visibilityMode;\n\n      // Update the FAQ\n      const updatedFAQ = await prisma.dataroomFaqItem.update({\n        where: { id: faqId },\n        data: updateData,\n        include: {\n          dataroom: {\n            select: { name: true },\n          },\n          link: {\n            select: { name: true },\n          },\n          dataroomDocument: {\n            include: {\n              document: {\n                select: { name: true },\n              },\n            },\n          },\n          publishedByUser: {\n            select: { name: true, email: true },\n          },\n        },\n      });\n\n      return res.status(200).json(updatedFAQ);\n    } catch (error) {\n      console.error(\"Error updating FAQ:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n\n  // DELETE /api/teams/[teamId]/datarooms/[id]/faqs/[faqId]\n  \"DELETE /[faqId]\": async (req: NextApiRequest, res: NextApiResponse) => {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    try {\n      // Validate URL parameters\n      const paramValidation = faqParamSchema.safeParse({\n        teamId: req.query.teamId,\n        id: req.query.id,\n        faqId: req.query.faqId,\n      });\n\n      if (!paramValidation.success) {\n        return res.status(400).json({\n          error: \"Invalid parameters\",\n          details: paramValidation.error.errors[0]?.message,\n        });\n      }\n\n      const { teamId, id: dataroomId, faqId } = paramValidation.data;\n      const userId = (session.user as CustomUser).id;\n      // Verify team access and FAQ ownership\n      const existingFAQ = await prisma.dataroomFaqItem.findFirst({\n        where: {\n          id: faqId,\n          dataroomId,\n          dataroom: {\n            team: {\n              id: teamId,\n              users: { some: { userId } },\n            },\n          },\n        },\n      });\n\n      if (!existingFAQ) {\n        return res.status(404).json({ error: \"FAQ not found\" });\n      }\n\n      // Delete the FAQ (cascade will handle votes)\n      await prisma.dataroomFaqItem.delete({\n        where: { id: faqId },\n      });\n\n      return res.status(200).json({ message: \"FAQ deleted successfully\" });\n    } catch (error) {\n      console.error(\"Error deleting FAQ:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  },\n};\n\n// Main handler function that routes to appropriate handler based on method and path\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const method = req.method;\n  let path = \"/\";\n\n  // Extract path from query parameters for nested routes\n  if (req.query.faqId) {\n    path = `/[faqId]`;\n  }\n\n  const handlerKey = `${method} ${path}`;\n  const handler = routeHandlers[handlerKey as keyof typeof routeHandlers];\n\n  if (handler) {\n    await handler(req, res);\n  } else {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n}\n"
  },
  {
    "path": "ee/features/conversations/api/toggle-conversations-route.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// Validation schema for the request body\nconst toggleConversationsSchema = z.object({\n  enabled: z.boolean(),\n});\n\nexport default async function toggleConversationsRoute(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // Only allow POST method\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  // Validate the session\n  const session = await getServerSession(req, res, authOptions);\n  if (!session?.user) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    // Extract query parameters\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    // Validate request body\n    const validationResult = toggleConversationsSchema.safeParse(req.body);\n    if (!validationResult.success) {\n      return res.status(400).json({\n        error: \"Invalid request data\",\n        details: validationResult.error.format(),\n      });\n    }\n\n    const { enabled } = validationResult.data;\n\n    // Check if user has access to this dataroom\n    const updatedDataroom = await prisma.dataroom.update({\n      where: {\n        id: dataroomId,\n        team: {\n          id: teamId,\n          users: {\n            some: { userId },\n          },\n        },\n      },\n      data: {\n        conversationsEnabled: enabled,\n      },\n    });\n\n    if (!updatedDataroom) {\n      return res.status(404).json({ error: \"Dataroom not found\" });\n    }\n\n    return res.status(200).json({ success: true, dataroom: updatedDataroom });\n  } catch (error) {\n    console.error(\"Error toggling conversations:\", error);\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "ee/features/conversations/components/dashboard/conversation-list-item.tsx",
    "content": "import { formatDistanceToNow } from \"date-fns\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Badge } from \"@/components/ui/badge\";\n\nexport function ConversationListItem({\n  navigateToConversation,\n  conversation,\n  isActive,\n  showVersionNumber = false,\n}: {\n  navigateToConversation: (id: string) => void;\n  conversation: any;\n  isActive: boolean;\n  showVersionNumber?: boolean;\n}) {\n  // Helper function to format document reference\n  const formatDocumentReference = () => {\n    if (!conversation.dataroomDocumentName) return \"Untitled conversation\";\n\n    const documentName = conversation.dataroomDocumentName;\n    const parts = [];\n\n    if (conversation.documentPageNumber) {\n      parts.push(`Page ${conversation.documentPageNumber}`);\n    }\n\n    // Only show version number if showVersionNumber is true (admin/team member view)\n    if (showVersionNumber && conversation.documentVersionNumber) {\n      parts.push(`v${conversation.documentVersionNumber}`);\n    }\n\n    const reference = parts.length > 0 ? ` (${parts.join(\", \")})` : \"\";\n    return `${documentName}${reference}`;\n  };\n\n  return (\n    <button\n      className={cn(\n        \"relative flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent\",\n        isActive && \"bg-muted\",\n      )}\n      onClick={() => navigateToConversation(conversation.id)}\n    >\n      <div className=\"flex w-full flex-col gap-1\">\n        <div className=\"flex items-center\">\n          <div className=\"flex items-center gap-2\">\n            <div className=\"font-semibold\">\n              {conversation.viewerEmail || \"Anonymous Viewer\"}\n            </div>\n          </div>\n          <div\n            className={cn(\n              \"ml-auto text-xs text-muted-foreground\",\n              isActive && \"text-foreground\",\n            )}\n          >\n            {formatDistanceToNow(new Date(conversation.updatedAt), {\n              addSuffix: true,\n            })}\n          </div>\n        </div>\n        <div className=\"text-xs font-medium\">\n          {formatDocumentReference() || conversation.title}\n        </div>\n      </div>\n\n      <div className=\"line-clamp-2 text-xs text-muted-foreground\">\n        {conversation.lastMessage.content}\n      </div>\n\n      {conversation.unreadCount > 0 && (\n        <Badge className=\"absolute right-3 top-3\" variant=\"default\">\n          {conversation.unreadCount}\n        </Badge>\n      )}\n    </button>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/dashboard/conversations-not-enabled-banner.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { ChevronDown, ChevronUp, X } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport z from \"zod\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Switch } from \"@/components/ui/switch\";\n\ninterface ConversationsNotEnabledBannerProps {\n  dataroomId: string;\n  teamId: string;\n  isConversationsEnabled: boolean;\n  onConversationsToggled?: (enabled: boolean) => void;\n}\n\nexport function ConversationsNotEnabledBanner({\n  dataroomId,\n  teamId,\n  isConversationsEnabled,\n  onConversationsToggled,\n}: ConversationsNotEnabledBannerProps) {\n  const [isProcessing, setIsProcessing] = useState(false);\n  const [isLocallyEnabled, setIsLocallyEnabled] = useState(\n    isConversationsEnabled,\n  );\n  const [isCollapsed, setIsCollapsed] = useState(false);\n  const [isDismissed, setIsDismissed] = useState(false);\n\n  // Check if banner should be collapsed or dismissed on initial load\n  useEffect(() => {\n    // Check for dismissed state first\n    const isDismissedLocal =\n      localStorage.getItem(\n        `dataroom-${dataroomId}-conversations-banner-dismissed`,\n      ) === \"true\";\n    if (isDismissedLocal) {\n      setIsDismissed(true);\n      return;\n    }\n\n    // Check for collapsed state\n    const shouldCollapse =\n      localStorage.getItem(\n        `dataroom-${dataroomId}-conversations-banner-collapsed`,\n      ) === \"true\";\n    if (shouldCollapse) {\n      setIsCollapsed(true);\n    }\n  }, [dataroomId]);\n\n  // Update local state when prop changes\n  useEffect(() => {\n    setIsLocallyEnabled(isConversationsEnabled);\n  }, [isConversationsEnabled]);\n\n  // Save collapsed state to localStorage\n  useEffect(() => {\n    localStorage.setItem(\n      `dataroom-${dataroomId}-conversations-banner-collapsed`,\n      isCollapsed.toString(),\n    );\n  }, [isCollapsed, dataroomId]);\n\n  const handleToggleConversations = async (newValue: boolean) => {\n    setIsProcessing(true);\n    try {\n      const dataroomIdParsed = z.string().cuid().parse(dataroomId);\n      const teamIdParsed = z.string().cuid().parse(teamId);\n\n      const response = await fetch(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/toggle-conversations`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ enabled: newValue }),\n        },\n      );\n\n      if (!response.ok)\n        throw new Error(\n          `Failed to ${newValue ? \"enable\" : \"disable\"} conversations`,\n        );\n\n      setIsLocallyEnabled(newValue);\n      toast.success(\n        `Conversations ${newValue ? \"enabled\" : \"disabled\"} successfully`,\n      );\n\n      // Notify parent component if provided\n      if (onConversationsToggled) {\n        onConversationsToggled(newValue);\n      }\n    } catch (error) {\n      console.error(\n        `Error ${newValue ? \"enabling\" : \"disabling\"} conversations:`,\n        error,\n      );\n      toast.error(`Failed to ${newValue ? \"enable\" : \"disable\"} conversations`);\n    } finally {\n      setIsProcessing(false);\n    }\n  };\n\n  const handleDismiss = () => {\n    setIsDismissed(true);\n    localStorage.setItem(\n      `dataroom-${dataroomId}-conversations-banner-dismissed`,\n      \"true\",\n    );\n  };\n\n  if (isDismissed) {\n    return null;\n  }\n\n  // Show collapsed version\n  if (isCollapsed) {\n    return (\n      <Card className=\"mb-6 border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800\">\n        <CardHeader className=\"py-4\">\n          <div className=\"flex items-center justify-between\">\n            <CardTitle className=\"text-sm font-medium\">\n              {isLocallyEnabled\n                ? \"Conversations are enabled - click to view setup steps\"\n                : \"Conversations are not enabled for this dataroom - click to expand\"}\n            </CardTitle>\n            <div className=\"flex items-center gap-2\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"h-8 w-8 p-0\"\n                onClick={() => setIsCollapsed(false)}\n              >\n                <ChevronDown className=\"h-4 w-4\" />\n                <span className=\"sr-only\">Expand</span>\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"h-8 w-8 p-0\"\n                onClick={handleDismiss}\n              >\n                <X className=\"h-4 w-4\" />\n                <span className=\"sr-only\">Dismiss</span>\n              </Button>\n            </div>\n          </div>\n        </CardHeader>\n      </Card>\n    );\n  }\n\n  return (\n    <Card className=\"mb-6 border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800\">\n      <CardHeader className=\"py-4\">\n        <div className=\"flex items-center justify-between\">\n          <CardTitle className=\"text-lg font-medium\">\n            {isLocallyEnabled\n              ? \"Conversations are enabled for this dataroom\"\n              : \"Conversations are not enabled for this dataroom\"}\n          </CardTitle>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-8 w-8 p-0\"\n              onClick={() => setIsCollapsed(true)}\n            >\n              <ChevronUp className=\"h-4 w-4\" />\n              <span className=\"sr-only\">Collapse</span>\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-8 w-8 p-0\"\n              onClick={handleDismiss}\n            >\n              <X className=\"h-4 w-4\" />\n              <span className=\"sr-only\">Dismiss</span>\n            </Button>\n          </div>\n        </div>\n        <CardDescription>\n          {isLocallyEnabled\n            ? \"You've enabled conversations. Here are the next steps:\"\n            : \"Follow these steps to set up Q&A conversations for your dataroom:\"}\n        </CardDescription>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        {/* Step 1: Toggle Dataroom Conversations */}\n        <div className=\"rounded-lg border bg-background p-4\">\n          <div className=\"mb-3 flex items-center\">\n            <div className=\"flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground\">\n              1\n            </div>\n            <h4 className=\"ml-2 font-medium\">\n              {isLocallyEnabled\n                ? \"Q&A Conversations are enabled for this dataroom\"\n                : \"Enable Q&A Conversations for this dataroom\"}\n            </h4>\n          </div>\n          <div className=\"flex items-center justify-between\">\n            <p className=\"text-sm text-muted-foreground\">\n              {isLocallyEnabled\n                ? \"Toggle to disable conversations for all viewers\"\n                : \"Allow conversations for all viewers with access to this dataroom\"}\n            </p>\n            <Switch\n              checked={isLocallyEnabled}\n              onCheckedChange={handleToggleConversations}\n              disabled={isProcessing}\n            />\n          </div>\n        </div>\n\n        {/* Step 2: Enable for a specific link */}\n        <div className=\"rounded-lg border bg-background p-4\">\n          <div className=\"mb-3 flex items-center\">\n            <div className=\"flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground\">\n              2\n            </div>\n            <h4 className=\"ml-2 font-medium\">Enable for a specific link</h4>\n          </div>\n          <div className=\"flex items-center justify-between\">\n            <p className=\"text-sm text-muted-foreground\">\n              Enable Q&A conversations for specific links in your dataroom.\n              Note: Email authentication is required for conversations.\n            </p>\n            <Button variant=\"outline\" size=\"sm\">\n              <Link href={`/datarooms/${dataroomId}/permissions`}>\n                Go to permissions\n              </Link>\n            </Button>\n          </div>\n        </div>\n\n        {/* Step 3: Share the link */}\n        <div className=\"rounded-lg border bg-background p-4\">\n          <div className=\"mb-3 flex items-center\">\n            <div className=\"flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground\">\n              3\n            </div>\n            <h4 className=\"ml-2 font-medium\">Share with viewers</h4>\n          </div>\n          <p className=\"text-sm text-muted-foreground\">\n            Share your dataroom link with viewers so they can start\n            conversations about your documents.\n          </p>\n        </div>\n      </CardContent>\n      <CardFooter className=\"rounded-b-lg border-t bg-gray-200 px-6 py-4 dark:bg-gray-700\">\n        <p className=\"text-sm text-muted-foreground\">\n          Enabling conversations allows viewers to ask questions about specific\n          documents or the dataroom in general.\n        </p>\n      </CardFooter>\n    </Card>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/dashboard/edit-faq-modal.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\n\nimport {\n  editFAQFormSchema,\n  faqParamSchema,\n} from \"@/ee/features/conversations/lib/schemas/faq\";\nimport { BookOpen, Check, FileText, Link2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\nimport { PublishedFAQ } from \"../../pages/faq-overview\";\n\ninterface EditFAQModalProps {\n  faq: PublishedFAQ;\n  dataroomId: string;\n  teamId: string;\n  isOpen: boolean;\n  onClose: () => void;\n  onSuccess: () => void;\n}\n\ninterface EditFAQFormData {\n  editedQuestion: string;\n  answer: string;\n  visibilityMode: \"PUBLIC_DATAROOM\" | \"PUBLIC_LINK\" | \"PUBLIC_DOCUMENT\";\n}\n\nconst visibilityOptions = [\n  {\n    value: \"PUBLIC_DATAROOM\" as const,\n    label: \"Entire Dataroom\",\n    description: \"Visible to all dataroom visitors\",\n    icon: BookOpen,\n  },\n  {\n    value: \"PUBLIC_LINK\" as const,\n    label: \"Specific Link\",\n    description: \"Visible only to visitors from this link\",\n    icon: Link2,\n  },\n  {\n    value: \"PUBLIC_DOCUMENT\" as const,\n    label: \"Specific Document\",\n    description: \"Visible only when viewing this document\",\n    icon: FileText,\n  },\n];\n\nexport function EditFAQModal({\n  faq,\n  dataroomId,\n  teamId,\n  isOpen,\n  onClose,\n  onSuccess,\n}: EditFAQModalProps) {\n  const [isUpdating, setIsUpdating] = useState(false);\n  const [formData, setFormData] = useState<EditFAQFormData>({\n    editedQuestion: faq.editedQuestion,\n    answer: faq.answer,\n    visibilityMode: faq.visibilityMode,\n  });\n\n  // Update form data when FAQ changes\n  useEffect(() => {\n    if (faq) {\n      setFormData({\n        editedQuestion: faq.editedQuestion,\n        answer: faq.answer,\n        visibilityMode: faq.visibilityMode,\n      });\n    }\n  }, [faq]);\n\n  const handleInputChange = (field: keyof EditFAQFormData, value: string) => {\n    setFormData((prev) => ({\n      ...prev,\n      [field]: value,\n    }));\n  };\n\n  const handleUpdate = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    try {\n      // Validate API parameters\n      const paramValidation = faqParamSchema.safeParse({\n        teamId,\n        id: dataroomId,\n        faqId: faq.id,\n      });\n\n      if (!paramValidation.success) {\n        toast.error(\"Invalid team, dataroom, or FAQ ID\");\n        return;\n      }\n\n      // Validate form data\n      const formValidation = editFAQFormSchema.safeParse(formData);\n\n      if (!formValidation.success) {\n        const firstError = formValidation.error.errors[0];\n        toast.error(firstError.message);\n        return;\n      }\n\n      setIsUpdating(true);\n      const validatedData = formValidation.data;\n\n      const response = await fetch(\n        `/api/teams/${paramValidation.data.teamId}/datarooms/${paramValidation.data.id}/faqs/${paramValidation.data.faqId}`,\n        {\n          method: \"PUT\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(validatedData),\n        },\n      );\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error || error.details || \"Failed to update FAQ\");\n      }\n\n      toast.success(\"FAQ updated successfully!\");\n      onSuccess();\n      onClose();\n    } catch (error) {\n      console.error(\"Error updating FAQ:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to update FAQ\",\n      );\n    } finally {\n      setIsUpdating(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onClose}>\n      <DialogContent className=\"max-h-[90vh] overflow-hidden sm:max-w-5xl md:w-full\">\n        <DialogHeader>\n          <DialogTitle>Edit FAQ</DialogTitle>\n          <DialogDescription>\n            Update the question and answer content. You can see the original\n            messages below.\n          </DialogDescription>\n        </DialogHeader>\n\n        <form onSubmit={handleUpdate} className=\"space-y-6\">\n          <div className=\"grid grid-cols-1 gap-6 lg:grid-cols-2\">\n            {/* Original Messages Preview */}\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"text-sm font-medium text-gray-700\">\n                  Original Question\n                </label>\n                <div className=\"mt-2 rounded-lg border border-blue-200 bg-blue-50/50 p-3\">\n                  <div className=\"flex items-start justify-between\">\n                    <p className=\"flex-1 text-sm\">\n                      {faq.questionMessage?.content ||\n                        faq.originalQuestion ||\n                        \"No original question available\"}\n                    </p>\n                    <Check className=\"ml-2 h-4 w-4 text-blue-600\" />\n                  </div>\n                  <p className=\"mt-1 text-xs text-gray-500\">\n                    Original visitor message\n                  </p>\n                </div>\n              </div>\n\n              <div>\n                <label className=\"text-sm font-medium text-gray-700\">\n                  Original Answer\n                </label>\n                <div className=\"mt-2 rounded-lg border border-green-200 bg-green-50/50 p-3\">\n                  <div className=\"flex items-start justify-between\">\n                    <p className=\"flex-1 text-sm\">\n                      {faq.answerMessage?.content ||\n                        \"No original answer available\"}\n                    </p>\n                    <Check className=\"ml-2 h-4 w-4 text-green-600\" />\n                  </div>\n                  <p className=\"mt-1 text-xs text-gray-500\">\n                    Original admin response\n                  </p>\n                </div>\n              </div>\n            </div>\n\n            {/* FAQ Details */}\n            <div className=\"space-y-4\">\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"editedQuestion\">Question *</Label>\n                <Textarea\n                  id=\"editedQuestion\"\n                  placeholder=\"Edit the question for clarity...\"\n                  className=\"min-h-[80px]\"\n                  value={formData.editedQuestion}\n                  onChange={(e) =>\n                    handleInputChange(\"editedQuestion\", e.target.value)\n                  }\n                />\n                <p className=\"text-sm text-gray-500\">\n                  You can edit the question to make it clearer for visitors.\n                </p>\n              </div>\n\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"answer\">Answer *</Label>\n                <Textarea\n                  id=\"answer\"\n                  placeholder=\"Edit the answer if needed...\"\n                  className=\"min-h-[80px]\"\n                  value={formData.answer}\n                  onChange={(e) => handleInputChange(\"answer\", e.target.value)}\n                />\n              </div>\n\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"visibilityMode\">Visibility</Label>\n                <Select\n                  value={formData.visibilityMode}\n                  onValueChange={(value) =>\n                    handleInputChange(\n                      \"visibilityMode\",\n                      value as EditFAQFormData[\"visibilityMode\"],\n                    )\n                  }\n                >\n                  <SelectTrigger className=\"h-12\">\n                    <SelectValue placeholder=\"Select visibility scope\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {visibilityOptions.map((option) => (\n                      <SelectItem\n                        key={option.value}\n                        value={option.value}\n                        disabled={\n                          option.value === \"PUBLIC_DOCUMENT\" &&\n                          !faq.dataroomDocument\n                        }\n                      >\n                        <div className=\"flex items-center space-x-4\">\n                          <option.icon className=\"h-4 w-4\" />\n                          <div className=\"flex flex-col items-start\">\n                            <div className=\"font-medium\">{option.label}</div>\n                            <div className=\"text-xs text-gray-500\">\n                              {option.description}\n                            </div>\n                          </div>\n                        </div>\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              </div>\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n              Cancel\n            </Button>\n            <Button type=\"submit\" variant=\"default\" disabled={isUpdating}>\n              {isUpdating ? \"Updating...\" : \"Update FAQ\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/dashboard/link-option-conversation-section.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport LinkItem from \"@/components/links/link-sheet/link-item\";\nimport { LinkUpgradeOptions } from \"@/components/links/link-sheet/link-options\";\n\nexport default function ConversationSection({\n  data,\n  setData,\n  isAllowed,\n  handleUpgradeStateChange,\n}: {\n  data: DEFAULT_LINK_TYPE;\n  setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;\n  isAllowed: boolean;\n  handleUpgradeStateChange: ({\n    state,\n    trigger,\n    plan,\n  }: LinkUpgradeOptions) => void;\n}) {\n  const { enableConversation } = data;\n  const [enabled, setEnabled] = useState<boolean>(true);\n\n  useEffect(() => {\n    setEnabled(enableConversation);\n  }, [enableConversation]);\n\n  const handleEnableConversation = () => {\n    const updatedEnableConversation = !enabled;\n    if (updatedEnableConversation) {\n      // Only set email settings to true when enabling conversations\n      setData({\n        ...data,\n        enableConversation: true,\n        emailAuthenticated: true,\n        emailProtected: true,\n      });\n    } else {\n      // When disabling conversations, don't modify email settings\n      setData({\n        ...data,\n        enableConversation: false,\n      });\n    }\n    setEnabled(updatedEnableConversation);\n  };\n\n  return (\n    <div className=\"pb-5\">\n      <LinkItem\n        title=\"Enable Q&A Conversations\"\n        tooltipContent=\"Private conversations between you and your viewers related to this dataroom.\"\n        enabled={enabled}\n        action={handleEnableConversation}\n        isAllowed={isAllowed}\n        requiredPlan=\"data rooms plus\"\n        upgradeAction={() =>\n          handleUpgradeStateChange({\n            state: true,\n            trigger: \"link_sheet_conversation_section\",\n            plan: \"Data Rooms Plus\",\n          })\n        }\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/dashboard/publish-faq-modal.tsx",
    "content": "import React, { useState } from \"react\";\n\nimport {\n  faqParamSchema,\n  publishFAQFormSchema,\n} from \"@/ee/features/conversations/lib/schemas/faq\";\nimport { BookOpen, Check, FileText, Link2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\ninterface Message {\n  id: string;\n  content: string;\n  createdAt: string;\n  userId: string | null;\n  viewerId: string | null;\n}\n\ninterface Conversation {\n  id: string;\n  title: string | null;\n  messages: Message[];\n  dataroomDocument?: {\n    id?: string;\n    document: {\n      name: string;\n    };\n  };\n  linkId?: string;\n  link?: {\n    id: string;\n    name: string;\n  };\n}\n\ninterface PublishFAQModalProps {\n  conversation: Conversation;\n  dataroomId: string;\n  teamId: string;\n  isOpen: boolean;\n  onClose: () => void;\n  onSuccess: () => void;\n  selectedQuestionMessage?: Message;\n  selectedAnswerMessage?: Message;\n}\n\ninterface PublishFAQFormData {\n  editedQuestion: string;\n  answer: string;\n  visibilityMode: \"PUBLIC_DATAROOM\" | \"PUBLIC_LINK\" | \"PUBLIC_DOCUMENT\";\n  questionMessageId: string;\n  answerMessageId: string;\n}\n\nconst visibilityOptions = [\n  {\n    value: \"PUBLIC_DATAROOM\" as const,\n    label: \"Entire Dataroom\",\n    description: \"Visible to all dataroom visitors\",\n    icon: BookOpen,\n  },\n  {\n    value: \"PUBLIC_LINK\" as const,\n    label: \"Specific Link\",\n    description: \"Visible only to visitors from this link\",\n    icon: Link2,\n  },\n  {\n    value: \"PUBLIC_DOCUMENT\" as const,\n    label: \"Specific Document\",\n    description: \"Visible only when viewing this document\",\n    icon: FileText,\n  },\n];\n\nexport function PublishFAQModal({\n  conversation,\n  dataroomId,\n  teamId,\n  isOpen,\n  onClose,\n  onSuccess,\n  selectedQuestionMessage,\n  selectedAnswerMessage,\n}: PublishFAQModalProps) {\n  const [isPublishing, setIsPublishing] = useState(false);\n  const [formData, setFormData] = useState<PublishFAQFormData>({\n    editedQuestion: selectedQuestionMessage?.content || \"\",\n    answer: selectedAnswerMessage?.content || \"\",\n    visibilityMode: \"PUBLIC_DATAROOM\",\n    questionMessageId: selectedQuestionMessage?.id || \"\",\n    answerMessageId: selectedAnswerMessage?.id || \"\",\n  });\n\n  // Update form data when selected messages change\n  React.useEffect(() => {\n    if (selectedQuestionMessage && selectedAnswerMessage) {\n      setFormData((prev) => ({\n        ...prev,\n        editedQuestion: selectedQuestionMessage.content,\n        answer: selectedAnswerMessage.content,\n        questionMessageId: selectedQuestionMessage.id,\n        answerMessageId: selectedAnswerMessage.id,\n      }));\n    }\n  }, [selectedQuestionMessage, selectedAnswerMessage]);\n\n  const handleInputChange = (\n    field: keyof PublishFAQFormData,\n    value: string,\n  ) => {\n    setFormData((prev) => ({\n      ...prev,\n      [field]: value,\n    }));\n  };\n\n  const handlePublish = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    // Basic validation\n    if (!selectedQuestionMessage || !selectedAnswerMessage) {\n      toast.error(\"Please select both a question and an answer\");\n      return;\n    }\n\n    try {\n      // Validate API parameters\n      const paramValidation = faqParamSchema.safeParse({\n        teamId,\n        id: dataroomId,\n      });\n\n      if (!paramValidation.success) {\n        toast.error(\"Invalid team or dataroom ID\");\n        return;\n      }\n\n      // Validate form data\n      const formValidation = publishFAQFormSchema.safeParse({\n        ...formData,\n        questionMessageId: selectedQuestionMessage.id,\n        answerMessageId: selectedAnswerMessage.id,\n      });\n\n      if (!formValidation.success) {\n        const firstError = formValidation.error.errors[0];\n        toast.error(firstError.message);\n        return;\n      }\n\n      const validatedData = formValidation.data;\n      setIsPublishing(true);\n\n      const payload = {\n        editedQuestion: validatedData.editedQuestion,\n        answer: validatedData.answer,\n        visibilityMode: validatedData.visibilityMode,\n        sourceConversationId: conversation.id,\n        linkId: conversation.linkId,\n        dataroomDocumentId: conversation.dataroomDocument?.id || null,\n        ...(conversation.dataroomDocument?.id\n          ? { dataroomDocumentId: conversation.dataroomDocument.id }\n          : {}),\n        isAnonymized: true, // Always anonymize published FAQs\n        questionMessageId: validatedData.questionMessageId,\n        answerMessageId: validatedData.answerMessageId,\n      };\n\n      const response = await fetch(\n        `/api/teams/${paramValidation.data.teamId}/datarooms/${paramValidation.data.id}/faqs`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify(payload),\n        },\n      );\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(\n          error.error || error.details || \"Failed to publish FAQ\",\n        );\n      }\n\n      toast.success(\"FAQ published successfully!\");\n      onSuccess();\n      onClose();\n      // Reset form\n      setFormData({\n        editedQuestion: selectedQuestionMessage?.content || \"\",\n        answer: selectedAnswerMessage?.content || \"\",\n        visibilityMode: \"PUBLIC_DATAROOM\",\n        questionMessageId: selectedQuestionMessage?.id || \"\",\n        answerMessageId: selectedAnswerMessage?.id || \"\",\n      });\n    } catch (error) {\n      console.error(\"Error publishing FAQ:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to publish FAQ\",\n      );\n    } finally {\n      setIsPublishing(false);\n    }\n  };\n\n  return (\n    <Dialog open={isOpen} onOpenChange={onClose}>\n      <DialogContent className=\"max-h-[90vh] overflow-hidden sm:max-w-5xl md:w-full\">\n        <DialogHeader>\n          <DialogTitle>Publish FAQ from Conversation</DialogTitle>\n          <DialogDescription>\n            Review and edit the selected question and answer before publishing\n            as a FAQ\n          </DialogDescription>\n        </DialogHeader>\n\n        <form onSubmit={handlePublish} className=\"space-y-6\">\n          <div className=\"grid grid-cols-1 gap-6 lg:grid-cols-2\">\n            {/* Selected Messages Preview */}\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"text-sm font-medium text-gray-700\">\n                  Selected Question\n                </label>\n                <div className=\"mt-2 rounded-lg border border-blue-200 bg-blue-50/50 p-3\">\n                  <div className=\"flex items-start justify-between\">\n                    <p className=\"flex-1 text-sm\">\n                      {selectedQuestionMessage?.content}\n                    </p>\n                    <Check className=\"ml-2 h-4 w-4 text-blue-600\" />\n                  </div>\n                  <p className=\"mt-1 text-xs text-gray-500\">\n                    Visitor •{\" \"}\n                    {selectedQuestionMessage &&\n                      new Date(\n                        selectedQuestionMessage.createdAt,\n                      ).toLocaleString()}\n                  </p>\n                </div>\n              </div>\n\n              <div>\n                <label className=\"text-sm font-medium text-gray-700\">\n                  Selected Answer\n                </label>\n                <div className=\"mt-2 rounded-lg border border-green-200 bg-green-50/50 p-3\">\n                  <div className=\"flex items-start justify-between\">\n                    <p className=\"flex-1 text-sm\">\n                      {selectedAnswerMessage?.content}\n                    </p>\n                    <Check className=\"ml-2 h-4 w-4 text-green-600\" />\n                  </div>\n                  <p className=\"mt-1 text-xs text-gray-500\">\n                    Admin •{\" \"}\n                    {selectedAnswerMessage &&\n                      new Date(\n                        selectedAnswerMessage.createdAt,\n                      ).toLocaleString()}\n                  </p>\n                </div>\n              </div>\n            </div>\n\n            {/* FAQ Details */}\n            <div className=\"space-y-4\">\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"editedQuestion\">Question *</Label>\n                <Textarea\n                  id=\"editedQuestion\"\n                  placeholder=\"Edit the question for clarity...\"\n                  className=\"min-h-[80px]\"\n                  value={formData.editedQuestion}\n                  onChange={(e) =>\n                    handleInputChange(\"editedQuestion\", e.target.value)\n                  }\n                />\n                <p className=\"text-sm text-gray-500\">\n                  You can edit the question to make it clearer for other\n                  visitors.\n                </p>\n              </div>\n\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"answer\">Answer *</Label>\n                <Textarea\n                  id=\"answer\"\n                  placeholder=\"Edit the answer if needed...\"\n                  className=\"min-h-[80px]\"\n                  value={formData.answer}\n                  onChange={(e) => handleInputChange(\"answer\", e.target.value)}\n                />\n              </div>\n\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"visibilityMode\">Visibility</Label>\n                <Select\n                  value={formData.visibilityMode}\n                  onValueChange={(value) =>\n                    handleInputChange(\n                      \"visibilityMode\",\n                      value as PublishFAQFormData[\"visibilityMode\"],\n                    )\n                  }\n                >\n                  <SelectTrigger className=\"h-12\">\n                    <SelectValue placeholder=\"Select visibility scope\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {visibilityOptions.map((option) => (\n                      <SelectItem\n                        key={option.value}\n                        value={option.value}\n                        disabled={\n                          option.value === \"PUBLIC_DOCUMENT\" &&\n                          !conversation.dataroomDocument\n                        }\n                      >\n                        <div className=\"flex items-center space-x-4\">\n                          <option.icon className=\"h-4 w-4\" />\n                          <div className=\"flex flex-col items-start\">\n                            <div className=\"font-medium\">{option.label}</div>\n                            <div className=\"text-xs text-gray-500\">\n                              {option.description}\n                            </div>\n                          </div>\n                        </div>\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              </div>\n            </div>\n          </div>\n\n          <DialogFooter>\n            <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n              Cancel\n            </Button>\n            <Button\n              type=\"submit\"\n              variant=\"default\"\n              disabled={\n                isPublishing ||\n                !selectedQuestionMessage ||\n                !selectedAnswerMessage\n              }\n            >\n              {isPublishing ? \"Publishing...\" : \"Publish FAQ\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/shared/conversation-document-context.tsx",
    "content": "import { BookOpenIcon, FileTextIcon } from \"lucide-react\";\n\ninterface ConversationDocumentContextProps {\n  dataroomDocument?: {\n    document: {\n      name: string;\n      type?: string;\n    };\n  };\n  documentPageNumber?: number | null;\n  documentVersionNumber?: number | null;\n  className?: string;\n  showVersionNumber?: boolean; // Controls whether to show version info\n}\n\nexport function ConversationDocumentContext({\n  dataroomDocument,\n  documentPageNumber,\n  documentVersionNumber,\n  className = \"\",\n  showVersionNumber = false,\n}: ConversationDocumentContextProps) {\n  if (!dataroomDocument) return null;\n\n  const hasPageOrVersion =\n    documentPageNumber || (showVersionNumber && documentVersionNumber);\n\n  return (\n    <div\n      className={`flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm ${className}`}\n    >\n      <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n        <FileTextIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n        <span className=\"truncate font-medium\">\n          {dataroomDocument.document.name}\n        </span>\n      </div>\n\n      {hasPageOrVersion && (\n        <div className=\"flex shrink-0 items-center gap-2 text-xs text-muted-foreground\">\n          {documentPageNumber && (\n            <span className=\"flex items-center gap-1\">\n              <BookOpenIcon className=\"h-3 w-3\" />\n              Page {documentPageNumber}\n            </span>\n          )}\n          {documentPageNumber && showVersionNumber && documentVersionNumber && (\n            <span>•</span>\n          )}\n          {showVersionNumber && documentVersionNumber && (\n            <span>v{documentVersionNumber}</span>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/shared/conversation-message.tsx",
    "content": "import { format } from \"date-fns\";\nimport { Check, HelpCircle, MessageSquareReply } from \"lucide-react\";\n\nexport function ConversationMessage({\n  message,\n  isAuthor,\n  senderEmail,\n  isSelectable = false,\n  isSelected = false,\n  selectionType,\n  isPublished = false,\n  onSelect,\n}: {\n  message: any;\n  isAuthor: boolean;\n  senderEmail: string;\n  isSelectable?: boolean;\n  isSelected?: boolean;\n  selectionType?: \"question\" | \"answer\";\n  isPublished?: boolean;\n  onSelect?: (messageId: string, isVisitor: boolean) => void;\n}) {\n  const isVisitor = message.viewerId != null;\n  const canBeSelected =\n    isSelectable &&\n    ((isVisitor && !isAuthor) || // Visitor questions\n      (!isVisitor && isAuthor)); // Admin answers\n\n  return (\n    <div className=\"group relative\">\n      <div\n        className={`flex w-fit max-w-[80%] cursor-pointer flex-col rounded-lg px-4 py-2 transition-all ${\n          isAuthor ? \"ml-auto bg-primary text-primary-foreground\" : \"bg-muted\"\n        } ${\n          isSelected\n            ? \"ring-2 ring-blue-500 ring-offset-2\"\n            : canBeSelected\n              ? \"hover:ring-1 hover:ring-gray-300\"\n              : \"\"\n        }`}\n        onClick={() => canBeSelected && onSelect?.(message.id, isVisitor)}\n      >\n        <div className=\"flex items-start justify-between gap-2\">\n          <p className=\"min-w-0 flex-1 break-words text-sm\">\n            {message.content}\n          </p>\n          <div className=\"mt-0.5 flex items-center gap-1\">\n            {isPublished && (\n              <div className=\"flex items-center text-xs opacity-60\">\n                <Check className=\"mr-1 h-3 w-3\" />\n                FAQ\n              </div>\n            )}\n            {canBeSelected && (\n              <div\n                className={`flex items-center text-xs opacity-60 ${\n                  isSelected\n                    ? \"opacity-100\"\n                    : \"opacity-0 group-hover:opacity-60\"\n                }`}\n              >\n                {isVisitor ? (\n                  <>\n                    <HelpCircle className=\"mr-1 h-3 w-3\" />\n                    {isSelected ? \"Question\" : \"Q\"}\n                  </>\n                ) : (\n                  <>\n                    <MessageSquareReply className=\"mr-1 h-3 w-3\" />\n                    {isSelected ? \"Answer\" : \"A\"}\n                  </>\n                )}\n              </div>\n            )}\n          </div>\n        </div>\n        <div className=\"mt-1 text-xs opacity-70\">\n          {isAuthor ? \"You\" : message.userId ? \"Admin\" : senderEmail} •{\" \"}\n          {format(new Date(message.createdAt), \"MMM d, h:mm a\")}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/viewer/conversation-view-sidebar.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { ConversationDocumentContext } from \"@/ee/features/conversations/components/shared/conversation-document-context\";\nimport { ConversationMessage } from \"@/ee/features/conversations/components/shared/conversation-message\";\nimport { FAQSection } from \"@/ee/features/conversations/components/viewer/faq-section\";\nimport { format } from \"date-fns\";\nimport { ArrowLeftIcon, BellIcon, BellOffIcon, Plus } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR, { mutate } from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\nimport { MAX_MESSAGE_LENGTH } from \"@/lib/utils/sanitize-html\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Sheet, SheetContent } from \"@/components/ui/sheet\";\n\n// Type definitions\ninterface Message {\n  id: string;\n  content: string;\n  createdAt: string;\n  updatedAt: string;\n  userId: string | null;\n  viewerId: string | null;\n  isRead: boolean;\n}\n\ninterface Conversation {\n  id: string;\n  title: string | null;\n  createdAt: string;\n  updatedAt: string;\n  messages: Message[];\n  userId: string | null;\n  viewerId: string | null;\n  documentPageNumber: number | null;\n  documentVersionNumber: number | null;\n  dataroomDocument?: {\n    document: {\n      name: string;\n    };\n  };\n  receiveNotifications: boolean;\n}\n\ninterface CreateConversationData {\n  title?: string;\n  initialMessage: string;\n}\n\nexport type ConversationSidebarProps = {\n  linkId: string;\n  viewId: string;\n  dataroomId?: string;\n  documentId?: string;\n  pageNumber?: number;\n  viewerId?: string;\n  isEnabled?: boolean;\n  isOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\nexport function ConversationViewSidebar({\n  dataroomId,\n  documentId,\n  pageNumber,\n  viewId,\n  viewerId,\n  linkId,\n  isEnabled = true,\n  isOpen = false,\n  onOpenChange,\n}: ConversationSidebarProps) {\n  const [activeConversation, setActiveConversation] =\n    useState<Conversation | null>(null);\n  const [isNewConversationFormOpen, setIsNewConversationFormOpen] =\n    useState(false);\n  const [newMessage, setNewMessage] = useState(\"\");\n\n  // SWR hook for fetching conversations\n  const {\n    data: conversations = [],\n    error,\n    isLoading,\n  } = useSWR<Conversation[]>(\n    `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,\n    fetcher,\n    {\n      revalidateOnFocus: true,\n      dedupingInterval: 5000, // 5 seconds\n      keepPreviousData: true,\n      onError: (err) => {\n        console.error(\"Error fetching conversations:\", err);\n        toast.error(\"Failed to load conversations\");\n      },\n    },\n  );\n\n  // Create a new conversation\n  const handleCreateConversation = async (data: CreateConversationData) => {\n    console.log(\"Creating conversation\", data);\n    try {\n      const response = await fetch(\"/api/conversations\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          ...data,\n          viewId,\n          viewerId,\n          documentId,\n          pageNumber,\n          linkId,\n          dataroomId,\n        }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        toast.error(errorData.error);\n        return;\n      }\n\n      const newConversation = await response.json();\n\n      // Update the SWR cache with the new conversation\n      mutate(\n        `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,\n        [newConversation, ...(conversations || [])],\n        false,\n      );\n\n      setActiveConversation(newConversation);\n      setIsNewConversationFormOpen(false);\n\n      toast.success(\"Conversation created successfully\");\n    } catch (error) {\n      toast.error(\"Failed to create conversation\");\n    }\n  };\n\n  // Send a new message to an active conversation\n  const handleSendMessage = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!newMessage.trim() || !activeConversation) return;\n\n    try {\n      const response = await fetch(`/api/conversations/messages`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          content: newMessage,\n          viewId,\n          viewerId,\n          conversationId: activeConversation.id,\n        }),\n      });\n\n      if (!response.ok) {\n        const errorData = await response.json();\n        toast.error(errorData.error);\n        return;\n      }\n\n      const message: Message = await response.json();\n\n      // Update the SWR cache with the new message\n      mutate(\n        `/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,\n        conversations?.map((conv) =>\n          conv.id === activeConversation.id\n            ? {\n                ...conv,\n                messages: [...conv.messages, message],\n                updatedAt: new Date().toISOString(),\n              }\n            : conv,\n        ),\n        false,\n      );\n\n      // Also update the active conversation\n      setActiveConversation((prev) =>\n        prev\n          ? {\n              ...prev,\n              messages: [...prev.messages, message],\n              updatedAt: new Date().toISOString(),\n            }\n          : null,\n      );\n\n      setNewMessage(\"\");\n    } catch (error) {\n      console.error(\"Error sending message:\", error);\n      toast.error(\"Failed to send message\");\n    }\n  };\n\n  return (\n    <Sheet open={isOpen} onOpenChange={onOpenChange}>\n      <SheetContent side=\"right\" className=\"p-0 sm:max-w-md\">\n        <div className=\"flex h-full flex-col\">\n          {/* Header */}\n          {/* <div className=\"flex items-center justify-between border-b px-4 py-3\">\n            <h2 className=\"text-lg font-medium\">Questions</h2>\n          </div> */}\n\n          {/* Content */}\n          <div className=\"flex flex-1 flex-col overflow-hidden\">\n            {isEnabled ? (\n              <>\n                {/* FAQ Section */}\n                <FAQSection\n                  dataroomId={dataroomId}\n                  linkId={linkId}\n                  documentId={documentId}\n                  viewerId={viewerId}\n                />\n\n                {activeConversation ? (\n                  <div className=\"flex flex-1 flex-col overflow-hidden\">\n                    {/* Conversation Header */}\n                    <div className=\"flex-shrink-0 border-b p-4\">\n                      <div className=\"flex items-center justify-between\">\n                        <div className=\"flex min-w-0 flex-1 items-center\">\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            onClick={() => setActiveConversation(null)}\n                            className=\"-ml-2 mr-2 shrink-0\"\n                          >\n                            <ArrowLeftIcon className=\"h-5 w-5\" />\n                          </Button>\n                          <div className=\"min-w-0\">\n                            <h3 className=\"truncate font-medium\">\n                              {activeConversation.title || \"Question\"}\n                            </h3>\n                          </div>\n                        </div>\n                        <Button\n                          variant={\n                            activeConversation.receiveNotifications\n                              ? \"default\"\n                              : \"ghost\"\n                          }\n                          size=\"icon\"\n                          onClick={async () => {\n                            try {\n                              const response = await fetch(\n                                `/api/conversations/notifications`,\n                                {\n                                  method: \"POST\",\n                                  headers: {\n                                    \"Content-Type\": \"application/json\",\n                                  },\n                                  body: JSON.stringify({\n                                    conversationId: activeConversation.id,\n                                    viewerId: viewerId,\n                                    enabled:\n                                      !activeConversation.receiveNotifications,\n                                  }),\n                                },\n                              );\n\n                              if (!response.ok)\n                                throw new Error(\n                                  \"Failed to toggle notifications\",\n                                );\n\n                              // Update the local state\n                              setActiveConversation((prev) =>\n                                prev\n                                  ? {\n                                      ...prev,\n                                      receiveNotifications:\n                                        !prev.receiveNotifications,\n                                    }\n                                  : null,\n                              );\n\n                              toast.success(\n                                `Notifications ${!activeConversation.receiveNotifications ? \"enabled\" : \"disabled\"}`,\n                              );\n                            } catch (error) {\n                              console.error(\n                                \"Error toggling notifications:\",\n                                error,\n                              );\n                              toast.error(\"Failed to toggle notifications\");\n                            }\n                          }}\n                        >\n                          {activeConversation.receiveNotifications ? (\n                            <BellIcon className=\"h-5 w-5 text-background\" />\n                          ) : (\n                            <BellOffIcon className=\"h-5 w-5 text-muted-foreground\" />\n                          )}\n                        </Button>\n                      </div>\n                    </div>\n\n                    {/* Messages */}\n                    <ScrollArea className=\"flex-1\">\n                      <div className=\"flex flex-col gap-2 p-4\">\n                        {/* Document Context */}\n                        <ConversationDocumentContext\n                          dataroomDocument={activeConversation.dataroomDocument}\n                          documentPageNumber={\n                            activeConversation.documentPageNumber\n                          }\n                          documentVersionNumber={\n                            activeConversation.documentVersionNumber\n                          }\n                          showVersionNumber={false} // Viewers see simplified context\n                          className=\"mb-2\"\n                        />\n                        {activeConversation.messages?.map((message) => (\n                          <ConversationMessage\n                            key={message.id}\n                            message={message}\n                            isAuthor={message.viewerId === viewerId}\n                            senderEmail={\"\"}\n                          />\n                        ))}\n                      </div>\n                    </ScrollArea>\n\n                    {/* Message Input */}\n                    <form\n                      onSubmit={handleSendMessage}\n                      className=\"flex-shrink-0 border-t p-3\"\n                    >\n                      <div className=\"flex gap-2\">\n                        <input\n                          type=\"text\"\n                          value={newMessage}\n                          onChange={(e) => setNewMessage(e.target.value)}\n                          maxLength={MAX_MESSAGE_LENGTH}\n                          className=\"flex-1 rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                          placeholder=\"Type your message...\"\n                        />\n                        <Button type=\"submit\" disabled={!newMessage.trim()}>\n                          Send\n                        </Button>\n                      </div>\n                    </form>\n                  </div>\n                ) : isNewConversationFormOpen ? (\n                  <div className=\"flex-1 p-4\">\n                    <div className=\"mb-4 flex items-center\">\n                      <Button\n                        variant=\"ghost\"\n                        size=\"sm\"\n                        onClick={() => setIsNewConversationFormOpen(false)}\n                        className=\"-ml-2 mr-2\"\n                      >\n                        <ArrowLeftIcon className=\"h-5 w-5\" />\n                      </Button>\n                      <h3 className=\"font-medium\">New Question</h3>\n                    </div>\n\n                    <form\n                      onSubmit={(e: React.FormEvent) => {\n                        e.preventDefault();\n                        if (!newMessage.trim()) return;\n\n                        handleCreateConversation({\n                          initialMessage: newMessage,\n                        });\n                        setNewMessage(\"\");\n                      }}\n                      className=\"space-y-4\"\n                    >\n                      <div>\n                        <textarea\n                          id=\"message\"\n                          value={newMessage}\n                          onChange={(e) => setNewMessage(e.target.value)}\n                          maxLength={MAX_MESSAGE_LENGTH}\n                          placeholder=\"Type your question...\"\n                          className=\"min-h-[100px] w-full rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                          required\n                        />\n                      </div>\n\n                      <div className=\"flex justify-end gap-2\">\n                        <Button\n                          type=\"button\"\n                          variant=\"outline\"\n                          onClick={() => setIsNewConversationFormOpen(false)}\n                        >\n                          Cancel\n                        </Button>\n                        <Button type=\"submit\" disabled={!newMessage.trim()}>\n                          Ask Question\n                        </Button>\n                      </div>\n                    </form>\n                  </div>\n                ) : (\n                  <div className=\"flex flex-1 flex-col overflow-hidden\">\n                    <div className=\"flex-shrink-0 p-4\">\n                      <Button\n                        onClick={() => setIsNewConversationFormOpen(true)}\n                        className=\"w-full\"\n                      >\n                        <Plus className=\"mr-2 h-4 w-4\" />\n                        New Question\n                      </Button>\n                    </div>\n                    <Separator />\n                    <div className=\"flex-1 overflow-y-auto\">\n                      <div className=\"divide-y\">\n                        {isLoading ? (\n                          <div className=\"space-y-3 p-4\">\n                            {Array.from({ length: 3 }).map((_, i) => (\n                              <div key={i} className=\"flex flex-col gap-2\">\n                                <div className=\"h-5 w-3/4 animate-pulse bg-muted\"></div>\n                                <div className=\"h-4 w-1/2 animate-pulse bg-muted\"></div>\n                                <div className=\"h-10 w-full animate-pulse bg-muted\"></div>\n                              </div>\n                            ))}\n                          </div>\n                        ) : conversations.length === 0 ? (\n                          <div className=\"p-4 text-center text-muted-foreground\">\n                            No questions yet. Ask a new one!\n                          </div>\n                        ) : (\n                          conversations.map((conversation) => {\n                            const lastMessage =\n                              conversation.messages?.[\n                                conversation.messages.length - 1\n                              ];\n                            const hasUnread = conversation.messages?.some(\n                              (msg) =>\n                                !msg.isRead &&\n                                (msg.userId ||\n                                  msg.viewerId !== conversation.viewerId),\n                            );\n\n                            return (\n                              <div\n                                key={conversation.id}\n                                className=\"cursor-pointer p-4 hover:bg-muted/50\"\n                                onClick={() =>\n                                  setActiveConversation(conversation)\n                                }\n                              >\n                                <div className=\"mb-1 flex items-center justify-between\">\n                                  <h3 className=\"truncate font-medium\">\n                                    {conversation.title || \"Untitled question\"}\n                                  </h3>\n                                  {/* {hasUnread && (\n                                  <span className=\"ml-2 inline-flex h-5 items-center rounded-full bg-primary px-2 text-xs font-medium text-primary-foreground\">\n                                    New\n                                  </span>\n                                )} */}\n                                </div>\n\n                                <div className=\"mb-2 text-xs text-muted-foreground\">\n                                  {format(\n                                    new Date(conversation.updatedAt),\n                                    \"MMM d, h:mm a\",\n                                  )}\n                                </div>\n\n                                {lastMessage && (\n                                  <p className=\"line-clamp-2 text-sm\">\n                                    {lastMessage.content}\n                                  </p>\n                                )}\n                              </div>\n                            );\n                          })\n                        )}\n                      </div>\n                    </div>\n                  </div>\n                )}\n              </>\n            ) : (\n              <div className=\"flex h-full items-center justify-center p-4 text-center text-muted-foreground\">\n                Questions are disabled for this document.\n              </div>\n            )}\n          </div>\n        </div>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/components/viewer/faq-section.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\n\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/accordion\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface FAQ {\n  id: string;\n  editedQuestion: string;\n  answer: string;\n  title?: string;\n  tags: string[];\n  viewCount: number;\n  description?: string;\n  documentPageNumber?: number;\n  documentVersionNumber?: number;\n  createdAt: string;\n}\n\ninterface FAQSectionProps {\n  dataroomId?: string;\n  linkId: string;\n  documentId?: string;\n  viewerId?: string;\n}\n\nexport function FAQSection({\n  dataroomId,\n  linkId,\n  documentId,\n  viewerId,\n}: FAQSectionProps) {\n  const [viewedFAQs, setViewedFAQs] = useState<Set<string>>(new Set());\n  const [showTopBlur, setShowTopBlur] = useState(false);\n  const [showBottomBlur, setShowBottomBlur] = useState(false);\n  const scrollAreaRef = useRef<HTMLDivElement>(null);\n\n  // Fetch FAQs for this dataroom/link/document\n  const { data: faqs = [], isLoading } = useSWR<FAQ[]>(\n    `/api/faqs?${new URLSearchParams({\n      ...(dataroomId && { dataroomId }),\n      ...(linkId && { linkId }),\n      ...(documentId && { documentId }),\n    }).toString()}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000, // 30 seconds\n      keepPreviousData: true,\n    },\n  );\n\n  const handleAccordionChange = async (openItems: string[]) => {\n    // Track views for newly opened items\n    const newlyOpened = openItems.filter((item) => !viewedFAQs.has(item));\n\n    for (const faqId of newlyOpened) {\n      try {\n        const params = new URLSearchParams({\n          ...(dataroomId && { dataroomId }),\n          ...(linkId && { linkId }),\n        });\n\n        await fetch(`/api/faqs/${faqId}?${params.toString()}`, {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        });\n\n        // Mark as viewed\n        setViewedFAQs((prev) => new Set(prev).add(faqId));\n      } catch (error) {\n        console.error(\"Error tracking FAQ view:\", error);\n      }\n    }\n  };\n\n  // Check scroll position to show/hide blur effects\n  const checkScrollPosition = () => {\n    const scrollElement = scrollAreaRef.current?.querySelector(\n      \"[data-radix-scroll-area-viewport]\",\n    );\n    if (!scrollElement) return;\n\n    const { scrollTop, scrollHeight, clientHeight } = scrollElement;\n\n    setShowTopBlur(scrollTop > 10);\n    setShowBottomBlur(scrollTop < scrollHeight - clientHeight - 10);\n  };\n\n  // Set up scroll listeners and initial check\n  useEffect(() => {\n    const scrollElement = scrollAreaRef.current?.querySelector(\n      \"[data-radix-scroll-area-viewport]\",\n    );\n    if (!scrollElement) return;\n\n    // Initial check and delayed check for content that loads after render\n    checkScrollPosition();\n    const timeoutId = setTimeout(checkScrollPosition, 100);\n\n    scrollElement.addEventListener(\"scroll\", checkScrollPosition);\n    return () => {\n      scrollElement.removeEventListener(\"scroll\", checkScrollPosition);\n      clearTimeout(timeoutId);\n    };\n  }, [faqs]);\n\n  if (isLoading) {\n    return (\n      <>\n        <div className=\"bg-gray-50 p-4 dark:bg-gray-900\">\n          <div className=\"mb-3 flex items-center gap-2\">\n            <h3 className=\"font-medium text-primary\">\n              Frequently Asked Questions\n            </h3>\n            <div className=\"h-5 w-5 animate-pulse rounded bg-muted\"></div>\n          </div>\n\n          <div className=\"space-y-2\">\n            {Array.from({ length: 2 }).map((_, i) => (\n              <div\n                key={i}\n                className=\"rounded-md border border-gray-200 bg-white\"\n              >\n                <div className=\"px-3 py-2.5\">\n                  <div className=\"flex w-full items-center justify-between gap-2\">\n                    <div className=\"h-5 w-5 animate-pulse rounded bg-muted\"></div>\n                    <div className=\"h-5 w-full animate-pulse rounded bg-muted\"></div>\n                  </div>\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n        <Separator />\n      </>\n    );\n  }\n\n  if (faqs.length === 0) {\n    return (\n      <>\n        <div className=\"bg-gray-50 p-4 dark:bg-gray-900\">\n          <div className=\"mb-3 flex items-center gap-2\">\n            <h3 className=\"font-medium text-primary\">\n              Frequently Asked Questions\n            </h3>\n            <Badge\n              variant=\"notification\"\n              className=\"bg-primary/80 text-xs text-primary-foreground\"\n            >\n              0\n            </Badge>\n          </div>\n          <div className=\"space-y-2 text-sm\">No questions published yet.</div>\n        </div>\n        <Separator />\n      </>\n    );\n  }\n\n  return (\n    <>\n      <div className=\"flex-shrink-0 bg-gray-50 dark:bg-gray-900\">\n        <div className=\"px-4 pb-2 pt-4\">\n          <div className=\"mb-3 flex items-center gap-2\">\n            <h3 className=\"font-medium text-primary\">\n              Frequently Asked Questions\n            </h3>\n            <Badge\n              variant=\"notification\"\n              className=\"bg-primary/80 text-xs text-primary-foreground\"\n            >\n              {faqs.length}\n            </Badge>\n          </div>\n        </div>\n\n        <div className=\"relative px-4\">\n          <ScrollArea className=\"h-fit\" ref={scrollAreaRef}>\n            <div className=\"max-h-80\">\n              <Accordion\n                type=\"multiple\"\n                defaultValue={faqs.length > 0 ? [faqs[0].id] : []}\n                onValueChange={handleAccordionChange}\n                className=\"space-y-2 last:pb-4\"\n              >\n                {faqs.map((faq) => (\n                  <AccordionItem\n                    key={faq.id}\n                    value={faq.id}\n                    className=\"rounded-md border border-gray-200 bg-white\"\n                  >\n                    <AccordionTrigger className=\"min-w-0 px-3 py-2.5 hover:no-underline\">\n                      <div className=\"flex w-full items-start justify-between gap-2\">\n                        <span className=\"flex-shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground\">\n                          Q\n                        </span>\n                        <div className=\"line-clamp-2 min-w-0 flex-1 break-words text-left text-sm font-medium text-gray-900\">\n                          {faq.editedQuestion}\n                        </div>\n                      </div>\n                    </AccordionTrigger>\n\n                    <AccordionContent className=\"rounded-b-md bg-muted px-3 pb-3 text-foreground\">\n                      <div className=\"space-y-2 pt-3\">\n                        <div className=\"flex items-start gap-2\">\n                          <span className=\"flex-shrink-0 rounded bg-primary/80 px-1.5 py-0.5 text-xs font-medium text-primary-foreground\">\n                            A\n                          </span>\n                          <p className=\"min-w-0 break-words whitespace-pre-wrap text-sm font-medium text-gray-700\">\n                            {faq.answer}\n                          </p>\n                        </div>\n                      </div>\n                    </AccordionContent>\n                  </AccordionItem>\n                ))}\n              </Accordion>\n            </div>\n          </ScrollArea>\n\n          {/* Scroll blur indicators */}\n          {showTopBlur && (\n            <div className=\"pointer-events-none absolute left-4 right-4 top-0 z-10 h-6 bg-gradient-to-b from-gray-50 via-gray-50/80 to-transparent dark:from-gray-900 dark:via-gray-900/80\" />\n          )}\n          {showBottomBlur && (\n            <div className=\"pointer-events-none absolute bottom-0 left-4 right-4 z-10 h-6 bg-gradient-to-t from-gray-50 via-gray-50/80 to-transparent dark:from-gray-900 dark:via-gray-900/80\" />\n          )}\n        </div>\n      </div>\n      <Separator />\n    </>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/emails/components/conversation-notification.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nexport default function ConversationNotification({\n  conversationTitle,\n  dataroomName,\n  senderEmail,\n  url,\n  unsubscribeUrl,\n}: {\n  conversationTitle: string;\n  dataroomName: string;\n  senderEmail: string;\n  url: string;\n  unsubscribeUrl: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>Conversation update available</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl\">\n              {`New message in ${dataroomName}`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              A new message has been added to the conversation{\" \"}\n              <span className=\"font-semibold\">{conversationTitle}</span> in the\n              dataroom <span className=\"font-semibold\">{dataroomName}</span> on\n              Papermark.\n            </Text>\n            <Section className=\"mb-[32px] mt-[32px] text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${url}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View the conversation\n              </Button>\n            </Section>\n            <Text className=\"text-sm text-black\">\n              or copy and paste this URL into your browser: <br />\n              {`${url}`}\n            </Text>\n            <Text className=\"text-sm text-gray-400\">Papermark</Text>\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc.\n              </Text>\n              <Text className=\"text-xs\">\n                You received this email from{\" \"}\n                <span className=\"font-semibold\">{senderEmail}</span> because you\n                turned on notifications for the conversation{\" \"}\n                <span className=\"font-semibold\">{conversationTitle}</span> in\n                the dataroom{\" \"}\n                <span className=\"font-semibold\">{dataroomName}</span> on\n                Papermark. If you have any feedback or questions about this\n                email, simply reply to it. To unsubscribe from updates about\n                this dataroom,{\" \"}\n                <a\n                  href={unsubscribeUrl}\n                  className=\"text-gray-400 underline underline-offset-2 hover:text-gray-400\"\n                >\n                  click here\n                </a>\n                .\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/emails/components/conversation-team-notification.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nexport default function ConversationTeamNotification({\n  conversationTitle,\n  dataroomName,\n  senderEmail,\n  url,\n}: {\n  conversationTitle: string;\n  dataroomName: string;\n  senderEmail: string;\n  url: string;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>New message from visitor in your dataroom</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-xl font-semibold\">\n              {`New message in ${dataroomName}`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              A visitor (<span className=\"font-semibold\">{senderEmail}</span>)\n              has sent a new message in the conversation{\" \"}\n              <span className=\"font-semibold\">{conversationTitle}</span> in your\n              dataroom <span className=\"font-semibold\">{dataroomName}</span> on\n              Papermark.\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              As a manager, you&apos;re receiving this notification to stay\n              informed about visitor interactions in your dataroom.\n            </Text>\n            <Section className=\"mb-[32px] mt-[32px] text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${url}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View the conversation\n              </Button>\n            </Section>\n            <Text className=\"text-sm text-black\">\n              or copy and paste this URL into your browser: <br />\n              {`${url}`}\n            </Text>\n            <Text className=\"text-sm text-gray-400\">Papermark</Text>\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc.\n              </Text>\n              <Text className=\"text-xs\">\n                If you have any feedback or questions about this email, simply\n                reply to it.\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/emails/lib/send-conversation-notification.ts",
    "content": "import ConversationNotification from \"@/ee/features/conversations/emails/components/conversation-notification\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendConversationNotification = async ({\n  dataroomName,\n  conversationTitle,\n  senderEmail,\n  to,\n  url,\n  unsubscribeUrl,\n}: {\n  dataroomName: string;\n  conversationTitle: string;\n  senderEmail: string;\n  to: string;\n  url: string;\n  unsubscribeUrl: string;\n}) => {\n  try {\n    await sendEmail({\n      to: to,\n      replyTo: senderEmail,\n      subject: `New message in ${dataroomName}`,\n      react: ConversationNotification({\n        senderEmail,\n        conversationTitle,\n        dataroomName,\n        url,\n        unsubscribeUrl,\n      }),\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n      unsubscribeUrl,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "ee/features/conversations/emails/lib/send-conversation-team-notification.ts",
    "content": "import ConversationTeamNotification from \"@/ee/features/conversations/emails/components/conversation-team-notification\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendConversationTeamNotification = async ({\n  dataroomName,\n  conversationTitle,\n  senderEmail,\n  teamMemberEmails,\n  url,\n}: {\n  dataroomName: string;\n  conversationTitle: string;\n  senderEmail: string;\n  teamMemberEmails: string[];\n  url: string;\n}) => {\n  try {\n    if (!teamMemberEmails || teamMemberEmails.length === 0) {\n      console.log(\"No team member emails provided\");\n      return;\n    }\n\n    await sendEmail({\n      to: teamMemberEmails[0], // Send to first team member\n      cc: teamMemberEmails.slice(1).join(\",\"), // Send to all other team members\n      subject: `New visitor message in ${dataroomName}`,\n      react: ConversationTeamNotification({\n        senderEmail,\n        conversationTitle,\n        dataroomName,\n        url,\n      }),\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(\"Failed to send team member notification:\", e);\n  }\n};\n"
  },
  {
    "path": "ee/features/conversations/lib/api/conversations/index.ts",
    "content": "import { ConversationVisibility, ParticipantRole } from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\nimport { validateContent } from \"@/lib/utils/sanitize-html\";\n\nexport type CreateConversationInput = {\n  title?: string;\n  dataroomDocumentId?: string;\n  documentPageNumber?: number;\n  documentVersionNumber?: number;\n  linkId?: string;\n  viewerGroupId?: string;\n  initialMessage?: string;\n};\n\nexport const conversationService = {\n  // Check if conversations are allowed for a link/dataroom\n  async areConversationsAllowed(\n    dataroomId: string,\n    linkId?: string,\n  ): Promise<boolean> {\n    // Get dataroom settings\n    const dataroom = await prisma.dataroom.findUnique({\n      where: { id: dataroomId },\n      select: { conversationsEnabled: true },\n    });\n\n    if (!dataroom?.conversationsEnabled) {\n      return false;\n    }\n\n    // If link is specified, check link-specific settings\n    if (linkId) {\n      const link = await prisma.link.findUnique({\n        where: { id: linkId },\n        select: { enableConversation: true },\n      });\n\n      return !!link?.enableConversation;\n    }\n\n    return true;\n  },\n\n  // Create a new conversation\n  async createConversation({\n    dataroomId,\n    viewId,\n    data,\n    teamId,\n    viewerId,\n    userId,\n  }: {\n    dataroomId: string;\n    viewId: string;\n    data: CreateConversationInput;\n    teamId: string;\n    viewerId?: string;\n    userId?: string;\n  }) {\n    // Only one of viewerId or userId should be provided\n    if ((!viewerId && !userId) || (viewerId && userId)) {\n      throw new Error(\n        \"Either viewerId or userId must be provided, but not both\",\n      );\n    }\n\n    const sanitizedInitialMessage = validateContent(data.initialMessage || \"\");\n\n    return prisma.conversation.create({\n      data: {\n        title: data.title || sanitizedInitialMessage.slice(0, 20),\n        dataroomId,\n        teamId,\n        dataroomDocumentId: data.dataroomDocumentId,\n        documentPageNumber: data.documentPageNumber,\n        documentVersionNumber: data.documentVersionNumber,\n        linkId: data.linkId,\n        viewerGroupId: data.viewerGroupId,\n        initialViewId: viewId,\n        visibilityMode: \"PRIVATE\" as ConversationVisibility,\n        lastMessageAt: new Date(),\n        participants: {\n          create: {\n            viewerId,\n            userId,\n            role: \"OWNER\" as ParticipantRole,\n            receiveNotifications: true,\n          },\n        },\n        messages: data.initialMessage\n          ? {\n              create: {\n                content: sanitizedInitialMessage,\n                viewerId,\n                userId,\n                viewId,\n              },\n            }\n          : undefined,\n        views: {\n          create: {\n            viewId,\n          },\n        },\n      },\n      include: {\n        participants: true,\n        messages: true,\n        views: true,\n        dataroom: true,\n        dataroomDocument: {\n          include: {\n            document: true,\n          },\n        },\n      },\n    });\n  },\n\n  // Get conversations for a dataroom\n  async getConversationsForDataroom({\n    dataroomId,\n    viewerId,\n    userId,\n    includeMessages = false,\n  }: {\n    dataroomId: string;\n    viewerId?: string;\n    userId?: string;\n    includeMessages?: boolean;\n  }) {\n    // Different queries for admin vs viewer\n    if (userId) {\n      // Admin/team member can see all conversations for the dataroom\n      return prisma.conversation.findMany({\n        where: {\n          dataroomId,\n        },\n        include: {\n          participants: true,\n          messages: includeMessages,\n          dataroom: true,\n          dataroomDocument: {\n            include: {\n              document: true,\n            },\n          },\n        },\n        orderBy: {\n          updatedAt: \"desc\",\n        },\n      });\n    } else if (viewerId) {\n      // Viewer can only see their own private conversations and public ones\n      return prisma.conversation.findMany({\n        where: {\n          dataroomId,\n          OR: [\n            // Private conversations where they are a participant\n            {\n              visibilityMode: \"PRIVATE\",\n              participants: {\n                some: {\n                  viewerId,\n                },\n              },\n            },\n            // Public conversations\n            {\n              visibilityMode: {\n                in: [\"PUBLIC_LINK\", \"PUBLIC_DOCUMENT\", \"PUBLIC_DATAROOM\"],\n              },\n            },\n          ],\n        },\n        include: {\n          participants: true,\n          messages: includeMessages,\n          dataroom: true,\n          dataroomDocument: {\n            include: {\n              document: true,\n            },\n          },\n        },\n        orderBy: {\n          updatedAt: \"desc\",\n        },\n      });\n    }\n\n    return [];\n  },\n\n  // Toggle conversation enabled status for dataroom\n  async toggleDataroomConversations(dataroomId: string, enabled: boolean) {\n    return prisma.dataroom.update({\n      where: { id: dataroomId },\n      data: { conversationsEnabled: enabled },\n    });\n  },\n\n  // Toggle conversation enabled status for link\n  async toggleLinkConversations(linkId: string, enabled: boolean) {\n    return prisma.link.update({\n      where: { id: linkId },\n      data: { enableConversation: enabled },\n    });\n  },\n\n  // Get a single conversation with messages\n  async getConversation(\n    conversationId: string,\n    viewerId?: string,\n    userId?: string,\n  ) {\n    const conversation = await prisma.conversation.findUnique({\n      where: { id: conversationId },\n      include: {\n        participants: true,\n        messages: {\n          orderBy: {\n            createdAt: \"asc\",\n          },\n          include: {\n            user: true,\n            viewer: true,\n          },\n        },\n        dataroom: true,\n        dataroomDocument: true,\n      },\n    });\n\n    if (!conversation) {\n      return null;\n    }\n\n    // Check permissions\n    if (userId) {\n      // Team members can access all conversations in their datarooms\n      // You might want to add a check here to ensure the user has access to this dataroom\n      return conversation;\n    } else if (viewerId) {\n      // Viewers can only access conversations they're part of or public ones\n      const canAccess =\n        conversation.visibilityMode !== \"PRIVATE\" ||\n        conversation.participants.some((p) => p.viewerId === viewerId);\n\n      if (!canAccess) {\n        return null;\n      }\n\n      return conversation;\n    }\n\n    return null;\n  },\n\n  // Mark all messages in a conversation as read\n  async markConversationAsRead(conversationId: string, userId: string) {\n    // Get conversation to verify access\n    const conversation = await prisma.conversation.findUnique({\n      where: { id: conversationId },\n      include: {\n        messages: {\n          where: {\n            isRead: false,\n            viewerId: { not: null }, // Only mark viewer messages as read\n          },\n          select: {\n            id: true,\n          },\n        },\n      },\n    });\n\n    if (!conversation) {\n      throw new Error(\"Conversation not found\");\n    }\n\n    // Mark all unread messages as read\n    if (conversation.messages.length > 0) {\n      await prisma.message.updateMany({\n        where: {\n          id: {\n            in: conversation.messages.map((m) => m.id),\n          },\n        },\n        data: {\n          isRead: true,\n        },\n      });\n    }\n\n    return { success: true, markedCount: conversation.messages.length };\n  },\n\n  // Delete a conversation and all related data\n  async deleteConversation(\n    conversationId: string,\n    userId: string,\n    dataroomId: string,\n    teamId: string,\n  ) {\n    // First verify the conversation exists and user has access\n    const conversation = await prisma.conversation.findUnique({\n      where: {\n        id: conversationId,\n        dataroomId,\n        teamId,\n        team: { users: { some: { userId } } },\n      },\n    });\n\n    if (!conversation) {\n      throw new Error(\"Conversation not found\");\n    }\n\n    // Delete the conversation (cascade will handle related data like messages, participants, etc.)\n    await prisma.conversation.delete({\n      where: { id: conversationId },\n    });\n\n    return { success: true };\n  },\n};\n"
  },
  {
    "path": "ee/features/conversations/lib/api/messages/index.ts",
    "content": "import { waitUntil } from \"@vercel/functions\";\n\nimport prisma from \"@/lib/prisma\";\nimport { validateContent } from \"@/lib/utils/sanitize-html\";\n\nexport const messageService = {\n  // Add a message to a conversation\n  async addMessage({\n    conversationId,\n    content,\n    viewId,\n    viewerId,\n    userId,\n  }: {\n    conversationId: string;\n    content: string;\n    viewId?: string;\n    viewerId?: string;\n    userId?: string;\n  }) {\n    // Only one of viewerId or userId should be provided\n    if ((!viewerId && !userId) || (viewerId && userId)) {\n      throw new Error(\n        \"Either viewerId or userId must be provided, but not both\",\n      );\n    }\n\n    const sanitizedContent = validateContent(content);\n\n    // Add the message\n    const message = await prisma.message.create({\n      data: {\n        content: sanitizedContent,\n        conversationId,\n        viewerId,\n        userId,\n        viewId,\n        isRead: false,\n      },\n      include: {\n        conversation: {\n          include: {\n            participants: true,\n            views: true,\n          },\n        },\n        user: true,\n        viewer: true,\n      },\n    });\n\n    // Check if the current viewer/user is already a participant in the conversation\n    const isParticipant = message.conversation.participants.some(\n      (participant) =>\n        participant.viewerId === viewerId || participant.userId === userId,\n    );\n\n    const conversationPromises = [\n      // Update the conversation's updatedAt, lastMessageAt timestamp\n      prisma.conversation.update({\n        where: { id: conversationId },\n        data: { updatedAt: new Date(), lastMessageAt: new Date() },\n      }),\n\n      // Create the conversation view if it doesn't exist for viewerId\n      viewerId &&\n        prisma.conversationView.upsert({\n          where: {\n            conversationId_viewId: {\n              conversationId,\n              viewId: viewId!, // viewId is not null because of the viewerId check above\n            },\n          },\n          create: {\n            conversationId,\n            viewId: viewId!, // viewId is not null because of the viewerId check above\n          },\n          update: {}, // No update needed\n        }),\n\n      // Add the new participant to the conversation if they are not already a participant\n      !isParticipant &&\n        (viewerId\n          ? prisma.conversationParticipant.create({\n              data: { conversationId, viewerId },\n            })\n          : prisma.conversationParticipant.create({\n              data: { conversationId, userId },\n            })),\n    ];\n\n    waitUntil(Promise.all(conversationPromises));\n\n    return message;\n  },\n\n  // Mark a message as read\n  async markMessageAsRead(messageId: string) {\n    return prisma.message.update({\n      where: { id: messageId },\n      data: { isRead: true },\n    });\n  },\n\n  // Get unread messages for a participant\n  async getUnreadMessages(viewerId?: string, userId?: string) {\n    if (!viewerId && !userId) {\n      return [];\n    }\n\n    // Query based on participant type\n    if (userId) {\n      return prisma.message.findMany({\n        where: {\n          isRead: false,\n          conversation: {\n            participants: {\n              some: {\n                userId,\n              },\n            },\n          },\n          // Only messages from viewers (not from this user)\n          viewerId: { not: null },\n          userId: null,\n        },\n        include: {\n          conversation: true,\n          viewer: true,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n    } else {\n      return prisma.message.findMany({\n        where: {\n          isRead: false,\n          conversation: {\n            participants: {\n              some: {\n                viewerId,\n              },\n            },\n          },\n          // Only messages from team members or other viewers (not from this viewer)\n          viewerId: { not: viewerId },\n        },\n        include: {\n          conversation: true,\n          user: true,\n          viewer: true,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n    }\n  },\n};\n"
  },
  {
    "path": "ee/features/conversations/lib/api/notifications/index.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\nexport const notificationService = {\n  // Toggle notifications for a conversation\n  async toggleNotificationsForConversation({\n    conversationId,\n    viewerId,\n    enabled,\n  }: {\n    conversationId: string;\n    viewerId: string;\n    enabled: boolean;\n  }) {\n    return prisma.conversationParticipant.update({\n      where: {\n        conversationId_viewerId: { conversationId, viewerId },\n      },\n      data: { receiveNotifications: enabled },\n    });\n  },\n};\n"
  },
  {
    "path": "ee/features/conversations/lib/schemas/faq.ts",
    "content": "import { z } from \"zod\";\n\n// Base FAQ schema with core validation rules\nconst baseFAQSchema = z.object({\n  editedQuestion: z\n    .string()\n    .min(10, \"Question must be at least 10 characters\")\n    .max(1000, \"Question too long\"),\n  answer: z\n    .string()\n    .min(10, \"Answer must be at least 10 characters\")\n    .max(1000, \"Answer too long\"),\n  visibilityMode: z.enum([\"PUBLIC_DATAROOM\", \"PUBLIC_LINK\", \"PUBLIC_DOCUMENT\"]),\n});\n\n// Schema for publishing a new FAQ\nexport const publishFAQSchema = baseFAQSchema\n  .extend({\n    originalQuestion: z.string().optional(),\n    linkId: z.string().cuid(\"Invalid link ID format\").optional(),\n    dataroomDocumentId: z.string().cuid(\"Invalid document ID format\").nullish(),\n    sourceConversationId: z\n      .string()\n      .cuid(\"Invalid conversation ID format\")\n      .optional(),\n    questionMessageId: z\n      .string()\n      .cuid(\"Invalid question message ID format\")\n      .optional(),\n    answerMessageId: z\n      .string()\n      .cuid(\"Invalid answer message ID format\")\n      .optional(),\n    isAnonymized: z.boolean().default(true),\n    documentPageNumber: z.number().int().min(1).optional(),\n    documentVersionNumber: z.number().int().min(1).optional(),\n  })\n  .superRefine((data, ctx) => {\n    if (data.visibilityMode === \"PUBLIC_LINK\" && !data.linkId) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"Link ID is required for link visibility\",\n        path: [\"linkId\"],\n      });\n    }\n    if (data.visibilityMode === \"PUBLIC_DOCUMENT\" && !data.dataroomDocumentId) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"Document ID is required for document visibility\",\n        path: [\"dataroomDocumentId\"],\n      });\n    }\n    if (\n      (data.documentPageNumber != null || data.documentVersionNumber != null) &&\n      !data.dataroomDocumentId\n    ) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"Document page/version requires a document\",\n        path: [\"dataroomDocumentId\"],\n      });\n    }\n    if (\n      (data.questionMessageId || data.answerMessageId) &&\n      !data.sourceConversationId\n    ) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        message: \"Message references require a source conversation\",\n        path: [\"sourceConversationId\"],\n      });\n    }\n  });\n\n// Schema for updating an existing FAQ (all fields optional except where business logic requires)\nexport const updateFAQSchema = z.object({\n  editedQuestion: z\n    .string()\n    .min(10, \"Question must be at least 10 characters\")\n    .max(1000, \"Question too long\")\n    .optional(),\n  answer: z\n    .string()\n    .min(10, \"Answer must be at least 10 characters\")\n    .max(1000, \"Answer too long\")\n    .optional(),\n  visibilityMode: z\n    .enum([\"PUBLIC_DATAROOM\", \"PUBLIC_LINK\", \"PUBLIC_DOCUMENT\"])\n    .optional(),\n  status: z.enum([\"DRAFT\", \"PUBLISHED\", \"ARCHIVED\"]).optional(),\n});\n\n// Frontend form validation schemas\nexport const publishFAQFormSchema = baseFAQSchema.extend({\n  questionMessageId: z.string().cuid(\"Invalid question message ID\"),\n  answerMessageId: z.string().cuid(\"Invalid answer message ID\"),\n});\n\nexport const editFAQFormSchema = baseFAQSchema;\n\n// Parameter validation schemas\nexport const faqParamSchema = z.object({\n  teamId: z.string().cuid(\"Invalid team ID format\"),\n  id: z.string().cuid(\"Invalid dataroom ID format\"),\n  faqId: z.string().cuid(\"Invalid FAQ ID format\").optional(),\n});\n\n// Type exports\nexport type PublishFAQInput = z.infer<typeof publishFAQSchema>;\nexport type UpdateFAQInput = z.infer<typeof updateFAQSchema>;\nexport type PublishFAQFormInput = z.infer<typeof publishFAQFormSchema>;\nexport type EditFAQFormInput = z.infer<typeof editFAQFormSchema>;\nexport type FAQParamInput = z.infer<typeof faqParamSchema>;\n"
  },
  {
    "path": "ee/features/conversations/lib/trigger/conversation-message-notification.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\n\nimport prisma from \"@/lib/prisma\";\nimport { ZViewerNotificationPreferencesSchema } from \"@/lib/zod/schemas/notifications\";\n\ntype NotificationPayload = {\n  dataroomId: string;\n  messageId: string;\n  conversationId: string;\n  teamId: string;\n  senderUserId: string;\n};\n\nexport const sendConversationMessageNotificationTask = task({\n  id: \"send-conversation-message-notification\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: NotificationPayload) => {\n    // Get all verified viewers for this dataroom\n    const participants = await prisma.conversationParticipant.findMany({\n      where: {\n        conversationId: payload.conversationId,\n        receiveNotifications: true,\n        viewer: {\n          verified: true,\n        },\n      },\n      select: {\n        id: true,\n        viewer: {\n          select: {\n            id: true,\n            notificationPreferences: true,\n            views: {\n              where: {\n                conversationViews: {\n                  some: {\n                    conversationId: payload.conversationId,\n                  },\n                },\n              },\n              take: 1,\n              select: {\n                link: {\n                  select: {\n                    id: true,\n                    slug: true,\n                    domainSlug: true,\n                    domainId: true,\n                    isArchived: true,\n                    expiresAt: true,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!participants || participants.length === 0) {\n      logger.info(\"No participants found for this conversation\", {\n        conversationId: payload.conversationId,\n      });\n      return;\n    }\n\n    // Construct simplified viewer objects with link information\n    const viewersWithLinks = participants\n      .map((participant) => {\n        if (!participant.viewer) {\n          return null;\n        }\n\n        const viewer = participant.viewer;\n\n        // Skip if notifications are disabled for this dataroom\n        const parsedPreferences =\n          ZViewerNotificationPreferencesSchema.safeParse(\n            viewer.notificationPreferences,\n          );\n        if (\n          parsedPreferences.success &&\n          parsedPreferences.data.dataroom[payload.dataroomId]?.enabled === false\n        ) {\n          logger.info(\"Viewer notifications are disabled for this dataroom\", {\n            viewerId: viewer.id,\n            conversationId: payload.conversationId,\n            dataroomId: payload.dataroomId,\n          });\n          return null;\n        }\n\n        // Get the link from the conversationView\n        const link = viewer.views[0]?.link;\n\n        // Skip if link is expired or archived\n        if (\n          !link ||\n          link.isArchived ||\n          (link.expiresAt && new Date(link.expiresAt) < new Date())\n        ) {\n          logger.info(\"Link is expired or archived\", {\n            conversationId: payload.conversationId,\n            link,\n          });\n          return null;\n        }\n\n        let linkUrl = \"\";\n        if (link.domainId && link.domainSlug && link.slug) {\n          linkUrl = `https://${link.domainSlug}/${link.slug}`;\n        } else {\n          linkUrl = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;\n        }\n\n        return {\n          id: viewer.id,\n          linkUrl,\n        };\n      })\n      .filter(\n        (participant): participant is { id: string; linkUrl: string } =>\n          participant !== null,\n      );\n\n    logger.info(\"Processed viewer links\", {\n      viewerCount: viewersWithLinks.length,\n    });\n\n    // Send notification to each viewer\n    for (const viewer of viewersWithLinks) {\n      try {\n        const response = await fetch(\n          `${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-conversation-new-message-notification`,\n          {\n            method: \"POST\",\n            body: JSON.stringify({\n              conversationId: payload.conversationId,\n              dataroomId: payload.dataroomId,\n              linkUrl: viewer.linkUrl,\n              viewerId: viewer.id,\n              senderUserId: payload.senderUserId,\n              teamId: payload.teamId,\n            }),\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n            },\n          },\n        );\n\n        if (!response.ok) {\n          logger.error(\"Failed to send dataroom notification\", {\n            viewerId: viewer.id,\n            dataroomId: payload.dataroomId,\n            error: await response.text(),\n          });\n          continue;\n        }\n\n        const { message } = (await response.json()) as { message: string };\n        logger.info(\"Notification sent successfully\", {\n          viewerId: viewer.id,\n          message,\n        });\n      } catch (error) {\n        logger.error(\"Error sending notification\", {\n          viewerId: viewer.id,\n          error,\n        });\n      }\n    }\n\n    logger.info(\"Completed sending notifications\", {\n      dataroomId: payload.dataroomId,\n      conversationId: payload.conversationId,\n      viewerCount: viewersWithLinks.length,\n    });\n    return;\n  },\n});\n\n// New task specifically for notifying team members when viewers write messages\nexport const sendConversationTeamMemberNotificationTask = task({\n  id: \"send-conversation-team-member-notification\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: NotificationPayload) => {\n    logger.info(\"Starting team member notifications\", {\n      conversationId: payload.conversationId,\n      teamId: payload.teamId,\n    });\n\n    // Get team members (ADMIN/MANAGER) for this team\n    const teamMembers = await prisma.userTeam.findMany({\n      where: {\n        teamId: payload.teamId,\n        role: {\n          in: [\"ADMIN\", \"MANAGER\"],\n        },\n        // Only active team members (not blocked)\n        blockedAt: null,\n      },\n      select: {\n        userId: true,\n      },\n    });\n\n    if (!teamMembers || teamMembers.length === 0) {\n      logger.info(\"No team members found for this conversation\", {\n        conversationId: payload.conversationId,\n        teamId: payload.teamId,\n      });\n      return;\n    }\n\n    logger.info(\"Found team members for notification\", {\n      teamMemberCount: teamMembers.length,\n      teamId: payload.teamId,\n      conversationId: payload.conversationId,\n    });\n\n    // Send notification to all team members at once\n    try {\n      const response = await fetch(\n        `${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-conversation-team-member-notification`,\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            conversationId: payload.conversationId,\n            dataroomId: payload.dataroomId,\n            senderUserId: payload.senderUserId,\n            teamId: payload.teamId,\n          }),\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n          },\n        },\n      );\n\n      if (!response.ok) {\n        logger.error(\"Failed to send team member notifications\", {\n          dataroomId: payload.dataroomId,\n          teamId: payload.teamId,\n          error: await response.text(),\n        });\n      } else {\n        const result = (await response.json()) as {\n          message: string;\n          notified: number;\n        };\n        logger.info(\"Team member notifications sent successfully\", {\n          teamId: payload.teamId,\n          notified: result.notified,\n          message: result.message,\n        });\n      }\n    } catch (error) {\n      logger.error(\"Error sending team member notifications\", {\n        teamId: payload.teamId,\n        error,\n      });\n    }\n\n    logger.info(\"Completed team member notifications\", {\n      conversationId: payload.conversationId,\n      teamId: payload.teamId,\n      teamMemberCount: teamMembers.length,\n    });\n    return;\n  },\n});\n"
  },
  {
    "path": "ee/features/conversations/pages/conversation-detail.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport React, { useEffect, useState } from \"react\";\n\nimport { ConversationListItem } from \"@/ee/features/conversations/components/dashboard/conversation-list-item\";\nimport { PublishFAQModal } from \"@/ee/features/conversations/components/dashboard/publish-faq-modal\";\nimport { ConversationDocumentContext } from \"@/ee/features/conversations/components/shared/conversation-document-context\";\nimport { ConversationMessage } from \"@/ee/features/conversations/components/shared/conversation-message\";\nimport {\n  BookOpenCheckIcon,\n  Loader2,\n  MessageSquare,\n  SearchIcon,\n  Send,\n  Trash2,\n  X,\n} from \"lucide-react\";\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\nimport useSWR, { mutate } from \"swr\";\nimport z from \"zod\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { CustomUser } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\nimport { MAX_MESSAGE_LENGTH } from \"@/lib/utils/sanitize-html\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\nimport { PublishedFAQ } from \"./faq-overview\";\n\n// Types for conversation\ninterface Message {\n  id: string;\n  content: string;\n  createdAt: string;\n  updatedAt: string;\n  userId: string | null;\n  viewerId: string | null;\n  isRead: boolean;\n}\n\ninterface Conversation {\n  id: string;\n  title: string | null;\n  createdAt: string;\n  updatedAt: string;\n  participants: { id: string; email: string | null }[];\n  documentPageNumber: number | null;\n  documentVersionNumber: number | null;\n  unreadCount: number;\n  messages: Message[];\n  dataroomDocument?: {\n    document: {\n      name: string;\n    };\n  };\n}\n\ninterface ConversationSummary {\n  id: string;\n  title: string | null;\n  createdAt: string;\n  updatedAt: string;\n  viewerId: string | null;\n  viewerEmail?: string;\n  documentPageNumber: number | null;\n  documentVersionNumber: number | null;\n  unreadCount: number;\n  lastMessage?: {\n    content: string;\n    createdAt: string;\n  };\n  dataroomDocument?: {\n    document: {\n      name: string;\n    };\n  };\n}\n\nexport default function ConversationDetailPage() {\n  const router = useRouter();\n  const { dataroom } = useDataroom();\n  const { data: session } = useSession();\n  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [isPublishFAQModalOpen, setIsPublishFAQModalOpen] = useState(false);\n  const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(\n    null,\n  );\n  const [selectedAnswerId, setSelectedAnswerId] = useState<string | null>(null);\n  const { conversationId, id: dataroomId } = router.query;\n  const teamId = router.query.teamId || dataroom?.teamId;\n\n  const [searchQuery, setSearchQuery] = useState(\"\");\n\n  // Clear selections when switching conversations\n  useEffect(() => {\n    setSelectedQuestionId(null);\n    setSelectedAnswerId(null);\n  }, [conversationId]);\n\n  // Auto-select latest question and answer\n  const autoSelectLatestQA = () => {\n    if (!conversation) return;\n\n    // Find latest visitor message (question)\n    const visitorMessages = conversation.messages.filter(\n      (msg) => msg.viewerId !== null,\n    );\n    const latestQuestion = visitorMessages[visitorMessages.length - 1];\n\n    // Find latest admin message (answer)\n    const adminMessages = conversation.messages.filter(\n      (msg) => msg.userId !== null,\n    );\n    const latestAnswer = adminMessages[adminMessages.length - 1];\n\n    if (latestQuestion) setSelectedQuestionId(latestQuestion.id);\n    if (latestAnswer) setSelectedAnswerId(latestAnswer.id);\n  };\n\n  // Handle message selection for FAQ publishing\n  const handleMessageSelect = (messageId: string, isVisitor: boolean) => {\n    if (isVisitor) {\n      // Selecting/deselecting a question\n      setSelectedQuestionId((prev) => (prev === messageId ? null : messageId));\n    } else {\n      // Selecting/deselecting an answer\n      setSelectedAnswerId((prev) => (prev === messageId ? null : messageId));\n    }\n  };\n\n  const getSelectedMessages = () => {\n    if (!conversation || !selectedQuestionId || !selectedAnswerId) return null;\n\n    const questionMessage = conversation.messages.find(\n      (m) => m.id === selectedQuestionId,\n    );\n    const answerMessage = conversation.messages.find(\n      (m) => m.id === selectedAnswerId,\n    );\n\n    return { questionMessage, answerMessage };\n  };\n\n  // SWR hook for fetching the conversation with messages\n  const { data: conversation, isLoading } = useSWR<Conversation>(\n    conversationId && dataroomId && teamId\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}/conversations/${conversationId}`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: true,\n      dedupingInterval: 3000, // 3 seconds\n      keepPreviousData: true,\n      onSuccess: (data) => {\n        if (\n          data &&\n          data.messages.some((msg) => !msg.isRead && msg.viewerId !== null)\n        ) {\n          markMessagesAsRead(data.id);\n        }\n      },\n      onError: (err) => {\n        console.error(\"Error fetching conversation:\", err);\n        toast.error(\"Failed to load conversation\");\n      },\n    },\n  );\n\n  // SWR hook for fetching all conversations\n  const { data: conversations = [], isLoading: isLoadingConversations } =\n    useSWR<ConversationSummary[]>(\n      dataroomId && teamId\n        ? `/api/teams/${teamId}/datarooms/${dataroomId}/conversations`\n        : null,\n      fetcher,\n      {\n        revalidateOnFocus: true,\n        dedupingInterval: 5000, // 5 seconds\n        keepPreviousData: true,\n        onError: (err) => {\n          console.error(\"Error fetching conversations:\", err);\n          toast.error(\"Failed to load conversations\");\n        },\n      },\n    );\n\n  // Fetch published FAQs\n  const { data: faqs = [] } = useSWR<PublishedFAQ[]>(\n    dataroom && teamId\n      ? `/api/teams/${teamId}/datarooms/${dataroom.id}/faqs`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: true,\n      dedupingInterval: 10000,\n      keepPreviousData: true,\n    },\n  );\n\n  // Filter conversations based on search query\n  const filteredConversations = conversations.filter((conversation) => {\n    if (!searchQuery) return true;\n\n    const query = searchQuery.toLowerCase();\n\n    // Search by viewer email\n    if (conversation.viewerEmail?.toLowerCase().includes(query)) return true;\n\n    // Search in conversation titles and last messages\n    return (\n      conversation.title?.toLowerCase().includes(query) ||\n      conversation.lastMessage?.content.toLowerCase().includes(query) ||\n      conversation.dataroomDocument?.document.name.toLowerCase().includes(query)\n    );\n  });\n\n  const isPublishFAQDisabled =\n    !conversation?.messages.some((msg) => msg.viewerId !== null) ||\n    !conversation?.messages.some((msg) => msg.userId !== null);\n\n  // Mark messages as read\n  const markMessagesAsRead = async (conversationId: string) => {\n    if (!dataroomId || !teamId) return;\n\n    try {\n      const conversationIdParsed = z.string().cuid().parse(conversationId);\n      const dataroomIdParsed = z.string().cuid().parse(dataroomId);\n      const teamIdParsed = z.string().cuid().parse(teamId);\n      const response = await fetch(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}/read`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n        },\n      );\n\n      if (!response.ok) throw new Error(\"Failed to mark messages as read\");\n\n      // Revalidate both the conversation and the summaries\n      mutate(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}`,\n      );\n      mutate(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations`,\n      );\n    } catch (error) {\n      console.error(\"Error marking messages as read:\", error);\n    }\n  };\n\n  // Handle conversation deletion\n  const handleDeleteConversation = async () => {\n    if (!conversation || !dataroomId || !teamId) return;\n\n    setIsDeleting(true);\n    try {\n      const conversationIdParsed = z.string().cuid().parse(conversation.id);\n      const dataroomIdParsed = z.string().cuid().parse(dataroomId);\n      const teamIdParsed = z.string().cuid().parse(teamId);\n      const response = await fetch(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}`,\n        {\n          method: \"DELETE\",\n        },\n      );\n\n      if (!response.ok) throw new Error(\"Failed to delete conversation\");\n\n      // Revalidate the summaries\n      mutate(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations`,\n      );\n\n      // Navigate back to conversations list\n      router.push(`/datarooms/${dataroomIdParsed}/conversations`);\n\n      toast.success(\"Conversation deleted successfully\");\n    } catch (error) {\n      console.error(\"Error deleting conversation:\", error);\n      toast.error(\"Failed to delete conversation\");\n    } finally {\n      setIsDeleting(false);\n      setIsDeleteDialogOpen(false);\n    }\n  };\n\n  // Handle sending a new message\n  const handleSendMessage = async (e: React.FormEvent, newMessage: string) => {\n    e.preventDefault();\n    if (!newMessage.trim() || !conversation || !dataroomId || !teamId) return;\n\n    try {\n      const conversationIdParsed = z.string().cuid().parse(conversation.id);\n      const dataroomIdParsed = z.string().cuid().parse(dataroomId);\n      const teamIdParsed = z.string().cuid().parse(teamId);\n      const response = await fetch(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}/messages`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            content: newMessage,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({}));\n        throw new Error(errorData.error || \"Failed to send message\");\n      }\n\n      // Revalidate both the conversation and the summaries\n      mutate(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}`,\n      );\n      mutate(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations`,\n      );\n\n      toast.success(\"Message sent\");\n    } catch (error) {\n      console.error(\"Error sending message:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to send message\",\n      );\n    }\n  };\n\n  // Navigate to a different conversation\n  const navigateToConversation = (id: string) => {\n    router.push(`/datarooms/${dataroomId}/conversations/${id}`);\n  };\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 my-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader title={dataroom.name} description={dataroom.pId} internalName={dataroom.internalName} />\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <Tabs value=\"conversations\" className=\"space-y-6\">\n          <TabsList>\n            <TabsTrigger\n              value=\"conversations\"\n              className=\"flex items-center gap-2\"\n            >\n              <MessageSquare className=\"h-4 w-4\" />\n              Conversations\n              <Badge variant=\"notification\">{conversations.length}</Badge>\n            </TabsTrigger>\n            <TabsTrigger value=\"faqs\" asChild>\n              <Link\n                href={`/datarooms/${dataroom.id}/conversations/faqs`}\n                className=\"flex items-center gap-2\"\n              >\n                <BookOpenCheckIcon className=\"h-4 w-4\" />\n                Published FAQs\n                <Badge variant=\"notification\">{faqs.length}</Badge>\n              </Link>\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"conversations\" className=\"space-y-0\">\n            <div className=\"h-[calc(100vh-20rem)] overflow-hidden rounded-md border\">\n              <div className=\"flex h-full flex-col md:flex-row\">\n                {/* Sidebar with conversations list */}\n                <div className=\"flex h-full w-full flex-col border-r md:w-96\">\n                  {/* <div className=\"flex items-center justify-between p-4\">\n                    <h2 className=\"text-lg font-semibold\">Conversations</h2>\n                    <Badge variant=\"secondary\">{conversations.length}</Badge>\n                  </div> */}\n\n                  <div className=\"flex items-center p-4\">\n                    <div className=\"relative w-full\">\n                      <SearchIcon className=\"absolute left-2 top-2.5 h-4 w-4 text-muted-foreground\" />\n                      <Input\n                        placeholder=\"Search conversations...\"\n                        className=\"pl-8\"\n                        value={searchQuery}\n                        onChange={(e) => setSearchQuery(e.target.value)}\n                      />\n                    </div>\n                  </div>\n\n                  <div className=\"flex h-[calc(100%-7.5rem)] flex-col\">\n                    <div className=\"m-0 flex-1 overflow-hidden\">\n                      <ScrollArea className=\"h-full\">\n                        <div className=\"flex flex-col gap-2 p-4 pt-0\">\n                          {isLoadingConversations ? (\n                            <div className=\"flex h-20 items-center justify-center\">\n                              <Loader2 className=\"h-6 w-6 animate-spin text-primary\" />\n                            </div>\n                          ) : filteredConversations.length === 0 ? (\n                            <div className=\"flex h-20 items-center justify-center\">\n                              <p className=\"text-sm text-muted-foreground\">\n                                No conversations found\n                              </p>\n                            </div>\n                          ) : (\n                            // Sort by most recent first\n                            [...filteredConversations]\n                              .sort(\n                                (a, b) =>\n                                  new Date(b.updatedAt).getTime() -\n                                  new Date(a.updatedAt).getTime(),\n                              )\n                              .map((conversation) => (\n                                <ConversationListItem\n                                  key={conversation.id}\n                                  navigateToConversation={\n                                    navigateToConversation\n                                  }\n                                  conversation={conversation}\n                                  isActive={conversation.id === conversationId}\n                                />\n                              ))\n                          )}\n                        </div>\n                      </ScrollArea>\n                    </div>\n                  </div>\n                </div>\n\n                {/* Conversation content */}\n                <div className=\"flex h-full flex-1 flex-col\">\n                  {isLoading ? (\n                    <div className=\"flex flex-1 items-center justify-center\">\n                      <Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n                    </div>\n                  ) : conversation ? (\n                    <>\n                      {/* Conversation header */}\n                      <div className=\"flex items-center justify-between border-b p-4\">\n                        <div className=\"flex items-center gap-2\">\n                          <Avatar className=\"h-8 w-8\">\n                            <AvatarImage src=\"\" />\n                            <AvatarFallback>\n                              {conversation.participants[0].email\n                                ? conversation.participants[0].email\n                                    .charAt(0)\n                                    .toUpperCase()\n                                : \"?\"}\n                            </AvatarFallback>\n                          </Avatar>\n                          <div className=\"flex-1\">\n                            <h2 className=\"text-base font-semibold\">\n                              {conversation.title || \"Conversation\"}\n                            </h2>\n                            <p className=\"text-sm text-muted-foreground\">\n                              {conversation.participants[0].email ||\n                                \"Anonymous Viewer\"}\n                            </p>\n                          </div>\n                        </div>\n                        <div className=\"flex items-center gap-2\">\n                          {(selectedQuestionId || selectedAnswerId) && (\n                            <Button\n                              variant=\"ghost\"\n                              size=\"sm\"\n                              title=\"Clear message selections\"\n                              onClick={() => {\n                                setSelectedQuestionId(null);\n                                setSelectedAnswerId(null);\n                              }}\n                            >\n                              <X className=\"mr-1 h-4 w-4\" />\n                              Clear\n                            </Button>\n                          )}\n                          <TooltipProvider>\n                            <Tooltip>\n                              <TooltipTrigger>\n                                <Button\n                                  size=\"sm\"\n                                  onClick={() => {\n                                    if (\n                                      selectedQuestionId &&\n                                      selectedAnswerId\n                                    ) {\n                                      setIsPublishFAQModalOpen(true);\n                                    } else {\n                                      autoSelectLatestQA();\n                                    }\n                                  }}\n                                  disabled={isPublishFAQDisabled}\n                                >\n                                  <BookOpenCheckIcon className=\"mr-2 h-4 w-4\" />\n                                  {selectedQuestionId && selectedAnswerId\n                                    ? \"Publish FAQ\"\n                                    : \"Select messages to publish\"}\n                                </Button>\n                              </TooltipTrigger>\n                              <TooltipContent>\n                                <p>\n                                  {isPublishFAQDisabled\n                                    ? \"Need both visitor questions and admin answers to publish FAQ\"\n                                    : selectedQuestionId && selectedAnswerId\n                                      ? \"Publish selected question and answer as FAQ\"\n                                      : \"Select the latest question and answer for FAQ publishing\"}\n                                </p>\n                              </TooltipContent>\n                            </Tooltip>\n                          </TooltipProvider>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"icon\"\n                            title=\"Delete conversation\"\n                            onClick={() => setIsDeleteDialogOpen(true)}\n                          >\n                            <Trash2 className=\"h-4 w-4\" />\n                          </Button>\n                        </div>\n                      </div>\n\n                      {/* Messages */}\n                      <ScrollArea className=\"flex-1\">\n                        <div className=\"flex flex-col gap-4 p-4\">\n                          {/* Document Context */}\n                          <ConversationDocumentContext\n                            dataroomDocument={conversation.dataroomDocument}\n                            documentPageNumber={conversation.documentPageNumber}\n                            documentVersionNumber={\n                              conversation.documentVersionNumber\n                            }\n                            showVersionNumber={true} // Admin sees full context\n                            className=\"mb-2\"\n                          />\n\n                          {/* Selection Guidance */}\n                          {/* {conversation.messages.length > 1 && (\n                        <div className=\"mb-2 rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm\">\n                          <div className=\"flex items-start gap-2\">\n                            <BookOpenCheck className=\"mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600\" />\n                            <div className=\"text-blue-800\">\n                              {!selectedQuestionId && !selectedAnswerId ? (\n                                <p>\n                                  <strong>💡 Step 1:</strong> Click \"Select\n                                  messages to publish\" to automatically choose\n                                  the latest question and answer, or manually\n                                  select different messages by clicking them\n                                  first.\n                                </p>\n                              ) : selectedQuestionId && !selectedAnswerId ? (\n                                <p>\n                                  <strong>Question selected!</strong> Now select\n                                  an admin answer to complete your FAQ pair.\n                                </p>\n                              ) : !selectedQuestionId && selectedAnswerId ? (\n                                <p>\n                                  <strong>Answer selected!</strong> Now select a\n                                  visitor question to complete your FAQ pair.\n                                </p>\n                              ) : (\n                                <p>\n                                  <strong>Step 2:</strong> Perfect! Both\n                                  question and answer are selected. Click\n                                  \"Publish FAQ\" to review and publish.\n                                </p>\n                              )}\n                            </div>\n                          </div>\n                        </div>\n                      )} */}\n                          {conversation.messages.map((message) => (\n                            <ConversationMessage\n                              key={message.id}\n                              message={message}\n                              isAuthor={\n                                message.userId ===\n                                (session?.user as CustomUser).id\n                              }\n                              senderEmail={\n                                conversation.participants[0].email || \"Viewer\"\n                              }\n                              isSelectable={true}\n                              isSelected={\n                                message.id === selectedQuestionId ||\n                                message.id === selectedAnswerId\n                              }\n                              onSelect={handleMessageSelect}\n                            />\n                          ))}\n                        </div>\n                      </ScrollArea>\n\n                      <ConversationReplyForm\n                        onSendMessage={handleSendMessage}\n                      />\n                    </>\n                  ) : (\n                    <div className=\"flex flex-1 items-center justify-center\">\n                      <p className=\"text-muted-foreground\">\n                        Conversation not found\n                      </p>\n                    </div>\n                  )}\n                </div>\n              </div>\n            </div>\n          </TabsContent>\n        </Tabs>\n      </div>\n\n      {/* Delete Confirmation Dialog */}\n      <Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>Delete Conversation</DialogTitle>\n            <DialogDescription>\n              Are you sure you want to delete this conversation? This action\n              cannot be undone.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setIsDeleteDialogOpen(false)}\n              disabled={isDeleting}\n            >\n              Cancel\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleDeleteConversation}\n              disabled={isDeleting}\n            >\n              {isDeleting ? (\n                <>\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                  Deleting...\n                </>\n              ) : (\n                \"Delete\"\n              )}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {/* Publish FAQ Modal */}\n      {conversation && (\n        <PublishFAQModal\n          conversation={conversation}\n          dataroomId={dataroomId as string}\n          teamId={teamId as string}\n          isOpen={isPublishFAQModalOpen}\n          onClose={() => {\n            setIsPublishFAQModalOpen(false);\n            // Keep selections when modal closes so user can reselect\n          }}\n          onSuccess={() => {\n            // Reset selections after successful publish\n            setSelectedQuestionId(null);\n            setSelectedAnswerId(null);\n            toast.success(\"FAQ published successfully!\");\n            mutate(\n              teamId && dataroom?.id\n                ? `/api/teams/${teamId}/datarooms/${dataroom.id}/faqs`\n                : null,\n            );\n          }}\n          selectedQuestionMessage={getSelectedMessages()?.questionMessage}\n          selectedAnswerMessage={getSelectedMessages()?.answerMessage}\n        />\n      )}\n    </AppLayout>\n  );\n}\n\n// Extracted reply form component\nfunction ConversationReplyForm({\n  onSendMessage,\n}: {\n  onSendMessage: (e: React.FormEvent, message: string) => Promise<void>;\n}) {\n  const [newMessage, setNewMessage] = useState(\"\");\n  const [isSending, setIsSending] = useState(false);\n  const isOverLimit = newMessage.length > MAX_MESSAGE_LENGTH;\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    if (!newMessage.trim() || isOverLimit) return;\n\n    setIsSending(true);\n    try {\n      await onSendMessage(e, newMessage);\n      setNewMessage(\"\");\n    } finally {\n      setIsSending(false);\n    }\n  };\n\n  return (\n    <div className=\"border-t p-4\">\n      <form onSubmit={handleSubmit} className=\"flex flex-col gap-4\">\n        <Textarea\n          placeholder=\"Type your reply...\"\n          className={`min-h-[100px] ${isOverLimit ? \"border-destructive focus-visible:ring-destructive\" : \"\"}`}\n          value={newMessage}\n          onChange={(e) => setNewMessage(e.target.value)}\n        />\n        <div className=\"flex items-center justify-between\">\n          <span\n            className={`text-xs ${isOverLimit ? \"font-medium text-destructive\" : \"text-muted-foreground\"}`}\n          >\n            {newMessage.length > 0 &&\n              `${newMessage.length} / ${MAX_MESSAGE_LENGTH}`}\n          </span>\n          <Button\n            type=\"submit\"\n            disabled={!newMessage.trim() || isOverLimit || isSending}\n          >\n            {isSending ? (\n              <>\n                <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                Sending...\n              </>\n            ) : (\n              <>\n                <Send className=\"mr-2 h-4 w-4\" />\n                Send\n              </>\n            )}\n          </Button>\n        </div>\n      </form>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/pages/conversation-overview.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ConversationListItem } from \"@/ee/features/conversations/components/dashboard/conversation-list-item\";\nimport { ConversationsNotEnabledBanner } from \"@/ee/features/conversations/components/dashboard/conversations-not-enabled-banner\";\nimport {\n  BookOpenCheckIcon,\n  Loader2,\n  MessageSquare,\n  Search,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport z from \"zod\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { fetcher } from \"@/lib/utils\";\nimport { localStorage as safeLocalStorage } from \"@/lib/webstorage\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\n\nimport { PublishedFAQ } from \"./faq-overview\";\n\nexport interface ConversationSummary {\n  id: string;\n  title: string | null;\n  createdAt: string;\n  updatedAt: string;\n  viewerId: string | null;\n  viewerEmail?: string;\n  documentPageNumber: number | null;\n  documentVersionNumber: number | null;\n  unreadCount: number;\n  lastMessage?: {\n    content: string;\n    createdAt: string;\n  };\n  dataroomDocument?: {\n    document: {\n      name: string;\n    };\n  };\n}\n\nexport default function DataroomConversationsPage() {\n  const router = useRouter();\n  const { limits } = useLimits();\n  const { dataroom } = useDataroom();\n  const { currentTeamId: teamId } = useTeam();\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [conversationToDelete, setConversationToDelete] = useState<\n    string | null\n  >(null);\n  const [localConversationsEnabled, setLocalConversationsEnabled] = useState<\n    boolean | undefined\n  >(undefined);\n  const [activeTab, setActiveTab] = useState(\"conversations\");\n\n  // Memoize banner dismissed state to avoid localStorage reads on every render\n  const isBannerDismissed = useMemo(() => {\n    if (!dataroom?.id) return false;\n    return (\n      safeLocalStorage.getItem(\n        `dataroom-${dataroom.id}-conversations-banner-dismissed`,\n      ) === \"true\"\n    );\n  }, [dataroom?.id]);\n\n  // Initialize local state from dataroom\n  useEffect(() => {\n    if (dataroom) {\n      setLocalConversationsEnabled(dataroom.conversationsEnabled);\n    }\n  }, [dataroom]);\n\n  // SWR hook for fetching conversation summaries\n  const { data: conversations = [], isLoading: isLoadingConversations } =\n    useSWR<ConversationSummary[]>(\n      dataroom && teamId\n        ? `/api/teams/${teamId}/datarooms/${dataroom.id}/conversations`\n        : null,\n      fetcher,\n      {\n        revalidateOnFocus: true,\n        dedupingInterval: 10000,\n        keepPreviousData: true,\n        onError: (err) => {\n          console.error(\"Error fetching conversations:\", err);\n          toast.error(\"Failed to load conversations\");\n        },\n      },\n    );\n\n  // Fetch published FAQs\n  const { data: faqs = [] } = useSWR<PublishedFAQ[]>(\n    dataroom && teamId\n      ? `/api/teams/${teamId}/datarooms/${dataroom.id}/faqs`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: true,\n      dedupingInterval: 10000,\n      keepPreviousData: true,\n    },\n  );\n\n  // Filter conversations based on search query\n  const filteredConversations = conversations.filter((conversation) => {\n    if (!searchQuery) return true;\n\n    const query = searchQuery.toLowerCase();\n\n    // Search by viewer email\n    if (conversation.viewerEmail?.toLowerCase().includes(query)) return true;\n\n    // Search in conversation titles and last messages\n    return (\n      conversation.title?.toLowerCase().includes(query) ||\n      conversation.lastMessage?.content.toLowerCase().includes(query) ||\n      conversation.dataroomDocument?.document.name.toLowerCase().includes(query)\n    );\n  });\n\n  // Handle conversation deletion\n  const handleDeleteConversation = async () => {\n    if (!conversationToDelete || !dataroom || !teamId) return;\n\n    setIsDeleting(true);\n    try {\n      const teamIdParsed = z.string().cuid().parse(teamId);\n      const dataroomIdParsed = z.string().cuid().parse(dataroom.id);\n      const conversationToDeleteParsed = z\n        .string()\n        .cuid()\n        .parse(conversationToDelete);\n\n      const response = await fetch(\n        `/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationToDeleteParsed}`,\n        {\n          method: \"DELETE\",\n        },\n      );\n\n      if (!response.ok) throw new Error(\"Failed to delete conversation\");\n\n      toast.success(\"Conversation deleted successfully\");\n    } catch (error) {\n      console.error(\"Error deleting conversation:\", error);\n      toast.error(\"Failed to delete conversation\");\n    } finally {\n      setIsDeleting(false);\n      setIsDeleteDialogOpen(false);\n      setConversationToDelete(null);\n    }\n  };\n\n  // Navigate to conversation detail\n  const navigateToConversation = (conversationId: string) => {\n    router.push(`/datarooms/${dataroom?.id}/conversations/${conversationId}`);\n  };\n\n  // Handle conversations being toggled\n  const handleConversationsToggled = (enabled: boolean) => {\n    setLocalConversationsEnabled(enabled);\n  };\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  if (!limits?.conversationsInDataroom) {\n    // Redirect to documents page if conversations are not enabled\n    router.push(`/datarooms/${dataroom?.id}/documents`);\n  }\n\n  const isConversationsEnabled =\n    localConversationsEnabled !== undefined\n      ? localConversationsEnabled\n      : dataroom.conversationsEnabled;\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 my-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader title={dataroom.name} description={dataroom.pId} internalName={dataroom.internalName} />\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        {/* Show banner unless it's been dismissed */}\n        {!isBannerDismissed && (\n          <ConversationsNotEnabledBanner\n            dataroomId={dataroom.id}\n            teamId={teamId as string}\n            isConversationsEnabled={isConversationsEnabled}\n            onConversationsToggled={handleConversationsToggled}\n          />\n        )}\n\n        <Tabs\n          value={activeTab}\n          onValueChange={setActiveTab}\n          className=\"space-y-6\"\n        >\n          <TabsList>\n            <TabsTrigger\n              value=\"conversations\"\n              className=\"flex items-center gap-2\"\n            >\n              <MessageSquare className=\"h-4 w-4\" />\n              Conversations\n              <Badge variant=\"notification\">{conversations.length}</Badge>\n            </TabsTrigger>\n            <TabsTrigger value=\"faqs\" asChild>\n              <Link\n                href={`/datarooms/${dataroom.id}/conversations/faqs`}\n                className=\"flex items-center gap-2\"\n              >\n                <BookOpenCheckIcon className=\"h-4 w-4\" />\n                Published FAQs\n                <Badge variant=\"notification\">{faqs.length}</Badge>\n              </Link>\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"conversations\" className=\"space-y-0\">\n            <div className=\"h-[calc(100vh-20rem)] overflow-hidden rounded-md border\">\n              <div className=\"flex h-full flex-col md:flex-row\">\n                {/* Sidebar with conversations list */}\n                <div className=\"flex h-full w-full flex-col border-r md:w-96\">\n                  {/* <div className=\"flex items-center justify-between p-4\">\n                    <h2 className=\"text-lg font-semibold\">Conversations</h2>\n                  </div> */}\n\n                  <div className=\"flex items-center p-4\">\n                    <div className=\"relative w-full\">\n                      <Search className=\"absolute left-2 top-2.5 h-4 w-4 text-muted-foreground\" />\n                      <Input\n                        placeholder=\"Search conversations...\"\n                        className=\"pl-8\"\n                        value={searchQuery}\n                        onChange={(e) => setSearchQuery(e.target.value)}\n                      />\n                    </div>\n                  </div>\n\n                  <div className=\"flex h-[calc(100%-7.5rem)] flex-col\">\n                    <div className=\"m-0 flex-1 overflow-hidden\">\n                      <ScrollArea className=\"h-full\">\n                        <div className=\"flex flex-col gap-2 p-4 pt-0\">\n                          {isLoadingConversations ? (\n                            <div className=\"flex h-20 items-center justify-center\">\n                              <Loader2 className=\"h-6 w-6 animate-spin text-primary\" />\n                            </div>\n                          ) : filteredConversations.length === 0 ? (\n                            <div className=\"flex h-20 items-center justify-center\">\n                              <p className=\"text-sm text-muted-foreground\">\n                                No conversations found\n                              </p>\n                            </div>\n                          ) : (\n                            // Sort by most recent first\n                            [...filteredConversations]\n                              .sort(\n                                (a, b) =>\n                                  new Date(b.updatedAt).getTime() -\n                                  new Date(a.updatedAt).getTime(),\n                              )\n                              .map((conversation) => (\n                                <ConversationListItem\n                                  key={conversation.id}\n                                  navigateToConversation={\n                                    navigateToConversation\n                                  }\n                                  conversation={conversation}\n                                  isActive={false}\n                                />\n                              ))\n                          )}\n                        </div>\n                      </ScrollArea>\n                    </div>\n                  </div>\n                </div>\n\n                {/* Empty state for the right panel */}\n                <div className=\"hidden flex-1 items-center justify-center md:flex\">\n                  <div className=\"text-center\">\n                    <MessageSquare className=\"mx-auto h-10 w-10 text-muted-foreground\" />\n                    <p className=\"mt-2 text-sm font-medium\">\n                      Select a conversation\n                    </p>\n                    <p className=\"text-xs text-muted-foreground\">\n                      Choose a conversation to view and reply\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </TabsContent>\n        </Tabs>\n      </div>\n\n      {/* Delete Confirmation Dialog */}\n      <Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>Delete Conversation</DialogTitle>\n            <DialogDescription>\n              Are you sure you want to delete this conversation? This action\n              cannot be undone.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setIsDeleteDialogOpen(false)}\n              disabled={isDeleting}\n            >\n              Cancel\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleDeleteConversation}\n              disabled={isDeleting}\n            >\n              {isDeleting ? (\n                <>\n                  <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                  Deleting...\n                </>\n              ) : (\n                \"Delete\"\n              )}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversations/pages/faq-overview.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { EditFAQModal } from \"@/ee/features/conversations/components/dashboard/edit-faq-modal\";\nimport {\n  BookOpenCheckIcon,\n  ClockIcon,\n  EditIcon,\n  EyeIcon,\n  FileTextIcon,\n  Link2Icon,\n  MessageSquare,\n  MoreVertical,\n  ServerIcon,\n  Trash2Icon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR, { mutate } from \"swr\";\nimport { z } from \"zod\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { fetcher, timeAgo } from \"@/lib/utils\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\n\nimport { ConversationSummary } from \"./conversation-overview\";\n\nconst apiParamSchema = z.object({\n  teamId: z.string().cuid(\"Invalid team ID\"),\n  dataroomId: z.string().cuid(\"Invalid dataroom ID\"),\n  faqId: z.string().cuid(\"Invalid FAQ ID\"),\n});\n\nexport interface PublishedFAQ {\n  id: string;\n  editedQuestion: string;\n  originalQuestion?: string;\n  answer: string;\n  visibilityMode: \"PUBLIC_DATAROOM\" | \"PUBLIC_LINK\" | \"PUBLIC_DOCUMENT\";\n  status: \"DRAFT\" | \"PUBLISHED\" | \"ARCHIVED\";\n  createdAt: string;\n  updatedAt: string;\n  dataroom: {\n    name: string;\n  };\n  link?: {\n    name: string;\n  };\n  dataroomDocument?: {\n    document: {\n      name: string;\n    };\n  };\n  publishedByUser: {\n    name: string;\n    email: string;\n  };\n  sourceConversation?: {\n    id: string;\n  };\n  questionMessage?: {\n    id: string;\n    content: string;\n  };\n  answerMessage?: {\n    id: string;\n    content: string;\n  };\n}\n\nconst visibilityIcons = {\n  PUBLIC_DATAROOM: ServerIcon,\n  PUBLIC_LINK: Link2Icon,\n  PUBLIC_DOCUMENT: FileTextIcon,\n};\n\nconst visibilityLabels = {\n  PUBLIC_DATAROOM: \"Dataroom\",\n  PUBLIC_LINK: \"Link\",\n  PUBLIC_DOCUMENT: \"Document\",\n};\n\nexport default function FAQOverview() {\n  const router = useRouter();\n  const { dataroom } = useDataroom();\n  const { currentTeamId: teamId } = useTeam();\n\n  const [isEditModalOpen, setIsEditModalOpen] = useState(false);\n  const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [faqToDelete, setFaqToDelete] = useState<string | null>(null);\n  const [faqToEdit, setFaqToEdit] = useState<PublishedFAQ | null>(null);\n\n  // Fetch published FAQs\n  const {\n    data: faqs = [],\n    isLoading,\n    error,\n  } = useSWR<PublishedFAQ[]>(\n    dataroom && teamId\n      ? `/api/teams/${teamId}/datarooms/${dataroom.id}/faqs`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: true,\n      dedupingInterval: 10000,\n      keepPreviousData: true,\n    },\n  );\n\n  // SWR hook for fetching conversation summaries\n  const { data: conversations = [], isLoading: isLoadingConversations } =\n    useSWR<ConversationSummary[]>(\n      dataroom && teamId\n        ? `/api/teams/${teamId}/datarooms/${dataroom.id}/conversations`\n        : null,\n      fetcher,\n      {\n        revalidateOnFocus: true,\n        dedupingInterval: 10000,\n        keepPreviousData: true,\n        onError: (err) => {\n          console.error(\"Error fetching conversations:\", err);\n          toast.error(\"Failed to load conversations\");\n        },\n      },\n    );\n\n  const handleEdit = (faq: PublishedFAQ) => {\n    setFaqToEdit(faq);\n    setIsEditModalOpen(true);\n  };\n\n  const handleStatusToggle = async (faq: PublishedFAQ) => {\n    const newStatus = faq.status === \"PUBLISHED\" ? \"DRAFT\" : \"PUBLISHED\";\n\n    const paramValidation = apiParamSchema.safeParse({\n      teamId,\n      dataroomId: dataroom?.id,\n      faqId: faq.id,\n    });\n\n    if (!paramValidation.success) {\n      toast.error(\"Invalid team, dataroom, or FAQ ID\");\n      return;\n    }\n\n    try {\n      const response = await fetch(\n        `/api/teams/${paramValidation.data.teamId}/datarooms/${paramValidation.data.dataroomId}/faqs/${paramValidation.data.faqId}`,\n        {\n          method: \"PUT\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({ status: newStatus }),\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to update FAQ status\");\n      }\n\n      const isPublish = newStatus === \"PUBLISHED\";\n      toast.success(\n        `FAQ ${isPublish ? \"published\" : \"unpublished\"} successfully`,\n      );\n      mutate(\n        `/api/teams/${paramValidation.data.teamId}/datarooms/${paramValidation.data.dataroomId}/faqs`,\n      );\n    } catch (error) {\n      console.error(\"Error updating FAQ status:\", error);\n      toast.error(\"Failed to update FAQ status\");\n    }\n  };\n\n  const handleDelete = async (faqId: string) => {\n    setIsDeleting(true);\n\n    const paramValidation = apiParamSchema.safeParse({\n      teamId,\n      dataroomId: dataroom?.id,\n      faqId,\n    });\n\n    if (!paramValidation.success) {\n      toast.error(\"Invalid team, dataroom, or FAQ ID\");\n      return;\n    }\n\n    try {\n      const response = await fetch(\n        `/api/teams/${paramValidation.data.teamId}/datarooms/${paramValidation.data.dataroomId}/faqs/${paramValidation.data.faqId}`,\n        {\n          method: \"DELETE\",\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to delete FAQ\");\n      }\n\n      toast.success(\"FAQ deleted successfully\");\n      mutate(\n        `/api/teams/${paramValidation.data.teamId}/datarooms/${paramValidation.data.dataroomId}/faqs`,\n      );\n      setIsDeleteModalOpen(false);\n      setFaqToDelete(null);\n    } catch (error) {\n      console.error(\"Error deleting FAQ:\", error);\n      toast.error(\"Failed to delete FAQ\");\n    } finally {\n      setIsDeleting(false);\n    }\n  };\n\n  const VisibilityIcon = ({\n    mode,\n  }: {\n    mode: PublishedFAQ[\"visibilityMode\"];\n  }) => {\n    const Icon = visibilityIcons[mode];\n    return <Icon className=\"h-4 w-4\" />;\n  };\n\n  if (isLoading) {\n    return (\n      <Card>\n        <CardContent className=\"flex h-48 items-center justify-center\">\n          <p>Loading published FAQs...</p>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  if (error) {\n    return (\n      <Card>\n        <CardContent className=\"flex h-48 items-center justify-center\">\n          <p className=\"text-destructive\">Failed to load FAQs</p>\n        </CardContent>\n      </Card>\n    );\n  }\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  if (faqs.length === 0) {\n    return (\n      <AppLayout>\n        <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n          <header>\n            <DataroomHeader title={dataroom.name} description={dataroom.pId} internalName={dataroom.internalName} />\n            <DataroomNavigation dataroomId={dataroom.id} />\n          </header>\n\n          <Tabs defaultValue=\"faqs\" className=\"space-y-6\">\n            <TabsList>\n              <TabsTrigger value=\"conversations\" asChild>\n                <Link\n                  href={`/datarooms/${dataroom.id}/conversations`}\n                  className=\"flex items-center gap-2\"\n                >\n                  <MessageSquare className=\"h-4 w-4\" />\n                  Conversations\n                  <Badge variant=\"secondary\">{conversations.length}</Badge>\n                </Link>\n              </TabsTrigger>\n              <TabsTrigger value=\"faqs\" className=\"flex items-center gap-2\">\n                <BookOpenCheckIcon className=\"h-4 w-4\" />\n                Published FAQs\n                <Badge variant=\"secondary\" className=\"ml-1\">\n                  {faqs.length}\n                </Badge>\n              </TabsTrigger>\n            </TabsList>\n\n            <TabsContent value=\"faqs\">\n              <Card>\n                <CardContent className=\"flex h-48 flex-col items-center justify-center text-center\">\n                  <BookOpenCheckIcon className=\"mb-4 h-12 w-12 text-muted-foreground\" />\n                  <h3 className=\"mb-2 text-lg font-semibold\">\n                    No Published FAQs\n                  </h3>\n                  <p className=\"text-muted-foreground\">\n                    Start by publishing FAQs from conversations to help visitors\n                    find answers quickly.\n                  </p>\n                </CardContent>\n              </Card>\n            </TabsContent>\n          </Tabs>\n        </div>\n      </AppLayout>\n    );\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader title={dataroom.name} description={dataroom.pId} internalName={dataroom.internalName} />\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <Tabs defaultValue=\"faqs\" className=\"space-y-6\">\n          <TabsList>\n            <TabsTrigger value=\"conversations\" asChild>\n              <Link\n                href={`/datarooms/${dataroom.id}/conversations`}\n                className=\"flex items-center gap-2\"\n              >\n                <MessageSquare className=\"h-4 w-4\" />\n                Conversations\n                <Badge variant=\"notification\">{conversations.length}</Badge>\n              </Link>\n            </TabsTrigger>\n            <TabsTrigger value=\"faqs\" className=\"flex items-center gap-2\">\n              <BookOpenCheckIcon className=\"h-4 w-4\" />\n              Published FAQs\n              <Badge variant=\"notification\">{faqs.length}</Badge>\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"faqs\">\n            <Card>\n              <CardHeader>\n                <CardTitle>Published FAQs</CardTitle>\n              </CardHeader>\n              <CardContent>\n                <Table>\n                  <TableHeader>\n                    <TableRow>\n                      <TableHead>Question & Answer</TableHead>\n                      <TableHead>Visibility</TableHead>\n                      <TableHead>Published</TableHead>\n                      <TableHead>Published Date</TableHead>\n                      <TableHead className=\"w-12\">Action</TableHead>\n                    </TableRow>\n                  </TableHeader>\n                  <TableBody>\n                    {faqs.map((faq) => (\n                      <TableRow key={faq.id} className=\"group\">\n                        <TableCell className=\"max-w-md\">\n                          <div className=\"space-y-3\">\n                            {/* Question */}\n                            <div>\n                              <div className=\"flex items-start gap-2\">\n                                <span className=\"rounded bg-primary/80 px-1.5 py-0.5 text-sm font-medium text-primary-foreground\">\n                                  Q\n                                </span>\n                                <div className=\"line-clamp-2 flex flex-col px-1 py-0.5 text-sm font-medium\">\n                                  {faq.editedQuestion}\n                                </div>\n                              </div>\n                            </div>\n\n                            {/* Answer */}\n                            <div className=\"flex items-start gap-2\">\n                              <span className=\"rounded bg-secondary px-1.5 py-0.5 text-sm font-medium text-foreground group-hover:bg-primary-foreground\">\n                                A\n                              </span>\n                              <div className=\"line-clamp-2 flex flex-col gap-1 rounded-sm bg-muted px-2 py-0.5 text-sm text-foreground group-hover:bg-primary-foreground\">\n                                {faq.answer}\n                              </div>\n                            </div>\n                          </div>\n                        </TableCell>\n                        <TableCell>\n                          <div className=\"flex items-center space-x-2\">\n                            <VisibilityIcon mode={faq.visibilityMode} />\n                            <span className=\"text-sm\">\n                              {visibilityLabels[faq.visibilityMode]}\n                            </span>\n                          </div>\n                          {faq.visibilityMode === \"PUBLIC_LINK\" && faq.link && (\n                            <p className=\"mt-1 text-xs text-muted-foreground\">\n                              {faq.link.name}\n                            </p>\n                          )}\n                          {faq.visibilityMode === \"PUBLIC_DOCUMENT\" &&\n                            faq.dataroomDocument && (\n                              <p className=\"mt-1 text-xs text-muted-foreground\">\n                                {faq.dataroomDocument.document.name}\n                              </p>\n                            )}\n                        </TableCell>\n                        <TableCell>\n                          <div className=\"flex items-center space-x-2\">\n                            <Switch\n                              checked={faq.status === \"PUBLISHED\"}\n                              onCheckedChange={() => handleStatusToggle(faq)}\n                              aria-label={`${faq.status === \"PUBLISHED\" ? \"Unpublish\" : \"Publish\"} FAQ`}\n                            />\n                            <span className=\"text-sm text-muted-foreground\">\n                              {faq.status === \"PUBLISHED\" ? \"Yes\" : \"No\"}\n                            </span>\n                          </div>\n                        </TableCell>\n                        <TableCell>\n                          <div className=\"flex items-center space-x-1\">\n                            <ClockIcon className=\"h-4 w-4 text-muted-foreground\" />\n                            <span className=\"text-sm\">\n                              {timeAgo(new Date(faq.createdAt))}\n                            </span>\n                          </div>\n                        </TableCell>\n\n                        <TableCell>\n                          <DropdownMenu>\n                            <DropdownMenuTrigger asChild>\n                              <Button variant=\"outline\" size=\"icon\">\n                                <MoreVertical className=\"h-4 w-4\" />\n                              </Button>\n                            </DropdownMenuTrigger>\n                            <DropdownMenuContent align=\"end\">\n                              <DropdownMenuItem onClick={() => handleEdit(faq)}>\n                                <EditIcon className=\"mr-1 h-4 w-4\" />\n                                Edit FAQ\n                              </DropdownMenuItem>\n                              {faq.sourceConversation && (\n                                <DropdownMenuItem\n                                  onClick={() =>\n                                    router.push(\n                                      `/datarooms/${dataroom?.id}/conversations/${faq.sourceConversation?.id}`,\n                                    )\n                                  }\n                                >\n                                  <MessageSquare className=\"mr-1 h-4 w-4\" />\n                                  View Conversation\n                                </DropdownMenuItem>\n                              )}\n                              <DropdownMenuSeparator />\n                              <DropdownMenuItem\n                                className=\"text-destructive\"\n                                onClick={() => {\n                                  setFaqToDelete(faq.id);\n                                  setIsDeleteModalOpen(true);\n                                }}\n                              >\n                                <Trash2Icon className=\"mr-1 h-4 w-4\" />\n                                Delete\n                              </DropdownMenuItem>\n                            </DropdownMenuContent>\n                          </DropdownMenu>\n                        </TableCell>\n                      </TableRow>\n                    ))}\n                  </TableBody>\n                </Table>\n              </CardContent>\n            </Card>\n\n            {/* Delete Confirmation Modal */}\n            <Dialog\n              open={isDeleteModalOpen}\n              onOpenChange={setIsDeleteModalOpen}\n            >\n              <DialogContent>\n                <DialogHeader>\n                  <DialogTitle>Delete FAQ</DialogTitle>\n                  <DialogDescription>\n                    Are you sure you want to delete this FAQ? This action cannot\n                    be undone.\n                  </DialogDescription>\n                </DialogHeader>\n                <DialogFooter>\n                  <Button\n                    variant=\"outline\"\n                    onClick={() => setIsDeleteModalOpen(false)}\n                    disabled={isDeleting}\n                  >\n                    Cancel\n                  </Button>\n                  <Button\n                    variant=\"destructive\"\n                    onClick={() => faqToDelete && handleDelete(faqToDelete)}\n                    disabled={isDeleting}\n                  >\n                    {isDeleting ? \"Deleting...\" : \"Delete\"}\n                  </Button>\n                </DialogFooter>\n              </DialogContent>\n            </Dialog>\n\n            {/* Edit FAQ Modal */}\n            {faqToEdit && (\n              <EditFAQModal\n                faq={faqToEdit}\n                dataroomId={dataroom?.id!}\n                teamId={teamId!}\n                isOpen={isEditModalOpen}\n                onClose={() => {\n                  setIsEditModalOpen(false);\n                  setFaqToEdit(null);\n                }}\n                onSuccess={() => {\n                  mutate(`/api/teams/${teamId}/datarooms/${dataroom?.id}/faqs`);\n                  setIsEditModalOpen(false);\n                  setFaqToEdit(null);\n                }}\n              />\n            )}\n          </TabsContent>\n        </Tabs>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "ee/features/conversions/python/docx-sanitizer.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nDOCX Sanitizer — fixes common issues that cause LibreOffice conversion failures.\n\nSupported fixes:\n  - RTL compat: Downgrade compatibilityMode 15→14 for Arabic/RTL documents\n    that cause LibreOffice's layout engine to hang indefinitely.\n  - Glossary removal: Remove corrupt glossary parts (0-byte fontTable.xml etc.)\n  - SDT unwrap: Unwrap <w:sdt> blocks from Google Docs exports that crash\n    LibreOffice.\n  - Footer field fix: Strip nested IF/NUMPAGES field codes from headers/footers\n    that cause infinite layout loops in LibreOffice's UNO API.\n\nUsage:\n    python docx-sanitizer.py input.docx [output.docx]\n    python docx-sanitizer.py --mode rtl -v input.docx output.docx\n    python docx-sanitizer.py --mode all -v input.docx output.docx\n\nModes:\n    rtl  — RTL compat fix + glossary removal only\n    sdt  — SDT unwrap only (original behavior)\n    all  — All fixes (default)\n\nIf output is omitted, the input file is overwritten in place.\n\"\"\"\n\nimport sys\nimport os\nimport re\nimport logging\nimport zipfile\nimport tempfile\nimport argparse\nimport shutil\nimport xml.etree.ElementTree as ET\n\nlog = logging.getLogger(\"docx-sanitizer\")\n\nW_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'\n\n\ndef has_rtl_content(doc_content: str) -> bool:\n    \"\"\"Check whether document.xml contains RTL / complex-script markers.\"\"\"\n    return '<w:rtl/>' in doc_content or '<w:bidi/>' in doc_content\n\n\ndef downgrade_compat_mode(content: str) -> tuple:\n    \"\"\"Downgrade compatibilityMode from 15 to 14 in settings.xml content.\"\"\"\n    new_content = re.sub(\n        r'(<w:compatSetting\\b'\n        r'(?=[^>]*\\bw:name=\"compatibilityMode\")'\n        r'(?=[^>]*\\bw:uri=\"http://schemas\\.microsoft\\.com/office/word\")'\n        r'[^>]*\\bw:val=\")15(\")',\n        r'\\g<1>14\\2',\n        content,\n    )\n    changed = new_content != content\n    return new_content, changed\n\n\ndef remove_glossary(tmp_dir: str) -> bool:\n    \"\"\"Remove word/glossary/ directory and clean up its references.\"\"\"\n    glossary_dir = os.path.join(tmp_dir, 'word', 'glossary')\n    if not os.path.isdir(glossary_dir):\n        return False\n\n    shutil.rmtree(glossary_dir)\n    log.info(\"Removed word/glossary/ directory\")\n\n    rels_path = os.path.join(tmp_dir, 'word', '_rels', 'document.xml.rels')\n    if os.path.exists(rels_path):\n        with open(rels_path, 'r', encoding='utf-8') as f:\n            rels_content = f.read()\n        new_rels = re.sub(\n            r'<Relationship[^>]*Target=\"glossary/[^\"]*\"[^>]*/>\\s*',\n            '',\n            rels_content,\n        )\n        if new_rels != rels_content:\n            with open(rels_path, 'w', encoding='utf-8') as f:\n                f.write(new_rels)\n            log.info(\"Removed glossary relationship from document.xml.rels\")\n\n    ct_path = os.path.join(tmp_dir, '[Content_Types].xml')\n    if os.path.exists(ct_path):\n        with open(ct_path, 'r', encoding='utf-8') as f:\n            ct_content = f.read()\n        new_ct = re.sub(\n            r'<Override[^>]*PartName=\"/word/glossary/[^\"]*\"[^>]*/>\\s*',\n            '',\n            ct_content,\n        )\n        if new_ct != ct_content:\n            with open(ct_path, 'w', encoding='utf-8') as f:\n                f.write(new_ct)\n            log.info(\"Removed glossary overrides from [Content_Types].xml\")\n\n    return True\n\n\nNUMPAGES_FIELD_RE = re.compile(\n    r'<w:instrText[^>]*>[^<]*\\b(?:numpages|sectionpages)\\b[^<]*</w:instrText>',\n    re.IGNORECASE,\n)\n\n_NUMPAGES_TEXT_RE = re.compile(r'\\b(?:numpages|sectionpages)\\b', re.IGNORECASE)\n\n\ndef _register_all_namespaces(path: str):\n    \"\"\"Register every namespace prefix declared in *path* so ET.write() preserves them.\"\"\"\n    for _event, ns in ET.iterparse(path, events=['start-ns']):\n        prefix, uri = ns\n        try:\n            ET.register_namespace(prefix, uri)\n        except ValueError:\n            pass\n\n\ndef strip_numpages_fields_in_hf(tmp_dir: str) -> int:\n    \"\"\"Remove paragraphs in headers/footers that contain NUMPAGES-based fields.\n\n    Nested IF fields that reference NUMPAGES inside headers/footers cause an\n    infinite layout recalculation loop in LibreOffice's UNO API\n    (loadComponentFromURL hangs).  The CLI converter (--headless --convert-to)\n    caps its layout passes so it completes, but the UNO pathway does not.\n\n    The fix: for each header/footer XML, find <w:p> elements whose field\n    instructions mention NUMPAGES or SECTIONPAGES, and replace them with an\n    empty paragraph (preserving the original <w:pPr>) so the layout loop is\n    broken.  Uses proper XML tree parsing instead of regex to safely handle\n    arbitrarily nested paragraph-property structures.\n    \"\"\"\n    import glob as _glob\n\n    count = 0\n    word_dir = os.path.join(tmp_dir, 'word')\n    for pattern in ('header*.xml', 'footer*.xml'):\n        for path in _glob.glob(os.path.join(word_dir, pattern)):\n            with open(path, 'r', encoding='utf-8') as f:\n                raw = f.read()\n\n            if not NUMPAGES_FIELD_RE.search(raw):\n                continue\n\n            _register_all_namespaces(path)\n            tree = ET.parse(path)\n            root = tree.getroot()\n            modified = False\n\n            for p_elem in root.iter(f'{{{W_NS}}}p'):\n                has_numpages = False\n                for instr in p_elem.iter(f'{{{W_NS}}}instrText'):\n                    if instr.text and _NUMPAGES_TEXT_RE.search(instr.text):\n                        has_numpages = True\n                        break\n                if not has_numpages:\n                    continue\n\n                count += 1\n                modified = True\n\n                ppr = p_elem.find(f'{{{W_NS}}}pPr')\n                for child in list(p_elem):\n                    p_elem.remove(child)\n\n                if ppr is not None:\n                    p_elem.insert(0, ppr)\n                else:\n                    ET.SubElement(p_elem, f'{{{W_NS}}}pPr')\n\n            if modified:\n                tree.write(path, xml_declaration=True, encoding='UTF-8')\n                log.info(\"Stripped NUMPAGES field paragraph(s) from %s\",\n                         os.path.basename(path))\n\n    return count\n\n\ndef unwrap_sdt(content: str) -> tuple:\n    \"\"\"Replace <w:sdt>...<w:sdtContent>X</w:sdtContent></w:sdt> with X.\"\"\"\n    count = 0\n    while '<w:sdt>' in content:\n        old = content\n        content = re.sub(\n            r'<w:sdt><w:sdtPr>.*?</w:sdtPr><w:sdtContent>(.*?)</w:sdtContent></w:sdt>',\n            r'\\1',\n            content,\n            count=1,\n            flags=re.DOTALL,\n        )\n        if content == old:\n            break\n        count += 1\n    return content, count\n\n\ndef repackage_docx(tmp_dir: str, output_path: str):\n    \"\"\"Repackage extracted DOCX with [Content_Types].xml first.\"\"\"\n    entries = []\n    ct_entry = None\n    for root, _dirs, files in os.walk(tmp_dir):\n        for file in files:\n            fp = os.path.join(root, file)\n            arc = os.path.relpath(fp, tmp_dir)\n            if arc == '[Content_Types].xml':\n                ct_entry = (fp, arc)\n            else:\n                entries.append((fp, arc))\n    if ct_entry:\n        entries.insert(0, ct_entry)\n\n    with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as z:\n        for fp, arc in entries:\n            z.write(fp, arc)\n\n\ndef sanitize_docx(input_path: str, output_path: str, mode: str = 'all') -> bool:\n    try:\n        input_size = os.path.getsize(input_path)\n        log.info(\"Input: %s (%d bytes), mode: %s\", input_path, input_size, mode)\n\n        with tempfile.TemporaryDirectory() as tmp:\n            with zipfile.ZipFile(input_path, 'r') as z:\n                tmp_abs = os.path.abspath(tmp)\n                for info in z.infolist():\n                    member_name = info.filename\n                    target_path = os.path.abspath(os.path.join(tmp, member_name))\n                    if not target_path.startswith(tmp_abs + os.sep):\n                        raise ValueError(f\"Unsafe ZIP entry path: {member_name}\")\n                    if info.is_dir():\n                        continue\n\n                    parent_dir = os.path.dirname(target_path)\n                    if parent_dir:\n                        os.makedirs(parent_dir, exist_ok=True)\n\n                    with z.open(info) as src, open(target_path, 'wb') as dst:\n                        shutil.copyfileobj(src, dst)\n\n            doc_path = os.path.join(tmp, 'word', 'document.xml')\n            if not os.path.exists(doc_path):\n                log.error(\"No word/document.xml found\")\n                return False\n\n            with open(doc_path, 'r', encoding='utf-8') as f:\n                doc_content = f.read()\n\n            doc_modified = False\n\n            # --- RTL compat fixes (glossary removal + compat mode downgrade) ---\n            if mode in ('rtl', 'all'):\n                remove_glossary(tmp)\n\n                if has_rtl_content(doc_content):\n                    log.info(\"RTL content detected — checking compatibilityMode\")\n                    settings_path = os.path.join(tmp, 'word', 'settings.xml')\n                    if os.path.exists(settings_path):\n                        with open(settings_path, 'r', encoding='utf-8') as f:\n                            settings_content = f.read()\n                        new_settings, changed = downgrade_compat_mode(settings_content)\n                        if changed:\n                            with open(settings_path, 'w', encoding='utf-8') as f:\n                                f.write(new_settings)\n                            log.info(\"Downgraded compatibilityMode 15 → 14\")\n                        else:\n                            log.info(\"compatibilityMode is not 15 — no change needed\")\n                    else:\n                        log.info(\"No word/settings.xml found — skipping compat fix\")\n                else:\n                    log.info(\"No RTL content detected — skipping compat downgrade\")\n\n            # --- Header/footer NUMPAGES field fix ---\n            if mode == 'all':\n                nf_count = strip_numpages_fields_in_hf(tmp)\n                if nf_count:\n                    log.info(\"Stripped %d NUMPAGES field paragraph(s) from headers/footers\",\n                             nf_count)\n                else:\n                    log.info(\"No NUMPAGES fields found in headers/footers\")\n\n            # --- SDT unwrap ---\n            if mode in ('sdt', 'all'):\n                new_content, sdt_count = unwrap_sdt(doc_content)\n                if sdt_count:\n                    log.info(\"Unwrapped %d <w:sdt> block(s) (removed %d bytes)\",\n                             sdt_count, len(doc_content) - len(new_content))\n                    doc_content = new_content\n                    doc_modified = True\n                else:\n                    log.info(\"No <w:sdt> blocks found\")\n\n            if doc_modified:\n                with open(doc_path, 'w', encoding='utf-8') as f:\n                    f.write(doc_content)\n\n            repackage_docx(tmp, output_path)\n\n            output_size = os.path.getsize(output_path)\n            log.info(\"Output: %s (%d bytes, %+d)\", output_path, output_size, output_size - input_size)\n            return True\n\n    except Exception as e:\n        log.exception(\"Error: %s\", e)\n        return False\n\n\ndef check_rtl(input_path: str) -> bool:\n    \"\"\"Open DOCX, read document.xml, return whether RTL content is present.\"\"\"\n    try:\n        with zipfile.ZipFile(input_path, 'r') as z:\n            with z.open('word/document.xml') as f:\n                content = f.read().decode('utf-8')\n        return has_rtl_content(content)\n    except (KeyError, Exception) as e:\n        log.warning(\"Could not check RTL: %s\", e)\n        return False\n\n\ndef main():\n    parser = argparse.ArgumentParser(\n        description=\"DOCX sanitizer for LibreOffice conversion issues\")\n    parser.add_argument(\"input\", help=\"Input .docx file\")\n    parser.add_argument(\"output\", nargs=\"?\", default=None,\n                        help=\"Output .docx (default: overwrite input)\")\n    parser.add_argument(\"-v\", \"--verbose\", action=\"count\", default=0)\n    parser.add_argument(\"--mode\", choices=[\"rtl\", \"sdt\", \"all\"], default=\"all\",\n                        help=\"Sanitization mode: rtl (compat fix only), \"\n                             \"sdt (unwrap only), all (default)\")\n    parser.add_argument(\"--check-rtl\", action=\"store_true\",\n                        help=\"Check if document has RTL content and exit. \"\n                             \"Prints 'true' or 'false' to stdout.\")\n    args = parser.parse_args()\n\n    level = logging.WARNING\n    if args.verbose >= 2:\n        level = logging.DEBUG\n    elif args.verbose >= 1:\n        level = logging.INFO\n    logging.basicConfig(level=level, format=\"%(levelname)-5s %(message)s\",\n                        stream=sys.stderr)\n\n    if not os.path.exists(args.input):\n        log.error(\"File not found: %s\", args.input)\n        sys.exit(1)\n\n    if args.check_rtl:\n        print(\"true\" if check_rtl(args.input) else \"false\")\n        sys.exit(0)\n\n    output = args.output or args.input\n    if output == args.input:\n        temp_fd = None\n        temp_path = None\n        try:\n            input_dir = os.path.dirname(os.path.abspath(args.input)) or \".\"\n            temp_fd, temp_path = tempfile.mkstemp(\n                suffix=\".docx\",\n                prefix=\".docx-sanitizer-\",\n                dir=input_dir,\n            )\n            os.close(temp_fd)\n            temp_fd = None\n\n            if sanitize_docx(args.input, temp_path, mode=args.mode):\n                os.replace(temp_path, args.input)\n                temp_path = None\n                print(f\"Sanitized DOCX written to: {args.input}\")\n            else:\n                if temp_path and os.path.exists(temp_path):\n                    os.remove(temp_path)\n                sys.exit(1)\n        except Exception as e:\n            if temp_fd is not None:\n                os.close(temp_fd)\n            if temp_path and os.path.exists(temp_path):\n                os.remove(temp_path)\n            log.exception(\"Error while sanitizing in place: %s\", e)\n            sys.exit(1)\n    else:\n        if sanitize_docx(args.input, output, mode=args.mode):\n            print(f\"Sanitized DOCX written to: {output}\")\n        else:\n            sys.exit(1)\n\n\nif __name__ == '__main__':\n    main()\n"
  },
  {
    "path": "ee/features/dataroom-invitations/api/group-invite.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { SendGroupInvitationSchema } from \"@/ee/features/dataroom-invitations/lib/schema/dataroom-invitations\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { LinkAudienceType, LinkType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { constructLinkUrl } from \"@/lib/utils/link-url\";\n\nimport { sendDataroomViewerInvite } from \"../emails/lib/send-dataroom-viewer-invite\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const user = session.user as CustomUser;\n  const {\n    teamId,\n    id: dataroomId,\n    groupId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    groupId: string;\n  };\n\n  const parseResult = SendGroupInvitationSchema.safeParse(req.body);\n  if (!parseResult.success) {\n    return res.status(400).json({\n      error: \"Invalid request body\",\n      details: parseResult.error.flatten(),\n    });\n  }\n\n  const { linkId, customMessage, emails } = parseResult.data;\n\n  try {\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: user.id,\n          teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // Check if dataroomInvitations feature is enabled for this team\n    const featureFlags = await getFeatureFlags({ teamId });\n    if (!featureFlags.dataroomInvitations) {\n      return res.status(403).json({\n        error: \"Dataroom invitations feature is not enabled for this team\",\n      });\n    }\n\n    const group = await prisma.viewerGroup.findUnique({\n      where: {\n        id: groupId,\n        dataroomId,\n        teamId,\n      },\n      select: {\n        id: true,\n        dataroom: {\n          select: {\n            name: true,\n          },\n        },\n        members: {\n          select: {\n            viewer: {\n              select: {\n                id: true,\n                email: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!group) {\n      return res.status(404).json({ error: \"Group not found\" });\n    }\n\n    const link = await prisma.link.findUnique({\n      where: {\n        id: linkId,\n        dataroomId,\n        teamId,\n        linkType: LinkType.DATAROOM_LINK,\n        audienceType: LinkAudienceType.GROUP,\n        groupId,\n        isArchived: false,\n      },\n      select: {\n        id: true,\n        domainId: true,\n        domainSlug: true,\n        slug: true,\n        allowList: true,\n      },\n    });\n\n    if (!link) {\n      return res\n        .status(404)\n        .json({ error: \"Link not found or not associated with this group\" });\n    }\n\n    const teamMember = await prisma.user.findUnique({\n      where: { id: user.id },\n      select: { email: true },\n    });\n\n    if (!teamMember?.email) {\n      return res.status(400).json({ error: \"Sender email not available\" });\n    }\n\n    const availableEmails = group.members\n      .map((member) => member.viewer.email)\n      .filter(Boolean);\n\n    const targetEmails = Array.from(\n      new Set(\n        (emails ?? availableEmails).filter((email) =>\n          availableEmails.includes(email),\n        ),\n      ),\n    );\n\n    if (targetEmails.length === 0) {\n      return res\n        .status(400)\n        .json({ error: \"No valid group member emails provided\" });\n    }\n\n    const viewers = await prisma.viewer.findMany({\n      where: {\n        teamId,\n        email: {\n          in: targetEmails,\n        },\n      },\n      select: {\n        id: true,\n        email: true,\n      },\n    });\n\n    const viewerByEmail = viewers.reduce<Record<string, { id: string }>>(\n      (acc, viewer) => {\n        if (viewer.email) {\n          acc[viewer.email] = { id: viewer.id };\n        }\n        return acc;\n      },\n      {},\n    );\n\n    const linkUrl = constructLinkUrl(link);\n\n    const successes: string[] = [];\n    const failures: { email: string; error: string }[] = [];\n\n    for (const email of targetEmails) {\n      const viewer = viewerByEmail[email];\n      if (!viewer) {\n        failures.push({\n          email,\n          error: \"Viewer not found\",\n        });\n        continue;\n      }\n\n      try {\n        await sendDataroomViewerInvite({\n          dataroomName: group.dataroom.name,\n          senderEmail: teamMember.email,\n          to: email,\n          url: linkUrl,\n          customMessage,\n        });\n\n        await prisma.viewerInvitation.create({\n          data: {\n            viewerId: viewer.id,\n            linkId: link.id,\n            groupId,\n            invitedBy: user.id,\n            customMessage,\n            status: \"SENT\",\n          },\n        });\n\n        successes.push(email);\n      } catch (error: any) {\n        failures.push({\n          email,\n          error: error?.message ?? \"Unknown error\",\n        });\n\n        await prisma.viewerInvitation.create({\n          data: {\n            viewerId: viewer.id,\n            linkId: link.id,\n            groupId,\n            invitedBy: user.id,\n            customMessage,\n            status: \"FAILED\",\n          },\n        });\n      }\n    }\n\n    return res.status(200).json({\n      success: successes,\n      failed: failures,\n    });\n  } catch (error) {\n    console.error(\"Error sending viewer invitations\", error);\n    return res.status(500).json({\n      error: \"Failed to send invitations\",\n    });\n  }\n}\n"
  },
  {
    "path": "ee/features/dataroom-invitations/api/link-invite.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport {\n  SendLinkInvitationSchema,\n  invitationEmailSchema,\n} from \"@/ee/features/dataroom-invitations/lib/schema/dataroom-invitations\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { LinkType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { constructLinkUrl } from \"@/lib/utils/link-url\";\n\nimport { sendDataroomViewerInvite } from \"../emails/lib/send-dataroom-viewer-invite\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const user = session.user as CustomUser;\n  const {\n    teamId,\n    id: dataroomId,\n    linkId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    linkId: string;\n  };\n\n  const parseResult = SendLinkInvitationSchema.safeParse(req.body);\n  if (!parseResult.success) {\n    return res.status(400).json({\n      error: \"Invalid request body\",\n      details: parseResult.error.flatten(),\n    });\n  }\n\n  const { customMessage, emails } = parseResult.data;\n\n  try {\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: user.id,\n          teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // Check if dataroomInvitations feature is enabled for this team\n    const featureFlags = await getFeatureFlags({ teamId });\n    if (!featureFlags.dataroomInvitations) {\n      return res.status(403).json({\n        error: \"Dataroom invitations feature is not enabled for this team\",\n      });\n    }\n\n    const link = await prisma.link.findFirst({\n      where: {\n        id: linkId,\n        dataroomId,\n        teamId,\n        linkType: LinkType.DATAROOM_LINK,\n        isArchived: false,\n      },\n      select: {\n        id: true,\n        domainId: true,\n        domainSlug: true,\n        slug: true,\n        allowList: true,\n        dataroom: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n\n    if (!link) {\n      return res.status(404).json({ error: \"Link not found\" });\n    }\n\n    const teamMember = await prisma.user.findUnique({\n      where: { id: user.id },\n      select: { email: true },\n    });\n\n    if (!teamMember?.email) {\n      return res.status(400).json({ error: \"Sender email not available\" });\n    }\n\n    const defaultEmails = (link.allowList ?? []).filter(\n      (value) => invitationEmailSchema.safeParse(value).success,\n    );\n\n    const targetEmails = Array.from(\n      new Set(\n        (emails ?? defaultEmails).filter(\n          (email) => invitationEmailSchema.safeParse(email).success,\n        ),\n      ),\n    );\n\n    if (targetEmails.length === 0) {\n      return res.status(400).json({\n        error: \"No valid recipient emails provided\",\n      });\n    }\n\n    await prisma.viewer.createMany({\n      data: targetEmails.map((email) => ({\n        email,\n        teamId,\n      })),\n      skipDuplicates: true,\n    });\n\n    const viewers = await prisma.viewer.findMany({\n      where: {\n        teamId,\n        email: {\n          in: targetEmails,\n        },\n      },\n      select: {\n        id: true,\n        email: true,\n      },\n    });\n\n    const viewerByEmail = viewers.reduce<Record<string, { id: string }>>(\n      (acc, viewer) => {\n        if (viewer.email) {\n          acc[viewer.email] = { id: viewer.id };\n        }\n        return acc;\n      },\n      {},\n    );\n\n    const linkUrl = constructLinkUrl(link);\n\n    const successes: string[] = [];\n    const failures: { email: string; error: string }[] = [];\n\n    for (const email of targetEmails) {\n      const viewer = viewerByEmail[email];\n      if (!viewer) {\n        failures.push({\n          email,\n          error: \"Viewer not found\",\n        });\n        continue;\n      }\n\n      try {\n        await sendDataroomViewerInvite({\n          dataroomName: link.dataroom?.name ?? \"\",\n          senderEmail: teamMember.email,\n          to: email,\n          url: linkUrl,\n          customMessage,\n        });\n\n        await prisma.viewerInvitation.create({\n          data: {\n            viewerId: viewer.id,\n            linkId: link.id,\n            invitedBy: user.id,\n            customMessage,\n            status: \"SENT\",\n          },\n        });\n\n        successes.push(email);\n      } catch (error: any) {\n        failures.push({\n          email,\n          error: error?.message ?? \"Unknown error\",\n        });\n\n        await prisma.viewerInvitation.create({\n          data: {\n            viewerId: viewer.id,\n            linkId: link.id,\n            invitedBy: user.id,\n            customMessage,\n            status: \"FAILED\",\n          },\n        });\n      }\n    }\n\n    return res.status(200).json({\n      success: successes,\n      failed: failures,\n    });\n  } catch (error) {\n    console.error(\"Error sending link invitations\", error);\n    return res.status(500).json({\n      error: \"Failed to send invitations\",\n    });\n  }\n}\n"
  },
  {
    "path": "ee/features/dataroom-invitations/api/uninvited.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const user = session.user as CustomUser;\n  const {\n    teamId,\n    id: dataroomId,\n    groupId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    groupId: string;\n  };\n\n  try {\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: user.id,\n          teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const group = await prisma.viewerGroup.findFirst({\n      where: {\n        id: groupId,\n        dataroomId,\n        teamId,\n      },\n      select: {\n        members: {\n          select: {\n            viewer: {\n              select: {\n                id: true,\n                email: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!group) {\n      return res.status(404).json({ error: \"Group not found\" });\n    }\n\n    const viewerIds = group.members.map((member) => member.viewer.id);\n\n    if (viewerIds.length === 0) {\n      return res.status(200).json({\n        count: 0,\n        emails: [],\n      });\n    }\n\n    const existingInvitations = await prisma.viewerInvitation.findMany({\n      where: {\n        groupId,\n        viewerId: {\n          in: viewerIds,\n        },\n      },\n      select: {\n        viewerId: true,\n      },\n    });\n\n    const invitedViewerIds = new Set(\n      existingInvitations.map((record) => record.viewerId),\n    );\n\n    const uninvitedEmails = group.members\n      .filter((member) => !invitedViewerIds.has(member.viewer.id))\n      .map((member) => member.viewer.email)\n      .filter((email): email is string => Boolean(email));\n\n    return res.status(200).json({\n      count: uninvitedEmails.length,\n      emails: uninvitedEmails,\n    });\n  } catch (error) {\n    console.error(\"Error fetching uninvited members\", error);\n    return res.status(500).json({\n      error: \"Failed to load uninvited members\",\n    });\n  }\n}\n"
  },
  {
    "path": "ee/features/dataroom-invitations/components/invite-viewers-modal.tsx",
    "content": "import { useEffect, useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { invitationEmailSchema } from \"@/ee/features/dataroom-invitations/lib/schema/dataroom-invitations\";\nimport { useUninvitedMembers } from \"@/ee/features/dataroom-invitations/lib/swr/use-dataroom-invitations\";\nimport { Link } from \"@prisma/client\";\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\ntype InviteViewersModalProps = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  dataroomId: string;\n  dataroomName: string;\n  groupId?: string;\n  linkId?: string;\n  defaultEmails?: string[];\n  onSuccess?: () => void;\n};\n\ntype LinkOption = Pick<\n  Link,\n  \"id\" | \"name\" | \"domainId\" | \"domainSlug\" | \"slug\"\n>;\n\nfunction parseRecipientInput(value: string) {\n  return Array.from(\n    new Set(\n      value\n        .split(/[\\s,]+/)\n        .map((email) => email.trim())\n        .filter((email) => email.length > 0),\n    ),\n  );\n}\n\nexport function InviteViewersModal({\n  open,\n  setOpen,\n  dataroomId,\n  dataroomName,\n  groupId,\n  linkId,\n  defaultEmails = [],\n  onSuccess,\n}: InviteViewersModalProps) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { data: session } = useSession();\n  const senderEmail = session?.user?.email ?? \"you\";\n\n  const { data: groupLinks } = useSWR<LinkOption[]>(\n    groupId && teamId\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/links`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n  const { uninvitedEmails, mutate: mutateUninvited } = useUninvitedMembers(\n    groupId ? dataroomId : undefined,\n    groupId,\n  );\n\n  const availableLinks: LinkOption[] = useMemo(() => {\n    if (groupId) {\n      return (\n        groupLinks?.map((link) => {\n          const last5 = link.id.slice(-5);\n          return {\n            id: link.id,\n            name: link.name || `Link #${last5}`,\n            domainId: link.domainId,\n            domainSlug: link.domainSlug,\n            slug: link.slug,\n          };\n        }) ?? []\n      );\n    }\n\n    return linkId\n      ? [\n          {\n            id: linkId,\n            name: `Link #${linkId.slice(-5)}`,\n            domainId: null,\n            domainSlug: null,\n            slug: null,\n          },\n        ]\n      : [];\n  }, [groupId, groupLinks, linkId]);\n\n  const [selectedLinkId, setSelectedLinkId] = useState<string | undefined>(\n    () => linkId ?? availableLinks[0]?.id,\n  );\n  const [customMessage, setCustomMessage] = useState<string>(\"\");\n  const [recipientInput, setRecipientInput] = useState<string>(\"\");\n  const [hasEditedRecipients, setHasEditedRecipients] =\n    useState<boolean>(false);\n  const [loading, setLoading] = useState<boolean>(false);\n\n  useEffect(() => {\n    if (!open) {\n      setCustomMessage(\"\");\n      setRecipientInput(\"\");\n      setHasEditedRecipients(false);\n      setSelectedLinkId(linkId ?? availableLinks[0]?.id);\n      return;\n    }\n\n    if (!hasEditedRecipients) {\n      const initialRecipients =\n        defaultEmails.length > 0\n          ? defaultEmails\n          : groupId\n            ? uninvitedEmails\n            : [];\n\n      if (initialRecipients.length > 0) {\n        setRecipientInput(initialRecipients.join(\"\\n\"));\n      }\n    }\n  }, [\n    open,\n    linkId,\n    availableLinks,\n    groupId,\n    uninvitedEmails,\n    defaultEmails,\n    hasEditedRecipients,\n  ]);\n\n  useEffect(() => {\n    if (!open) {\n      setSelectedLinkId(linkId ?? availableLinks[0]?.id);\n    }\n  }, [open, linkId, availableLinks]);\n\n  const selectedLink = availableLinks.find(\n    (link) => link.id === selectedLinkId,\n  );\n\n  const defaultRecipients =\n    defaultEmails.length > 0 ? defaultEmails : groupId ? uninvitedEmails : [];\n\n  const currentRecipients =\n    hasEditedRecipients && recipientInput.length > 0\n      ? parseRecipientInput(recipientInput)\n      : defaultRecipients;\n\n  const recipientCount = currentRecipients.length;\n\n  const displayRecipients = currentRecipients.slice(0, 2);\n  const remainingCount = recipientCount - displayRecipients.length;\n\n  const fallbackSubject = `You are invited to view ${dataroomName}`;\n\n  const handleClose = () => {\n    setOpen(false);\n    setLoading(false);\n  };\n\n  const handleSend = async () => {\n    if (!teamId) {\n      toast.error(\"No active team selected\");\n      return;\n    }\n\n    if (groupId && !selectedLinkId) {\n      toast.error(\"Select a link to include in the invitation\");\n      return;\n    }\n\n    const parsedEmails = hasEditedRecipients\n      ? parseRecipientInput(recipientInput)\n      : defaultRecipients;\n\n    if (parsedEmails.length > 0) {\n      const invalidEmails = parsedEmails.filter(\n        (email) => !invitationEmailSchema.safeParse(email).success,\n      );\n\n      if (invalidEmails.length > 0) {\n        toast.error(\n          `Found invalid emails: ${invalidEmails\n            .slice(0, 3)\n            .join(\", \")}${invalidEmails.length > 3 ? \"...\" : \"\"}`,\n        );\n        return;\n      }\n    }\n\n    const endpoint = groupId\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/invite`\n      : linkId\n        ? `/api/teams/${teamId}/datarooms/${dataroomId}/links/${linkId}/invite`\n        : null;\n\n    if (!endpoint) {\n      toast.error(\"Unable to determine invitation endpoint\");\n      return;\n    }\n\n    setLoading(true);\n\n    try {\n      const response = await fetch(endpoint, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          ...(groupId ? { linkId: selectedLinkId } : {}),\n          customMessage: customMessage.length > 0 ? customMessage : undefined,\n          emails: parsedEmails,\n        }),\n      });\n\n      if (!response.ok) {\n        const payload = await response.json().catch(() => null);\n        throw new Error(payload?.error ?? \"Failed to send invitations\");\n      }\n\n      const payload = await response.json();\n      const sentCount = payload?.success?.length ?? 0;\n      const failedCount = payload?.failed?.length ?? 0;\n\n      if (sentCount > 0) {\n        toast.success(\n          `Invitation${sentCount > 1 ? \"s\" : \"\"} sent to ${sentCount} recipient${\n            sentCount > 1 ? \"s\" : \"\"\n          }.`,\n        );\n      }\n\n      if (failedCount > 0) {\n        toast.error(\n          `Failed to send to ${failedCount} recipient${\n            failedCount > 1 ? \"s\" : \"\"\n          }.`,\n        );\n      }\n\n      onSuccess?.();\n      if (groupId) {\n        mutateUninvited();\n      }\n      handleClose();\n    } catch (error: any) {\n      toast.error(error?.message ?? \"Failed to send invitations\");\n      setLoading(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogContent className=\"flex max-h-[90vh] flex-col overflow-hidden sm:max-w-5xl\">\n        <DialogHeader className=\"text-left\">\n          <DialogTitle>Share invitation</DialogTitle>\n        </DialogHeader>\n\n        <div className=\"grid gap-6 overflow-y-auto md:grid-cols-2\">\n          {/* Left Column - Input Fields */}\n          <div className=\"space-y-4 md:overflow-y-auto md:pr-2\">\n            {groupId ? (\n              <div className=\"space-y-2\">\n                <span className=\"text-sm font-medium text-muted-foreground\">\n                  Choose link\n                </span>\n                <Select\n                  value={selectedLinkId}\n                  onValueChange={setSelectedLinkId}\n                  disabled={loading}\n                >\n                  <SelectTrigger>\n                    <SelectValue placeholder=\"Select a link\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {availableLinks.map((link) => (\n                      <SelectItem key={link.id} value={link.id}>\n                        {link.name}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              </div>\n            ) : null}\n\n            <div className=\"space-y-2\">\n              <span className=\"text-sm font-medium text-muted-foreground\">\n                Custom message\n              </span>\n              <Textarea\n                value={customMessage}\n                onChange={(event) => setCustomMessage(event.target.value)}\n                rows={4}\n                maxLength={500}\n                placeholder=\"Add a short personal message (optional)\"\n                className=\"bg-muted\"\n                disabled={loading}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {customMessage.length}/500 characters\n              </p>\n            </div>\n\n            <div className=\"space-y-2\">\n              <span className=\"text-sm font-medium text-muted-foreground\">\n                Recipients\n              </span>\n              <Textarea\n                value={recipientInput}\n                onChange={(event) => {\n                  setRecipientInput(event.target.value);\n                  setHasEditedRecipients(true);\n                }}\n                placeholder={\n                  defaultRecipients.length > 0\n                    ? defaultRecipients.join(\"\\n\")\n                    : \"Enter email addresses separated by comma or new line\"\n                }\n                className=\"bg-muted\"\n                rows={6}\n                disabled={loading}\n              />\n              <p className=\"text-xs text-muted-foreground\">\n                {recipientInput.length > 0 || defaultRecipients.length > 0\n                  ? `${recipientCount} recipient${recipientCount !== 1 ? \"s\" : \"\"} will receive ${recipientCount !== 1 ? \"invitations\" : \"an invitation\"}`\n                  : \"Enter email addresses to send invitations\"}\n              </p>\n            </div>\n          </div>\n\n          {/* Right Column - Email Preview */}\n          <div className=\"hidden flex-col overflow-y-auto rounded-md border bg-muted/40 md:flex\">\n            <div className=\"sticky top-0 border-b bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60\">\n              <p className=\"font-medium text-foreground\">Email preview</p>\n            </div>\n\n            <div className=\"flex-1 space-y-4 p-4 text-sm\">\n              <div className=\"space-y-1 text-muted-foreground\">\n                <p>\n                  <span className=\"font-medium text-foreground\">Subject:</span>{\" \"}\n                  {fallbackSubject}\n                </p>\n                <p>\n                  <span className=\"font-medium text-foreground\">From:</span>{\" \"}\n                  system@papermark.com\n                </p>\n                <p>\n                  <span className=\"font-medium text-foreground\">To:</span>{\" \"}\n                  {currentRecipients.length > 0 ? (\n                    <>\n                      {displayRecipients.join(\", \")}\n                      {remainingCount > 0 && (\n                        <span className=\"text-foreground\">\n                          {\" \"}\n                          +{remainingCount} more\n                        </span>\n                      )}\n                    </>\n                  ) : (\n                    \"Recipients\"\n                  )}\n                </p>\n              </div>\n\n              <Separator />\n\n              <div className=\"space-y-3 text-sm text-muted-foreground\">\n                <p>Hey!</p>\n                <p>\n                  You have been invited to view the{\" \"}\n                  <span className=\"font-semibold text-foreground\">\n                    {dataroomName}\n                  </span>{\" \"}\n                  dataroom on{\" \"}\n                  <span className=\"font-semibold text-foreground\">\n                    Papermark\n                  </span>\n                  .\n                  <br />\n                  The invitation was sent by{\" \"}\n                  <span className=\"font-semibold text-foreground\">\n                    {senderEmail}\n                  </span>\n                  .\n                </p>\n                {customMessage.length > 0 ? (\n                  <p className=\"whitespace-pre-wrap text-foreground\">\n                    {customMessage}\n                  </p>\n                ) : null}\n                <div className=\"my-4 rounded border border-gray-200 bg-black px-5 py-3 text-center text-xs font-semibold text-white\">\n                  View the dataroom\n                </div>\n                <p className=\"text-xs\">\n                  or copy and paste this URL into your browser:\n                  <br />\n                  <span className=\"break-all text-foreground\">\n                    {selectedLink\n                      ? `https://papermark.com/view/${selectedLink.slug ?? selectedLink.id}`\n                      : \"https://papermark.com/view/...\"}\n                  </span>\n                </p>\n                <Separator className=\"my-2\" />\n                <p className=\"text-xs\">\n                  © {new Date().getFullYear()} Papermark, Inc. All rights\n                  reserved.\n                </p>\n                <p className=\"text-xs\">\n                  This email was intended for{\" \"}\n                  <span className=\"text-foreground\">\n                    {currentRecipients.length > 0\n                      ? currentRecipients[0]\n                      : \"recipient@example.com\"}\n                  </span>\n                  . If you were not expecting this email, you can ignore this\n                  email.\n                </p>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <div className=\"flex w-full flex-col gap-2 sm:flex-row sm:justify-end\">\n            <Button\n              variant=\"outline\"\n              type=\"button\"\n              onClick={handleClose}\n              disabled={loading}\n            >\n              Cancel\n            </Button>\n            <Button\n              onClick={handleSend}\n              loading={loading}\n              disabled={\n                groupId\n                  ? !selectedLinkId || loading || recipientCount === 0\n                  : loading || recipientCount === 0\n              }\n            >\n              {loading\n                ? \"Sending invitations...\"\n                : `Send ${recipientCount} invitation${recipientCount !== 1 ? \"s\" : \"\"}`}\n            </Button>\n          </div>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "ee/features/dataroom-invitations/emails/components/dataroom-viewer-invitation.tsx",
    "content": "import React from \"react\";\n\nimport {\n  Body,\n  Button,\n  Container,\n  Head,\n  Hr,\n  Html,\n  Preview,\n  Section,\n  Tailwind,\n  Text,\n} from \"@react-email/components\";\n\nimport { Footer } from \"../../../../../components/emails/shared/footer\";\n\nexport default function DataroomViewerInvitation({\n  dataroomName = \"Example Data Room\",\n  senderEmail = \"sender@example.com\",\n  url = \"https://app.papermark.com/datarooms/123\",\n  recipientEmail = \"recipient@example.com\",\n  customMessage,\n}: {\n  dataroomName: string;\n  senderEmail: string;\n  url: string;\n  recipientEmail: string;\n  customMessage?: string | null;\n}) {\n  return (\n    <Html>\n      <Head />\n      <Preview>View dataroom on Papermark</Preview>\n      <Tailwind>\n        <Body className=\"mx-auto my-auto bg-white font-sans\">\n          <Container className=\"mx-auto my-10 w-[465px] p-5\">\n            <Text className=\"mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal\">\n              <span className=\"font-bold tracking-tighter\">Papermark</span>\n            </Text>\n            <Text className=\"font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl\">\n              {`View ${dataroomName}`}\n            </Text>\n            <Text className=\"text-sm leading-6 text-black\">Hey!</Text>\n            <Text className=\"text-sm leading-6 text-black\">\n              You have been invited to view the{\" \"}\n              <span className=\"font-semibold\">{dataroomName}</span> dataroom on{\" \"}\n              <span className=\"font-semibold\">Papermark</span>.\n              <br />\n              The invitation was sent by{\" \"}\n              <span className=\"font-semibold\">{senderEmail}</span>.\n            </Text>\n            {customMessage ? (\n              <Text\n                className=\"text-sm leading-6 text-black\"\n                style={{ whiteSpace: \"pre-wrap\" }}\n              >\n                {customMessage}\n              </Text>\n            ) : null}\n            <Section className=\"mb-[32px] mt-[32px] text-center\">\n              <Button\n                className=\"rounded bg-black text-center text-xs font-semibold text-white no-underline\"\n                href={`${url}`}\n                style={{ padding: \"12px 20px\" }}\n              >\n                View the dataroom\n              </Button>\n            </Section>\n            <Text className=\"text-sm text-black\">\n              or copy and paste this URL into your browser: <br />\n              {`${url}`}\n            </Text>\n            <Hr />\n            <Section className=\"mt-8 text-gray-400\">\n              <Text className=\"text-xs\">\n                © {new Date().getFullYear()} Papermark, Inc. All rights\n                reserved.\n              </Text>\n              <Text className=\"text-xs\">\n                This email was intended for{\" \"}\n                <span className=\"text-black\">{recipientEmail}</span>. If you\n                were not expecting this email, you can ignore this email. If you\n                have any feedback or questions about this email, simply reply to\n                it.\n              </Text>\n            </Section>\n          </Container>\n        </Body>\n      </Tailwind>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "ee/features/dataroom-invitations/emails/lib/send-dataroom-viewer-invite.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport DataroomViewerInvitation from \"@/ee/features/dataroom-invitations/emails/components/dataroom-viewer-invitation\";\n\nexport const sendDataroomViewerInvite = async ({\n  dataroomName,\n  senderEmail,\n  to,\n  url,\n  customMessage,\n}: {\n  dataroomName: string;\n  senderEmail: string;\n  to: string;\n  url: string;\n  customMessage?: string;\n}) => {\n  try {\n    await sendEmail({\n      to: to,\n      subject: `You are invited to view ${dataroomName}`,\n      react: DataroomViewerInvitation({\n        senderEmail,\n        dataroomName,\n        url,\n        recipientEmail: to,\n        customMessage,\n      }),\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "ee/features/dataroom-invitations/lib/schema/dataroom-invitations.ts",
    "content": "import { z } from \"zod\";\n\nconst MAX_CUSTOM_MESSAGE_LENGTH = 500;\n\nexport const invitationEmailSchema = z.string().email();\n\nconst trimmedCustomMessageSchema = z\n  .string()\n  .max(MAX_CUSTOM_MESSAGE_LENGTH)\n  .transform((value) => value.trim());\n\nexport const optionalCustomMessageSchema = z\n  .union([trimmedCustomMessageSchema, z.literal(\"\"), z.undefined(), z.null()])\n  .transform((value) => {\n    if (typeof value !== \"string\") {\n      return undefined;\n    }\n\n    return value.length > 0 ? value : undefined;\n  });\n\nexport const sendGroupInvitationSchema = z.object({\n  linkId: z.string().min(1),\n  customMessage: optionalCustomMessageSchema,\n  emails: z.array(invitationEmailSchema).optional(),\n});\n\nexport const sendLinkInvitationSchema = z.object({\n  customMessage: optionalCustomMessageSchema,\n  emails: z.array(invitationEmailSchema).optional(),\n});\n\nexport const SendGroupInvitationSchema = sendGroupInvitationSchema;\nexport const SendLinkInvitationSchema = sendLinkInvitationSchema;\n\nexport type SendGroupInvitationInput = z.infer<typeof sendGroupInvitationSchema>;\nexport type SendLinkInvitationInput = z.infer<typeof sendLinkInvitationSchema>;\n\n"
  },
  {
    "path": "ee/features/dataroom-invitations/lib/swr/use-dataroom-invitations.ts",
    "content": "import useSWR from \"swr\";\n\nimport { useTeam } from \"@/context/team-context\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ntype UninvitedMembersResponse = {\n  count: number;\n  emails: string[];\n};\n\nexport function useUninvitedMembers(\n  dataroomId?: string,\n  groupId?: string,\n) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const {\n    data,\n    error,\n    mutate,\n  } = useSWR<UninvitedMembersResponse>(\n    teamId && dataroomId && groupId\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/uninvited`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 15000,\n    },\n  );\n\n  return {\n    uninvitedCount: data?.count ?? 0,\n    uninvitedEmails: data?.emails ?? [],\n    loading: !data && !error,\n    error,\n    mutate,\n  };\n}\n\n\n"
  },
  {
    "path": "ee/features/permissions/components/dataroom-link-sheet.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { Dispatch, SetStateAction, useEffect, useRef, useState } from \"react\";\n\nimport { useHotkeys } from \"react-hotkeys-hook\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  ItemType,\n  LinkAudienceType,\n  LinkPreset,\n  LinkType,\n} from \"@prisma/client\";\nimport { EyeIcon, RefreshCwIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport useSWR from \"swr\";\nimport z from \"zod\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useDataroomGroups from \"@/lib/swr/use-dataroom-groups\";\nimport { useDomains } from \"@/lib/swr/use-domains\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { LinkWithViews } from \"@/lib/types\";\nimport { convertDataUrlToFile, fetcher, uploadImage } from \"@/lib/utils\";\n\nimport {\n  DEFAULT_LINK_PROPS as BASE_DEFAULT_LINK_PROPS,\n  DEFAULT_LINK_TYPE as BASE_DEFAULT_LINK_TYPE,\n} from \"@/components/links/link-sheet\";\nimport DomainSection from \"@/components/links/link-sheet/domain-section\";\nimport { LinkOptions } from \"@/components/links/link-sheet/link-options\";\nimport LinkSuccessSheet from \"@/components/links/link-sheet/link-success-sheet\";\nimport TagSection from \"@/components/links/link-sheet/tags/tag-section\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { ScrollArea } from \"@/components/ui/scroll-area\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { BadgeTooltip, ButtonTooltip } from \"@/components/ui/tooltip\";\n\nimport { PermissionsSheet } from \"./permissions-sheet\";\n\nexport const DEFAULT_LINK_PROPS = (\n  linkType: LinkType,\n  groupId: string | null = null,\n  showBanner: boolean = true,\n) => ({\n  ...BASE_DEFAULT_LINK_PROPS(linkType, groupId, showBanner),\n  permissions: null,\n});\n\nexport type ItemPermission = Record<\n  string,\n  { view: boolean; download: boolean; itemType: ItemType }\n>;\n\nexport type DEFAULT_LINK_TYPE = BASE_DEFAULT_LINK_TYPE & {\n  permissions?: ItemPermission | null;\n};\n\nexport function DataroomLinkSheet({\n  isOpen,\n  setIsOpen,\n  linkType,\n  currentLink,\n  existingLinks,\n}: {\n  isOpen: boolean;\n  setIsOpen: Dispatch<SetStateAction<boolean>>;\n  linkType: LinkType;\n  currentLink?: DEFAULT_LINK_TYPE;\n  existingLinks?: LinkWithViews[];\n}) {\n  const router = useRouter();\n  const { id: targetId, groupId } = router.query as {\n    id: string;\n    groupId?: string;\n  };\n\n  const { domains } = useDomains({ enabled: isOpen });\n\n  const {\n    viewerGroups,\n    loading: isLoadingGroups,\n    mutate: mutateGroups,\n  } = useDataroomGroups();\n  const { currentTeamId: teamId } = useTeam();\n  const { isFree, isPro, isBusiness, isDatarooms, isDataroomsPlus, isTrial } =\n    usePlan();\n  const { limits } = useLimits();\n  const analytics = useAnalytics();\n  const [data, setData] = useState<DEFAULT_LINK_TYPE>(\n    DEFAULT_LINK_PROPS(linkType, groupId, !isDatarooms),\n  );\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [isSaving, setIsSaving] = useState<boolean>(false);\n  const [currentPreset, setCurrentPreset] = useState<LinkPreset | null>(null);\n  const [showPermissionsSheet, setShowPermissionsSheet] =\n    useState<boolean>(false);\n  const formRef = useRef<HTMLFormElement>(null);\n  const [pendingLinkData, setPendingLinkData] =\n    useState<DEFAULT_LINK_TYPE | null>(null);\n  const [showSuccessSheet, setShowSuccessSheet] = useState<boolean>(false);\n  const [createdLink, setCreatedLink] = useState<LinkWithViews | null>(null);\n  const [hasCustomPermissions, setHasCustomPermissions] =\n    useState<boolean>(false);\n\n  const isPresetsAllowed =\n    isTrial ||\n    (isPro && limits?.advancedLinkControlsOnPro) ||\n    isBusiness ||\n    isDatarooms ||\n    isDataroomsPlus;\n\n  // Presets\n  const { data: presets } = useSWR<LinkPreset[]>(\n    teamId ? `/api/teams/${teamId}/presets` : null,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  useEffect(() => {\n    setData(currentLink || DEFAULT_LINK_PROPS(linkType, groupId, !isDatarooms));\n  }, [currentLink]);\n\n  // Handle Command+Enter (Mac) or Ctrl+Enter (Windows/Linux) to submit the form\n  useHotkeys(\n    \"mod+enter\",\n    (e) => {\n      e.preventDefault();\n      if (!isSaving && formRef.current) {\n        formRef.current.requestSubmit();\n      }\n    },\n    { enabled: isOpen, enableOnFormTags: true },\n    [isSaving],\n  );\n\n  const handlePreviewLink = async (link: LinkWithViews) => {\n    if (link.domainId && isFree) {\n      toast.error(\"You need to upgrade to preview this link\");\n      return;\n    }\n\n    setIsLoading(true);\n    try {\n      const linkId = z.string().cuid().parse(link.id);\n      const response = await fetch(`/api/links/${linkId}/preview`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      });\n\n      if (!response.ok) {\n        toast.error(\"Failed to generate preview link\");\n        setIsLoading(false);\n        return;\n      }\n\n      const { previewToken } = await response.json();\n      const previewLink = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${linkId}?previewToken=${previewToken}`;\n      setIsLoading(false);\n      const linkElement = document.createElement(\"a\");\n      linkElement.href = previewLink;\n      linkElement.target = \"_blank\";\n      document.body.appendChild(linkElement);\n      linkElement.click();\n\n      setTimeout(() => {\n        document.body.removeChild(linkElement);\n      }, 100);\n    } catch (error) {\n      console.error(\"Error generating preview link:\", error);\n      toast.error(\"Failed to generate preview link\");\n      setIsLoading(false);\n      return;\n    }\n  };\n\n  const applyPreset = (presetId: string) => {\n    const preset = presets?.find((p) => p.id === presetId);\n    if (!preset) return;\n\n    setData((prev) => {\n      const isGroupLink = prev.audienceType === LinkAudienceType.GROUP;\n\n      return {\n        ...prev,\n        name: prev.name, // Keep existing name\n        domain: prev.domain, // Keep existing domain\n        slug: prev.slug, // Keep existing slug\n        emailProtected: preset.emailProtected ?? prev.emailProtected,\n        emailAuthenticated:\n          preset.emailAuthenticated ?? prev.emailAuthenticated,\n        // For group links, ignore allow/deny lists from presets as access is controlled by group membership\n        allowList: isGroupLink\n          ? prev.allowList\n          : preset.allowList || prev.allowList,\n        denyList: isGroupLink\n          ? prev.denyList\n          : preset.denyList || prev.denyList,\n        password: preset.password || prev.password,\n        enableCustomMetatag:\n          preset.enableCustomMetaTag ?? prev.enableCustomMetatag,\n        metaTitle: preset.metaTitle || prev.metaTitle,\n        metaDescription: preset.metaDescription || prev.metaDescription,\n        metaImage: preset.metaImage || prev.metaImage,\n        metaFavicon: preset.metaFavicon || prev.metaFavicon,\n        allowDownload: preset.allowDownload || prev.allowDownload,\n        enableAgreement: preset.enableAgreement || prev.enableAgreement,\n        agreementId: preset.agreementId || prev.agreementId,\n        enableScreenshotProtection:\n          preset.enableScreenshotProtection || prev.enableScreenshotProtection,\n        enableNotification: !!preset.enableNotification,\n        showBanner: preset.showBanner ?? prev.showBanner,\n      };\n    });\n\n    setCurrentPreset(preset);\n  };\n\n  const handlePermissionsSave = async (permissions: ItemPermission | null) => {\n    if (!pendingLinkData) return;\n\n    setIsSaving(true);\n\n    try {\n      // Use the unified function for both new and existing links\n      await createOrUpdateLinkWithPermissions(\n        pendingLinkData,\n        permissions,\n        false,\n        true,\n        true,\n      );\n\n      // Close the sheets and show success\n      setIsOpen(false);\n      setShowPermissionsSheet(false);\n      setPendingLinkData(null);\n    } catch (error) {\n      console.error(\"Error creating/updating link with permissions:\", error);\n      setIsSaving(false);\n    }\n  };\n\n  const createOrUpdateLinkWithPermissions = async (\n    linkData: DEFAULT_LINK_TYPE,\n    permissions: ItemPermission | null,\n    shouldPreview: boolean = false,\n    showSuccess: boolean = false,\n    isPermissionUpdate: boolean = false,\n  ) => {\n    // Upload the image if it's a data URL\n    let blobUrl: string | null =\n      linkData.metaImage && linkData.metaImage.startsWith(\"data:\")\n        ? null\n        : linkData.metaImage;\n    if (linkData.metaImage && linkData.metaImage.startsWith(\"data:\")) {\n      // Convert the data URL to a blob\n      const blob = convertDataUrlToFile({ dataUrl: linkData.metaImage });\n      // Upload the blob to vercel storage\n      blobUrl = await uploadImage(blob);\n    }\n\n    // Upload meta favicon if it's a data URL\n    let blobUrlFavicon: string | null =\n      linkData.metaFavicon && linkData.metaFavicon.startsWith(\"data:\")\n        ? null\n        : linkData.metaFavicon;\n    if (linkData.metaFavicon && linkData.metaFavicon.startsWith(\"data:\")) {\n      const blobFavicon = convertDataUrlToFile({\n        dataUrl: linkData.metaFavicon,\n      });\n      blobUrlFavicon = await uploadImage(blobFavicon);\n    }\n\n    const isUpdating = !!currentLink?.id;\n\n    if (isUpdating && !currentLink?.id) {\n      toast.error(\"Invalid link ID for update\");\n      setIsSaving(false);\n      return;\n    }\n\n    const customFields = linkData.customFields?.filter((field) =>\n      field.label.trim(),\n    );\n\n    const response = await fetch(\n      isUpdating ? `/api/links/${currentLink.id}` : \"/api/links\",\n      {\n        method: isUpdating ? \"PUT\" : \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          ...linkData,\n          customFields: customFields,\n          metaImage: blobUrl,\n          metaFavicon: blobUrlFavicon,\n          targetId: targetId,\n          linkType: linkType,\n          teamId: teamId,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      // handle error with toast message\n      const { error } = await response.json();\n      toast.error(error);\n      setIsSaving(false);\n      return;\n    }\n\n    const returnedLink = await response.json();\n\n    // Handle permissions\n    if (\n      isPermissionUpdate &&\n      permissions === null &&\n      isUpdating &&\n      currentLink?.permissionGroupId\n    ) {\n      // Delete the permission group - database will set permissionGroupId to null automatically\n      if (!teamId || !targetId || !currentLink.permissionGroupId) {\n        toast.error(\"Invalid parameters for permission group deletion\");\n        setIsSaving(false);\n        return;\n      }\n\n      try {\n        const targetIdParsed = z.string().cuid().parse(targetId);\n        const teamIdParsed = z.string().cuid().parse(teamId);\n        const permissionGroupIdParsed = z\n          .string()\n          .cuid()\n          .parse(currentLink.permissionGroupId);\n\n        const deleteResponse = await fetch(\n          `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,\n          {\n            method: \"DELETE\",\n          },\n        );\n\n        if (!deleteResponse.ok) {\n          // Handle error with toast message\n          try {\n            const errorData = await deleteResponse.json();\n            toast.error(errorData.error || \"Failed to delete permission group\");\n          } catch {\n            toast.error(\"Failed to delete permission group\");\n          }\n          setIsSaving(false);\n          return;\n        }\n\n        returnedLink.permissionGroupId = null;\n\n        // Show success message\n        toast.success(\"Permission group deleted successfully\");\n\n        // Refresh the links cache\n        mutate(\n          `/api/teams/${teamId}/datarooms/${encodeURIComponent(targetId)}/links`,\n        );\n\n        // Clear the permission group cache instead of invalidating to avoid 404\n        mutate(\n          `/api/teams/${teamId}/datarooms/${targetId}/permission-groups/${currentLink.permissionGroupId}`,\n          undefined,\n          false,\n        );\n      } catch (error) {\n        console.error(\"Error deleting permission group:\", error);\n        toast.error(\"Failed to delete permission group\");\n        setIsSaving(false);\n        return;\n      }\n    } else if (permissions !== null) {\n      // Only handle permission group operations if we have specific permissions to set\n      await handlePermissionGroupOperations(\n        returnedLink,\n        permissions,\n        isUpdating,\n      );\n    }\n\n    // Handle UI updates and notifications\n    await handlePostSaveOperations(\n      returnedLink,\n      isUpdating,\n      showSuccess,\n      shouldPreview,\n      permissions,\n    );\n\n    setData(DEFAULT_LINK_PROPS(linkType, groupId));\n    setIsSaving(false);\n  };\n\n  const handlePermissionGroupOperations = async (\n    link: any,\n    permissions: ItemPermission,\n    isUpdating: boolean,\n  ) => {\n    // Create/update permission group with the provided permissions\n    if (isUpdating && currentLink?.permissionGroupId) {\n      if (!teamId || !targetId || !currentLink.permissionGroupId) {\n        console.error(\"Invalid parameters for permission group update\");\n        return;\n      }\n\n      try {\n        const targetIdParsed = z.string().cuid().parse(targetId);\n        const teamIdParsed = z.string().cuid().parse(teamId);\n        const permissionGroupIdParsed = z\n          .string()\n          .cuid()\n          .parse(currentLink.permissionGroupId);\n        // Update existing permission group\n        const response = await fetch(\n          `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,\n          {\n            method: \"PUT\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              permissions: permissions,\n            }),\n          },\n        );\n\n        if (response.ok) {\n          // Invalidate the permission group cache\n          mutate(\n            `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,\n          );\n        }\n      } catch (error) {\n        console.error(\"Error updating permission group:\", error);\n        toast.error(\"Failed to update permission group\");\n        setIsSaving(false);\n        return;\n      }\n    } else {\n      if (!teamId || !targetId) {\n        console.error(\"Invalid parameters for permission group creation\");\n        return;\n      }\n\n      try {\n        const targetIdParsed = z.string().cuid().parse(targetId);\n        const teamIdParsed = z.string().cuid().parse(teamId);\n        // Create new permission group\n        const response = await fetch(\n          `/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({\n              permissions: permissions,\n              linkId: link.id,\n            }),\n          },\n        );\n\n        if (response.ok) {\n          const { permissionGroup: newPermissionGroup, _ } =\n            await response.json();\n          // Cache the new permission group data\n          mutate(\n            `/api/teams/${teamId}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,\n            newPermissionGroup,\n            false,\n          );\n\n          // Update the link with the new permission group ID\n          if (newPermissionGroup.id) {\n            link.permissionGroupId = newPermissionGroup.id;\n          }\n        }\n      } catch (error) {\n        console.error(\"Error creating/updating permission group:\", error);\n        toast.error(\"Failed to create/update permission group\");\n        setIsSaving(false);\n        return;\n      }\n    }\n  };\n\n  const handlePostSaveOperations = async (\n    returnedLink: any,\n    isUpdating: boolean,\n    showSuccess: boolean,\n    shouldPreview: boolean,\n    permissions: ItemPermission | null,\n  ) => {\n    const endpointTargetType = `${linkType.replace(\"_LINK\", \"\").toLowerCase()}s`; // \"documents\" or \"datarooms\"\n\n    if (isUpdating) {\n      setIsOpen(false);\n      // Update the link in the list of links\n      mutate(\n        `/api/teams/${teamId}/${endpointTargetType}/${encodeURIComponent(\n          targetId,\n        )}/links`,\n        (existingLinks || []).map((link) =>\n          link.id === currentLink!.id ? returnedLink : link,\n        ),\n        false,\n      );\n\n      // Handle group changes\n      if (!!groupId && returnedLink.audienceType === LinkAudienceType.GROUP) {\n        // If we're viewing a group page\n        if (currentLink!.groupId !== returnedLink.groupId) {\n          // If the link's group has changed\n          if (currentLink!.groupId === groupId) {\n            // If the link was in the current group but is now in a different group\n            // Remove it from the current group's view\n            const groupLinks =\n              existingLinks?.filter(\n                (link) =>\n                  link.id !== currentLink!.id && link.groupId === groupId,\n              ) || [];\n\n            mutate(\n              `/api/teams/${teamId}/${endpointTargetType}/${encodeURIComponent(\n                targetId,\n              )}/groups/${groupId}/links`,\n              groupLinks,\n              false,\n            );\n          } else if (returnedLink.groupId === groupId) {\n            // If the link was in a different group but is now in the current group\n            // Add it to the current group's view\n            const groupLinks =\n              existingLinks?.filter((link) => link.groupId === groupId) || [];\n\n            mutate(\n              `/api/teams/${teamId}/${endpointTargetType}/${encodeURIComponent(\n                targetId,\n              )}/groups/${groupId}/links`,\n              [returnedLink, ...groupLinks],\n              false,\n            );\n          }\n        } else if (returnedLink.groupId === groupId) {\n          // If the link's group hasn't changed and it's in the current group\n          // Update it in the current group's view\n          const groupLinks =\n            existingLinks?.filter((link) => link.groupId === groupId) || [];\n\n          mutate(\n            `/api/teams/${teamId}/${endpointTargetType}/${encodeURIComponent(\n              targetId,\n            )}/groups/${groupId}/links`,\n            groupLinks.map((link) =>\n              link.id === currentLink!.id ? returnedLink : link,\n            ),\n            false,\n          );\n        }\n      }\n\n      // Track what changed for analytics\n      const changedFields: Record<string, { from: unknown; to: unknown }> =\n        {};\n      const trackableFields: (keyof BASE_DEFAULT_LINK_TYPE)[] = [\n        \"name\",\n        \"domain\",\n        \"slug\",\n        \"expiresAt\",\n        \"emailProtected\",\n        \"emailAuthenticated\",\n        \"allowDownload\",\n        \"allowList\",\n        \"denyList\",\n        \"enableNotification\",\n        \"enableFeedback\",\n        \"enableScreenshotProtection\",\n        \"enableCustomMetatag\",\n        \"metaTitle\",\n        \"metaDescription\",\n        \"welcomeMessage\",\n        \"enableQuestion\",\n        \"questionText\",\n        \"questionType\",\n        \"enableAgreement\",\n        \"agreementId\",\n        \"showBanner\",\n        \"enableWatermark\",\n        \"audienceType\",\n        \"groupId\",\n        \"enableConversation\",\n        \"enableAIAgents\",\n        \"enableUpload\",\n        \"isFileRequestOnly\",\n        \"uploadFolderId\",\n        \"enableIndexFile\",\n        \"permissionGroupId\",\n        \"tags\",\n      ];\n\n      for (const field of trackableFields) {\n        if (\n          JSON.stringify(currentLink![field]) !== JSON.stringify(data[field])\n        ) {\n          changedFields[field] = {\n            from: currentLink![field],\n            to: data[field],\n          };\n        }\n      }\n\n      // Password: log set/unset/changed status only, not actual values\n      if (!!currentLink!.password !== !!data.password) {\n        changedFields.password = {\n          from: currentLink!.password ? \"set\" : \"unset\",\n          to: data.password ? \"set\" : \"unset\",\n        };\n      } else if (\n        currentLink!.password &&\n        data.password &&\n        currentLink!.password !== data.password\n      ) {\n        changedFields.password = { from: \"set\", to: \"changed\" };\n      }\n\n      // Image fields: log set/unset status only, not URLs\n      if (currentLink!.metaImage !== data.metaImage) {\n        changedFields.metaImage = {\n          from: currentLink!.metaImage ? \"set\" : \"unset\",\n          to: data.metaImage ? \"set\" : \"unset\",\n        };\n      }\n      if (currentLink!.metaFavicon !== data.metaFavicon) {\n        changedFields.metaFavicon = {\n          from: currentLink!.metaFavicon ? \"set\" : \"unset\",\n          to: data.metaFavicon ? \"set\" : \"unset\",\n        };\n      }\n\n      // Watermark config: log configured/unset status\n      if (\n        JSON.stringify(currentLink!.watermarkConfig) !==\n        JSON.stringify(data.watermarkConfig)\n      ) {\n        changedFields.watermarkConfig = {\n          from: currentLink!.watermarkConfig ? \"configured\" : \"unset\",\n          to: data.watermarkConfig ? \"configured\" : \"unset\",\n        };\n      }\n\n      // Custom fields: log count change\n      if (\n        JSON.stringify(currentLink!.customFields) !==\n        JSON.stringify(data.customFields)\n      ) {\n        changedFields.customFields = {\n          from: currentLink!.customFields?.length ?? 0,\n          to: data.customFields?.length ?? 0,\n        };\n      }\n\n      analytics.capture(\"Link Updated\", {\n        linkId: currentLink!.id,\n        targetId,\n        linkType,\n        teamId,\n        customDomain: returnedLink.domainSlug ?? null,\n        changes: changedFields,\n        changedProperties: Object.keys(changedFields),\n      });\n\n      toast.success(\"Link updated successfully\");\n    } else {\n      // Add the new link to the list of links\n      mutate(\n        `/api/teams/${teamId}/${endpointTargetType}/${encodeURIComponent(\n          targetId,\n        )}/links`,\n        [returnedLink, ...(existingLinks || [])],\n        false,\n      );\n\n      // Also update the group-specific links cache if this is a group link\n      if (\n        !!groupId &&\n        returnedLink.audienceType === LinkAudienceType.GROUP &&\n        returnedLink.groupId === groupId\n      ) {\n        const groupLinks =\n          existingLinks?.filter((link) => link.groupId === groupId) || [];\n        mutate(\n          `/api/teams/${teamId}/${endpointTargetType}/${encodeURIComponent(\n            targetId,\n          )}/groups/${groupId}/links`,\n          [returnedLink, ...groupLinks],\n          false,\n        );\n      }\n\n      analytics.capture(\"Link Added\", {\n        linkId: returnedLink.id,\n        targetId,\n        linkType,\n        customDomain: returnedLink.domainSlug,\n      });\n\n      if (showSuccess) {\n        // Show success sheet instead of closing\n        setIsOpen(false);\n        setShowPermissionsSheet(false);\n        setPendingLinkData(null);\n        setCreatedLink(returnedLink);\n        setHasCustomPermissions(\n          permissions !== null &&\n            permissions &&\n            Object.keys(permissions).length > 0,\n        );\n        setShowSuccessSheet(true);\n      } else {\n        setIsOpen(false);\n        setShowPermissionsSheet(false);\n        setPendingLinkData(null);\n        toast.success(\"Link created successfully\");\n        const isOnPermissionsPage = router.asPath.includes(\"/permissions\");\n        if (linkType === LinkType.DATAROOM_LINK && !isOnPermissionsPage) {\n          router.push(`/datarooms/${targetId}/permissions`);\n        }\n      }\n    }\n\n    if (shouldPreview) {\n      await handlePreviewLink(returnedLink);\n    }\n  };\n\n  // Remove the old createLinkWithPermissions function and replace it with a simple wrapper\n  const createLinkWithPermissions = async (\n    linkData: DEFAULT_LINK_TYPE,\n    shouldPreview: boolean = false,\n    showSuccess: boolean = false,\n  ) => {\n    // For backward compatibility, extract permissions from linkData\n    setIsSaving(true);\n    const permissions = linkData.permissions || null;\n    await createOrUpdateLinkWithPermissions(\n      linkData,\n      permissions,\n      shouldPreview,\n      showSuccess,\n      false,\n    );\n  };\n\n  const handleSubmit = async (\n    event: any,\n    shouldPreview: boolean = false,\n    shouldManagePermissions: boolean = false,\n  ) => {\n    event.preventDefault();\n\n    if (shouldManagePermissions && linkType === LinkType.DATAROOM_LINK) {\n      // Store the link data and show permissions sheet\n      setPendingLinkData(data);\n      setShowPermissionsSheet(true);\n      return;\n    }\n    // Use the refactored function\n    await createLinkWithPermissions(data, shouldPreview);\n  };\n\n  const handleCreateAnother = () => {\n    // Close success sheet and open new link sheet\n    setShowSuccessSheet(false);\n    setCreatedLink(null);\n    setHasCustomPermissions(false);\n    setData(DEFAULT_LINK_PROPS(linkType, groupId));\n    setIsOpen(true);\n  };\n\n  return (\n    <>\n      <Sheet open={isOpen} onOpenChange={(open: boolean) => setIsOpen(open)}>\n        <SheetContent className=\"flex w-[90%] flex-col justify-between border-l border-gray-200 bg-background px-4 text-foreground dark:border-gray-800 dark:bg-gray-900 sm:w-[800px] sm:max-w-4xl md:px-5\">\n          <SheetHeader className=\"text-start\">\n            <SheetTitle>\n              {currentLink\n                ? `Edit ${currentLink.audienceType === LinkAudienceType.GROUP ? \"group\" : \"\"} link`\n                : \"Create a new link\"}\n            </SheetTitle>\n          </SheetHeader>\n\n          <form\n            ref={formRef}\n            className=\"flex grow flex-col\"\n            onSubmit={(e) => handleSubmit(e, false)}\n          >\n            <ScrollArea className=\"flex-grow\">\n              <div className=\"h-0 flex-1\">\n                <div className=\"flex flex-1 flex-col justify-between pb-6\">\n                  <div className=\"divide-y divide-gray-200\">\n                    <Tabs\n                      value={data.audienceType}\n                      onValueChange={(value) =>\n                        setData({\n                          ...data,\n                          audienceType: value as LinkAudienceType,\n                        })\n                      }\n                    >\n                      {/* {linkType === LinkType.DATAROOM_LINK && !!!currentLink ? (\n                      <TabsList className=\"grid w-full grid-cols-2\">\n                        <TabsTrigger value={LinkAudienceType.GENERAL}>\n                          General\n                        </TabsTrigger>\n                        {isDatarooms || isDataroomsPlus || isTrial ? (\n                          <TabsTrigger value={LinkAudienceType.GROUP}>\n                            Group\n                          </TabsTrigger>\n                        ) : (\n                          <UpgradePlanModal\n                            clickedPlan={PlanEnum.DataRooms}\n                            trigger=\"add_group_link\"\n                          >\n                            <div className=\"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all\">\n                              Group\n                            </div>\n                          </UpgradePlanModal>\n                        )}\n                      </TabsList>\n                    ) : null} */}\n\n                      {/* GENERAL LINK */}\n                      <TabsContent value={LinkAudienceType.GENERAL}>\n                        <div className=\"space-y-6 pt-2\">\n                          <div className=\"space-y-2\">\n                            <Label htmlFor=\"link-name\">Link Name</Label>\n                            <Input\n                              type=\"text\"\n                              name=\"link-name\"\n                              id=\"link-name\"\n                              placeholder=\"Recipient's Organization\"\n                              value={data.name || \"\"}\n                              className=\"focus:ring-inset\"\n                              onChange={(e) =>\n                                setData({ ...data, name: e.target.value })\n                              }\n                            />\n                          </div>\n\n                          <div className=\"space-y-2\">\n                            <DomainSection\n                              {...{ data, setData, domains }}\n                              linkType={linkType}\n                              editLink={!!currentLink}\n                            />\n                          </div>\n\n                          {/* Preset Selector - only show when creating a new link */}\n                          {!currentLink &&\n                            isPresetsAllowed &&\n                            presets &&\n                            presets.length > 0 && (\n                              <div className=\"space-y-2\">\n                                <div className=\"flex items-center justify-between\">\n                                  <Label htmlFor=\"preset\">Link Preset</Label>\n                                  <Link\n                                    href=\"/settings/presets\"\n                                    className=\"text-xs text-muted-foreground hover:text-foreground hover:underline\"\n                                  >\n                                    Manage\n                                  </Link>\n                                </div>\n                                <Select onValueChange={applyPreset}>\n                                  <SelectTrigger className=\"w-full\">\n                                    <SelectValue placeholder=\"Select a preset\" />\n                                  </SelectTrigger>\n                                  <SelectContent>\n                                    {presets.map((preset) => (\n                                      <SelectItem\n                                        key={preset.id}\n                                        value={preset.id}\n                                      >\n                                        {preset.name}\n                                      </SelectItem>\n                                    ))}\n                                  </SelectContent>\n                                </Select>\n                                <p className=\"text-xs text-muted-foreground\">\n                                  Apply a preset to quickly configure link\n                                  settings\n                                </p>\n                              </div>\n                            )}\n\n                          <div className=\"relative flex items-center\">\n                            <Separator className=\"absolute bg-muted-foreground\" />\n                            <div className=\"relative mx-auto\">\n                              <span className=\"bg-background px-2 text-sm text-muted-foreground dark:bg-gray-900\">\n                                Link Options\n                              </span>\n                            </div>\n                          </div>\n\n                          <LinkOptions\n                            data={data}\n                            setData={setData}\n                            targetId={targetId}\n                            linkType={linkType}\n                            editLink={!!currentLink}\n                            currentPreset={currentPreset}\n                          />\n                        </div>\n                      </TabsContent>\n\n                      {/* GROUP LINK */}\n                      <TabsContent value={LinkAudienceType.GROUP}>\n                        <div className=\"space-y-6 pt-2\">\n                          <div className=\"space-y-2\">\n                            <div className=\"flex w-full items-center justify-between\">\n                              <Label htmlFor=\"group-id\">Group</Label>\n                              <ButtonTooltip content=\"Refresh groups\">\n                                <Button\n                                  size=\"icon\"\n                                  variant=\"ghost\"\n                                  className=\"h-6\"\n                                  onClick={async (e) => {\n                                    e.stopPropagation();\n                                    e.preventDefault();\n                                    await mutateGroups();\n                                  }}\n                                >\n                                  <RefreshCwIcon className=\"h-4 w-4\" />\n                                </Button>\n                              </ButtonTooltip>\n                            </div>\n                            <Select\n                              onValueChange={(value) => {\n                                if (value === \"add_group\") {\n                                  return;\n                                }\n\n                                setData({ ...data, groupId: value });\n                              }}\n                              defaultValue={data.groupId ?? undefined}\n                            >\n                              <SelectTrigger className=\"focus:ring-offset-3 flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-gray-400 sm:text-sm sm:leading-6\">\n                                <SelectValue placeholder=\"Select an group\" />\n                              </SelectTrigger>\n                              <SelectContent>\n                                {isLoadingGroups ? (\n                                  <SelectItem value=\"loading\" disabled>\n                                    Loading groups...\n                                  </SelectItem>\n                                ) : viewerGroups && viewerGroups.length > 0 ? (\n                                  viewerGroups.map(({ id, name, _count }) => (\n                                    <SelectItem key={id} value={id}>\n                                      {name}{\" \"}\n                                      <span className=\"text-muted-foreground\">\n                                        ({_count.members} members)\n                                      </span>\n                                    </SelectItem>\n                                  ))\n                                ) : (\n                                  <SelectItem value=\"no-groups\" disabled>\n                                    No groups available\n                                  </SelectItem>\n                                )}\n                              </SelectContent>\n                            </Select>\n                          </div>\n\n                          <div className=\"space-y-2\">\n                            <Label htmlFor=\"link-name\">Link Name</Label>\n\n                            <Input\n                              type=\"text\"\n                              name=\"link-name\"\n                              id=\"link-name\"\n                              placeholder={\n                                viewerGroups?.find(\n                                  (group) => group.id === data.groupId,\n                                )?.name\n                                  ? `${\n                                      viewerGroups?.find(\n                                        (group) => group.id === data.groupId,\n                                      )?.name\n                                    } Link`\n                                  : \"Group Link\"\n                              }\n                              value={data.name || \"\"}\n                              className=\"focus:ring-inset\"\n                              onChange={(e) =>\n                                setData({ ...data, name: e.target.value })\n                              }\n                            />\n                          </div>\n\n                          <div className=\"space-y-2\">\n                            <DomainSection\n                              {...{ data, setData, domains }}\n                              linkType={linkType}\n                              editLink={!!currentLink}\n                            />\n                          </div>\n\n                          {/* Preset Selector for Group links - only show when creating a new link */}\n                          {!currentLink &&\n                            isPresetsAllowed &&\n                            presets &&\n                            presets.length > 0 && (\n                              <div className=\"space-y-2\">\n                                <div className=\"flex items-center justify-between\">\n                                  <Label htmlFor=\"preset\">Link Preset</Label>\n                                  <Link\n                                    href=\"/settings/presets\"\n                                    className=\"text-xs text-muted-foreground hover:text-foreground hover:underline\"\n                                  >\n                                    Manage\n                                  </Link>\n                                </div>\n                                <Select onValueChange={applyPreset}>\n                                  <SelectTrigger className=\"w-full\">\n                                    <SelectValue placeholder=\"Select a preset\" />\n                                  </SelectTrigger>\n                                  <SelectContent>\n                                    {presets.map((preset) => (\n                                      <SelectItem\n                                        key={preset.id}\n                                        value={preset.id}\n                                      >\n                                        {preset.name}\n                                      </SelectItem>\n                                    ))}\n                                  </SelectContent>\n                                </Select>\n                                <p className=\"text-xs text-muted-foreground\">\n                                  Apply a preset to quickly configure link\n                                  settings\n                                </p>\n                              </div>\n                            )}\n\n                          <div className=\"relative flex items-center\">\n                            <Separator className=\"absolute bg-muted-foreground\" />\n                            <div className=\"relative mx-auto\">\n                              <span className=\"bg-background px-2 text-sm text-muted-foreground dark:bg-gray-900\">\n                                Link Options\n                              </span>\n                            </div>\n                          </div>\n\n                          <LinkOptions\n                            data={data}\n                            setData={setData}\n                            targetId={targetId}\n                            linkType={linkType}\n                            editLink={!!currentLink}\n                            currentPreset={currentPreset}\n                          />\n                        </div>\n                      </TabsContent>\n                    </Tabs>\n                  </div>\n                  <Separator className=\"mb-6 mt-2\" />\n\n                  <div className=\"space-y-2\">\n                    <TagSection\n                      {...{ data, setData }}\n                      teamId={teamId as string}\n                    />\n                  </div>\n                </div>\n              </div>\n            </ScrollArea>\n\n            <SheetFooter>\n              <div className=\"flex flex-row-reverse items-center gap-2 pt-2\">\n                {linkType === LinkType.DATAROOM_LINK &&\n                  data?.audienceType !== LinkAudienceType.GROUP && (\n                    <Button\n                      type=\"button\"\n                      variant=\"default\"\n                      onClick={(e) => handleSubmit(e, false, true)}\n                    >\n                      Manage File Permissions\n                    </Button>\n                  )}\n                <Button\n                  type=\"button\"\n                  variant={\n                    linkType === LinkType.DOCUMENT_LINK ||\n                    (linkType === LinkType.DATAROOM_LINK &&\n                      data?.audienceType === LinkAudienceType.GROUP)\n                      ? \"default\"\n                      : \"outline\"\n                  }\n                  loading={isSaving}\n                  onClick={(e) => handleSubmit(e, false)}\n                >\n                  {currentLink ? \"Update Link\" : \"Save Link\"}\n                </Button>\n                <BadgeTooltip\n                  content={currentLink ? \"Update & Preview\" : \"Save & Preview\"}\n                >\n                  <Button\n                    type=\"button\"\n                    variant=\"link\"\n                    loading={isLoading}\n                    onClick={(e) => handleSubmit(e, true)}\n                    className=\"flex items-center gap-2\"\n                  >\n                    <EyeIcon className=\"h-4 w-4\" />\n                  </Button>\n                </BadgeTooltip>\n              </div>\n            </SheetFooter>\n          </form>\n        </SheetContent>\n\n        <PermissionsSheet\n          isOpen={showPermissionsSheet}\n          setIsOpen={(open) => {\n            setShowPermissionsSheet(open);\n            if (!open) {\n              setShowSuccessSheet(true);\n            }\n          }}\n          dataroomId={targetId}\n          linkId={currentLink?.id ?? undefined}\n          permissionGroupId={currentLink?.permissionGroupId ?? undefined}\n          onSave={handlePermissionsSave}\n        />\n      </Sheet>\n\n      {createdLink && (\n        <LinkSuccessSheet\n          isOpen={showSuccessSheet}\n          setIsOpen={setShowSuccessSheet}\n          link={createdLink}\n          hasCustomPermissions={hasCustomPermissions}\n          onCreateAnother={handleCreateAnother}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "ee/features/permissions/components/permissions-sheet.tsx",
    "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { ItemType, PermissionGroupAccessControls } from \"@prisma/client\";\nimport {\n  ColumnDef,\n  flexRender,\n  getCoreRowModel,\n  getExpandedRowModel,\n  useReactTable,\n} from \"@tanstack/react-table\";\nimport {\n  ArrowDownToLineIcon,\n  ChevronDown,\n  ChevronRight,\n  CrownIcon,\n  EyeIcon,\n  EyeOffIcon,\n  File,\n  Folder,\n  HomeIcon,\n} from \"lucide-react\";\nimport useSWR from \"swr\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDataroomFoldersTree } from \"@/lib/swr/use-dataroom\";\nimport { cn, fetcher } from \"@/lib/utils\";\nimport {\n  HIERARCHICAL_DISPLAY_STYLE,\n  getHierarchicalDisplayName,\n} from \"@/lib/utils/hierarchical-display\";\n\nimport PlanBadge from \"@/components/billing/plan-badge\";\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport CloudDownloadOff from \"@/components/shared/icons/cloud-download-off\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Sheet,\n  SheetContent,\n  SheetFooter,\n  SheetHeader,\n  SheetTitle,\n} from \"@/components/ui/sheet\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\n\nconst PermissionGroupItemName = ({ item }: { item: FileOrFolder }) => {\n  const { isFeatureEnabled } = useFeatureFlags();\n  const isDataroomIndexEnabled = isFeatureEnabled(\"dataroomIndex\");\n\n  const displayName = getHierarchicalDisplayName(\n    item.name,\n    item.hierarchicalIndex,\n    isDataroomIndexEnabled,\n  );\n\n  const isRoot = item.id === \"__dataroom_root__\";\n\n  return (\n    <div className=\"flex items-center text-foreground\">\n      {isRoot ? (\n        <HomeIcon className=\"mr-2 h-5 w-5\" />\n      ) : item.itemType === ItemType.DATAROOM_FOLDER ? (\n        <Folder className=\"mr-2 h-5 w-5\" />\n      ) : (\n        <File className=\"mr-2 h-5 w-5\" />\n      )}\n      <span className=\"truncate\" style={HIERARCHICAL_DISPLAY_STYLE}>\n        {displayName}\n      </span>\n    </div>\n  );\n};\n\n// FileOrFolder type for link permissions\ntype FileOrFolder = {\n  id: string;\n  name: string;\n  hierarchicalIndex?: string | null;\n  subItems?: FileOrFolder[];\n  permissions: {\n    view: boolean;\n    download: boolean;\n    partialView?: boolean;\n    partialDownload?: boolean;\n  };\n  itemType: ItemType;\n  documentId?: string;\n};\n\ntype ItemPermission = Record<\n  string,\n  { view: boolean; download: boolean; itemType: ItemType }\n>;\n\ntype ColumnExtra = {\n  updatePermissions: (id: string, newPermissions: string[]) => void;\n};\n\nconst createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [\n  {\n    accessorKey: \"name\",\n    header: \"Name\",\n    cell: ({ row }) => {\n      const isRoot = row.original.id === \"__dataroom_root__\";\n      return (\n        <div className=\"flex items-center text-foreground\">\n          {isRoot ? (\n            <div className=\"h-6 w-6 shrink-0\" />\n          ) : row.getCanExpand() ? (\n            <Button\n              variant=\"ghost\"\n              onClick={row.getToggleExpandedHandler()}\n              className=\"mr-1 h-6 w-6 shrink-0 p-0\"\n              disabled={isRoot} // Root is always expanded\n            >\n              {row.getIsExpanded() ? (\n                <ChevronDown className=\"h-4 w-4\" />\n              ) : (\n                <ChevronRight className=\"h-4 w-4\" />\n              )}\n            </Button>\n          ) : (\n            <div className=\"mr-1 h-6 w-6 shrink-0\" /> // Placeholder to maintain alignment\n          )}\n          <PermissionGroupItemName item={row.original} />\n        </div>\n      );\n    },\n  },\n  {\n    id: \"actions\",\n    header: \"Actions\",\n    cell: ({ row }) => {\n      const item = row.original;\n\n      const handleValueChange = (value: string[]) => {\n        extra.updatePermissions(item.id, value);\n      };\n\n      return (\n        <ToggleGroup\n          type=\"multiple\"\n          value={Object.entries(item.permissions)\n            .filter(([_, value]) => value)\n            .map(([key, _]) => key)}\n          onValueChange={handleValueChange}\n        >\n          <ToggleGroupItem\n            value=\"view\"\n            aria-label=\"Toggle view\"\n            size=\"sm\"\n            className={cn(\n              \"px-2 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n              item.permissions.view\n                ? item.permissions.partialView\n                  ? \"data-[state=on]:bg-gray-400 data-[state=on]:text-background\"\n                  : \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                : \"\",\n            )}\n          >\n            {item.permissions.view ||\n            (item.permissions.view && item.permissions.partialView) ? (\n              <EyeIcon className=\"h-5 w-5\" />\n            ) : (\n              <EyeOffIcon className=\"h-5 w-5\" />\n            )}\n          </ToggleGroupItem>\n          <ToggleGroupItem\n            value=\"download\"\n            aria-label=\"Toggle download\"\n            size=\"sm\"\n            className={cn(\n              \"px-2 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background\",\n              item.permissions.download\n                ? item.permissions.partialDownload\n                  ? \"data-[state=on]:bg-gray-400 data-[state=on]:text-background\"\n                  : \"data-[state=on]:bg-foreground data-[state=on]:text-background\"\n                : \"\",\n            )}\n          >\n            {item.permissions.download ||\n            (item.permissions.download && item.permissions.partialDownload) ? (\n              <ArrowDownToLineIcon className=\"h-5 w-5\" />\n            ) : (\n              <CloudDownloadOff className=\"h-5 w-5\" />\n            )}\n          </ToggleGroupItem>\n        </ToggleGroup>\n      );\n    },\n  },\n];\n\n// Build tree function adapted for link permissions with virtual root\nconst buildTree = (\n  items: any[],\n  permissions: PermissionGroupAccessControls[],\n  parentId: string | null = null,\n): FileOrFolder[] => {\n  const getPermissions = (id: string) => {\n    const permission = permissions.find((p) => p.itemId === id);\n\n    // If we have permission data loaded, use it. Otherwise default to true for new links.\n    const hasPermissionData = permissions.length > 0;\n\n    return {\n      view: permission ? permission.canView : hasPermissionData ? false : true,\n      download: permission ? permission.canDownload : false,\n      partialView: false,\n      partialDownload: false,\n    };\n  };\n\n  const result: FileOrFolder[] = [];\n\n  // Handle folders and their contents\n  items\n    .filter((item) => item.parentId === parentId && !item.document)\n    .forEach((folder) => {\n      const subItems = buildTree(items, permissions, folder.id);\n\n      // Add documents directly in this folder\n      const folderDocuments = (folder.documents || []).map((doc: any) => ({\n        id: doc.id,\n        documentId: doc.document.id,\n        name: doc.document.name,\n        hierarchicalIndex: doc.hierarchicalIndex,\n        permissions: getPermissions(doc.id),\n        itemType: ItemType.DATAROOM_DOCUMENT,\n      }));\n\n      const allSubItems = [...subItems, ...folderDocuments];\n\n      const folderPermissions = getPermissions(folder.id);\n\n      // Calculate view and partialView for folders\n      let viewStatus = folderPermissions.view;\n      let partialView = false;\n      let downloadStatus = folderPermissions.download;\n      let partialDownload = false;\n\n      if (allSubItems.length > 0) {\n        const viewableItems = allSubItems.filter(\n          (item) => item.permissions.view,\n        );\n        const downloadableItems = allSubItems.filter(\n          (item) => item.permissions.download,\n        );\n\n        viewStatus = viewableItems.length > 0;\n        partialView =\n          viewableItems.length > 0 && viewableItems.length < allSubItems.length;\n        downloadStatus = downloadableItems.length > 0;\n        partialDownload =\n          downloadableItems.length > 0 &&\n          downloadableItems.length < allSubItems.length;\n      }\n\n      result.push({\n        id: folder.id,\n        name: folder.name,\n        hierarchicalIndex: folder.hierarchicalIndex,\n        subItems: allSubItems,\n        permissions: {\n          view: viewStatus,\n          download: downloadStatus,\n          partialView,\n          partialDownload,\n        },\n        itemType: ItemType.DATAROOM_FOLDER,\n      });\n    });\n\n  // Handle documents that are direct children of the current parent (including root level)\n  items\n    .filter(\n      (item) =>\n        (item.parentId === parentId && item.document) ||\n        (parentId === null && item.folderId === null && item.document),\n    )\n    .forEach((doc) => {\n      result.push({\n        id: doc.id,\n        documentId: doc.document.id,\n        name: doc.document.name,\n        hierarchicalIndex: doc.hierarchicalIndex,\n        permissions: getPermissions(doc.id),\n        itemType: ItemType.DATAROOM_DOCUMENT,\n      });\n    });\n\n  return result;\n};\n\n// Build tree with virtual root folder\nconst buildTreeWithRoot = (\n  items: any[],\n  permissions: PermissionGroupAccessControls[],\n  dataroomName: string = \"Dataroom Home\",\n): FileOrFolder[] => {\n  // Get all items (folders and root documents) - buildTree already handles root documents when parentId is null\n  const allItems = buildTree(items, permissions, null);\n\n  // Calculate overall permissions for the virtual root\n  const calculateRootPermissions = (items: FileOrFolder[]) => {\n    const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {\n      return items.reduce((acc, item) => {\n        acc.push(item);\n        if (item.subItems) {\n          acc.push(...flattenItems(item.subItems));\n        }\n        return acc;\n      }, [] as FileOrFolder[]);\n    };\n\n    const allFlatItems = flattenItems(items);\n    const viewableItems = allFlatItems.filter((item) => item.permissions.view);\n    const downloadableItems = allFlatItems.filter(\n      (item) => item.permissions.download,\n    );\n\n    return {\n      view: viewableItems.length > 0,\n      download: downloadableItems.length > 0,\n      partialView:\n        viewableItems.length > 0 && viewableItems.length < allFlatItems.length,\n      partialDownload:\n        downloadableItems.length > 0 &&\n        downloadableItems.length < allFlatItems.length,\n    };\n  };\n\n  const rootPermissions = calculateRootPermissions(allItems);\n\n  return [\n    {\n      id: \"__dataroom_root__\",\n      name: dataroomName,\n      subItems: allItems,\n      permissions: rootPermissions,\n      itemType: ItemType.DATAROOM_FOLDER,\n    },\n  ];\n};\n\ninterface PermissionsSheetProps {\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n  dataroomId: string;\n  linkId?: string; // For editing existing links\n  permissionGroupId?: string | null; // For loading existing permissions\n  onSave: (permissions: ItemPermission | null) => void;\n  // initialPermissions?: PermissionGroupAccessControls[]; // Keep for backward compatibility\n}\n\nexport function PermissionsSheet({\n  isOpen,\n  setIsOpen,\n  dataroomId,\n  linkId,\n  permissionGroupId,\n  onSave,\n  // initialPermissions = [],\n}: PermissionsSheetProps) {\n  const { currentTeamId } = useTeam();\n  const { isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n\n  // Check if custom permissions are allowed\n  const canSetCustomPermissions = isDatarooms || isDataroomsPlus || isTrial;\n\n  const { folders, loading } = useDataroomFoldersTree({\n    dataroomId,\n    include_documents: true,\n  });\n\n  // Fetch permission group data if permissionGroupId is provided\n  const { data: permissionGroupData, isLoading: permissionGroupLoading } =\n    useSWR<{\n      permissionGroup: {\n        id: string;\n        name: string;\n        description: string | null;\n        accessControls: PermissionGroupAccessControls[];\n      };\n    }>(\n      permissionGroupId && currentTeamId\n        ? `/api/teams/${currentTeamId}/datarooms/${dataroomId}/permission-groups/${permissionGroupId}`\n        : null,\n      fetcher,\n    );\n\n  const [data, setData] = useState<FileOrFolder[]>([]);\n  const [pendingChanges, setPendingChanges] = useState<ItemPermission>({});\n  const [isSaving, setIsSaving] = useState<boolean>(false);\n\n  // Determine the effective permissions to use\n  const effectivePermissions = useMemo(() => {\n    if (permissionGroupData?.permissionGroup?.accessControls) {\n      return permissionGroupData.permissionGroup.accessControls;\n    }\n    return [];\n  }, [permissionGroupData]);\n\n  // Add state for the \"share entire dataroom\" toggle\n  const [shareEntireDataroom, setShareEntireDataroom] = useState<boolean>(\n    effectivePermissions.length === 0, // Default to true if no effective permissions\n  );\n\n  // Use ref to access current data without dependency\n  const dataRef = useRef<FileOrFolder[]>([]);\n\n  useEffect(() => {\n    dataRef.current = data;\n  }, [data]);\n\n  const updatePermissions = useCallback(\n    (id: string, newPermissions: string[]) => {\n      // When any permission is changed, turn off the \"share entire dataroom\" toggle\n      setShareEntireDataroom(false);\n\n      const isRoot = id === \"__dataroom_root__\";\n\n      const findItemAndParents = (\n        items: FileOrFolder[],\n        targetId: string,\n        parents: FileOrFolder[] = [],\n      ): { item: FileOrFolder; parents: FileOrFolder[] } | null => {\n        for (const item of items) {\n          if (item.id === targetId) {\n            return { item, parents };\n          }\n          if (item.subItems) {\n            const result = findItemAndParents(item.subItems, targetId, [\n              ...parents,\n              item,\n            ]);\n            if (result) return result;\n          }\n        }\n        return null;\n      };\n\n      const result = findItemAndParents(dataRef.current, id);\n      if (!result) return;\n\n      const { item, parents } = result;\n\n      const updatedPermissions = {\n        view: newPermissions.includes(\"view\"),\n        download: newPermissions.includes(\"download\"),\n        partialView: newPermissions.includes(\"partialView\"),\n        partialDownload: newPermissions.includes(\"partialDownload\"),\n      };\n\n      // Special cases\n      if (!updatedPermissions.view && item.permissions.download) {\n        updatedPermissions.download = false;\n      } else if (updatedPermissions.download && !updatedPermissions.view) {\n        updatedPermissions.view = true;\n      }\n\n      if (updatedPermissions.partialDownload) {\n        updatedPermissions.download = true;\n      }\n\n      if (updatedPermissions.partialView) {\n        updatedPermissions.view = true;\n      }\n\n      // Handle root-level permissions (affects all items)\n      if (isRoot) {\n        setData((prevData) => {\n          const updateAllItems = (items: FileOrFolder[]): FileOrFolder[] => {\n            return items.map((currentItem) => {\n              if (currentItem.id === \"__dataroom_root__\") {\n                return {\n                  ...currentItem,\n                  permissions: {\n                    view: updatedPermissions.view,\n                    download: updatedPermissions.download,\n                    partialView: false,\n                    partialDownload: false,\n                  },\n                  subItems: currentItem.subItems\n                    ? updateAllItems(currentItem.subItems)\n                    : undefined,\n                };\n              }\n\n              const updatedItem = {\n                ...currentItem,\n                permissions: {\n                  view: updatedPermissions.view,\n                  download: updatedPermissions.download,\n                  partialView: false,\n                  partialDownload: false,\n                },\n                subItems: currentItem.subItems\n                  ? updateAllItems(currentItem.subItems)\n                  : undefined,\n              };\n\n              return updatedItem;\n            });\n          };\n\n          return updateAllItems(prevData);\n        });\n\n        // Collect changes for all items\n        const collectAllChanges = (items: FileOrFolder[]): ItemPermission => {\n          let changes: ItemPermission = {};\n\n          const processItems = (items: FileOrFolder[]) => {\n            items.forEach((item) => {\n              // Don't save the virtual __dataroom_root__ item to database\n              if (item.id !== \"__dataroom_root__\") {\n                changes[item.id] = {\n                  view: updatedPermissions.view,\n                  download: updatedPermissions.download,\n                  itemType: item.itemType,\n                };\n              }\n\n              if (item.subItems) {\n                processItems(item.subItems);\n              }\n            });\n          };\n\n          processItems(items);\n          return changes;\n        };\n\n        const rootChanges = collectAllChanges(dataRef.current);\n        setPendingChanges((prev) => ({\n          ...prev,\n          ...rootChanges,\n        }));\n\n        return;\n      }\n\n      setData((prevData) => {\n        const updateItemInTree = (items: FileOrFolder[]): FileOrFolder[] => {\n          return items.map((currentItem) => {\n            if (currentItem.id === id) {\n              const updatedItem = {\n                ...currentItem,\n                permissions: {\n                  view: updatedPermissions.view,\n                  download: updatedPermissions.download,\n                  partialView: false,\n                  partialDownload: false,\n                },\n              };\n\n              // If it's a folder, update all subitems\n              if (updatedItem.itemType === ItemType.DATAROOM_FOLDER) {\n                updatedItem.subItems = updateSubItems(\n                  updatedItem.subItems || [],\n                  updatedPermissions.view,\n                  updatedPermissions.download,\n                );\n              }\n\n              return updatedItem;\n            }\n\n            // if the current item is a parent of the updated item, update the parent's permissions\n            if (parents.some((parent) => parent.id === currentItem.id)) {\n              const updatedSubItems = currentItem.subItems\n                ? updateItemInTree(currentItem.subItems)\n                : [];\n              return updateParentPermissions(currentItem, updatedSubItems);\n            }\n\n            // if the current item has subitems, update the subitems\n            if (currentItem.subItems) {\n              return {\n                ...currentItem,\n                subItems: updateItemInTree(currentItem.subItems),\n              };\n            }\n            return currentItem;\n          });\n        };\n\n        const updateSubItems = (\n          items: FileOrFolder[],\n          viewState: boolean,\n          downloadState: boolean,\n        ): FileOrFolder[] => {\n          return items.map((item) => ({\n            ...item,\n            permissions: {\n              ...item.permissions,\n              view: viewState,\n              partialView: false,\n              partialDownload: false,\n              download: downloadState,\n            },\n            subItems: item.subItems\n              ? updateSubItems(item.subItems, viewState, downloadState)\n              : undefined,\n          }));\n        };\n\n        const updateParentPermissions = (\n          parent: FileOrFolder,\n          subItems: FileOrFolder[],\n        ): FileOrFolder => {\n          const isParentRoot = parent.id === \"__dataroom_root__\";\n\n          // For root folder, calculate based on all descendants\n          const calculatePermissions = (items: FileOrFolder[]) => {\n            const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {\n              return items.reduce((acc, item) => {\n                if (item.id !== \"__dataroom_root__\") {\n                  acc.push(item);\n                }\n                if (item.subItems) {\n                  acc.push(...flattenItems(item.subItems));\n                }\n                return acc;\n              }, [] as FileOrFolder[]);\n            };\n\n            const allItems = flattenItems(items);\n            const viewableItems = allItems.filter(\n              (item) => item.permissions.view,\n            );\n            const downloadableItems = allItems.filter(\n              (item) => item.permissions.download,\n            );\n\n            return {\n              view: viewableItems.length > 0,\n              partialView:\n                viewableItems.length > 0 &&\n                viewableItems.length < allItems.length,\n              download: downloadableItems.length > 0,\n              partialDownload:\n                downloadableItems.length > 0 &&\n                downloadableItems.length < allItems.length,\n            };\n          };\n\n          if (isParentRoot) {\n            const rootPermissions = calculatePermissions(subItems);\n            return {\n              ...parent,\n              permissions: rootPermissions,\n              subItems,\n            };\n          }\n\n          // For regular folders\n          const someSubItemViewable = subItems.some(\n            (subItem) => subItem.permissions.view,\n          );\n          const allSubItemsViewable = subItems.every(\n            (subItem) => subItem.permissions.view,\n          );\n          const someSubItemDownloadable = subItems.some(\n            (subItem) => subItem.permissions.download,\n          );\n          const allSubItemsDownloadable = subItems.every(\n            (subItem) => subItem.permissions.download,\n          );\n\n          return {\n            ...parent,\n            permissions: {\n              view: someSubItemViewable,\n              partialView: someSubItemViewable && !allSubItemsViewable,\n              download: someSubItemDownloadable,\n              partialDownload:\n                someSubItemDownloadable && !allSubItemsDownloadable,\n            },\n            subItems,\n          };\n        };\n\n        return updateItemInTree(prevData);\n      });\n\n      // Collect changes for database update\n      const collectChanges = (\n        item: FileOrFolder,\n        parents: FileOrFolder[],\n      ): ItemPermission => {\n        let changes: ItemPermission = {};\n\n        // Don't save the virtual __dataroom_root__ item to database\n        if (item.id !== \"__dataroom_root__\") {\n          changes[item.id] = {\n            view: updatedPermissions.view,\n            download: updatedPermissions.download,\n            itemType: item.itemType,\n          };\n        }\n\n        // Collect changes for all subitems\n        const collectSubItemChanges = (\n          subItems: FileOrFolder[] | undefined,\n        ) => {\n          if (!subItems) return;\n          subItems.forEach((subItem) => {\n            // Don't save the virtual __dataroom_root__ item to database\n            if (subItem.id !== \"__dataroom_root__\") {\n              changes[subItem.id] = {\n                view: updatedPermissions.view,\n                download: updatedPermissions.download,\n                itemType: subItem.itemType,\n              };\n            }\n            collectSubItemChanges(subItem.subItems);\n          });\n        };\n\n        collectSubItemChanges(item.subItems);\n\n        // Ensure all parent folders are viewable if the item is being set to viewable\n        if (updatedPermissions.view || updatedPermissions.download) {\n          parents.forEach((parent) => {\n            // Don't save the virtual __dataroom_root__ item to database\n            if (parent.id !== \"__dataroom_root__\") {\n              changes[parent.id] = {\n                view: true,\n                download: updatedPermissions.download\n                  ? true\n                  : parent.permissions.download,\n                itemType: parent.itemType,\n              };\n            }\n          });\n        } else {\n          // If turning off view, recalculate parent permissions\n          [...parents].reverse().forEach((parent) => {\n            // Don't save the virtual __dataroom_root__ item to database\n            if (parent.id !== \"__dataroom_root__\") {\n              const otherChildren =\n                parent.subItems?.filter((subItem) => subItem.id !== item.id) ||\n                [];\n              const someSubItemViewable = otherChildren.some(\n                (subItem) => subItem.permissions.view,\n              );\n              const someSubItemDownloadable = otherChildren.some(\n                (subItem) => subItem.permissions.download,\n              );\n\n              changes[parent.id] = {\n                view: someSubItemViewable,\n                download: someSubItemDownloadable,\n                itemType: parent.itemType,\n              };\n            }\n          });\n        }\n\n        return changes;\n      };\n\n      setPendingChanges((prev) => ({\n        ...prev,\n        ...collectChanges(item, parents),\n      }));\n    },\n    [], // Remove data dependency to prevent excessive re-renders\n  );\n\n  useEffect(() => {\n    if (folders && !loading) {\n      const treeData = buildTreeWithRoot(\n        folders,\n        effectivePermissions,\n        \"Dataroom Home\",\n      );\n      setData(treeData);\n    }\n  }, [folders, loading, effectivePermissions]); // Add effectivePermissions as dependency\n\n  // Reset settings when sheet opens for a new link\n  useEffect(() => {\n    if (isOpen) {\n      // Reset to default state for new links\n      setShareEntireDataroom(effectivePermissions.length === 0);\n      setPendingChanges({});\n\n      // For new links (no existing permissions), default to \"share entire dataroom\" on\n      // But if they turn it off, they'll start with no permissions selected (opt-in approach)\n      // For existing links, show the current permissions state\n      if (effectivePermissions.length === 0) {\n        // New link - start with full access shown but toggle is on\n        setData((prevData) => {\n          const resetToFullAccess = (items: FileOrFolder[]): FileOrFolder[] => {\n            return items.map((item) => ({\n              ...item,\n              permissions: {\n                view: true,\n                download: false,\n                partialView: false,\n                partialDownload: false,\n              },\n              subItems: item.subItems\n                ? resetToFullAccess(item.subItems)\n                : undefined,\n            }));\n          };\n\n          return resetToFullAccess(prevData);\n        });\n      }\n    }\n  }, [isOpen, effectivePermissions.length]);\n\n  // Handle the \"share entire dataroom\" toggle\n  const handleShareEntireDataroomToggle = (checked: boolean) => {\n    setShareEntireDataroom(checked);\n    if (checked) {\n      // Clear pending changes when sharing entire dataroom\n      setPendingChanges({});\n      // Reset UI to show full access (but don't save to database)\n      setData((prevData) => {\n        const updateAllItems = (items: FileOrFolder[]): FileOrFolder[] => {\n          return items.map((item) => {\n            return {\n              ...item,\n              permissions: {\n                view: true,\n                download: false,\n                partialView: false,\n                partialDownload: false,\n              },\n              subItems: item.subItems\n                ? updateAllItems(item.subItems)\n                : undefined,\n            };\n          });\n        };\n\n        return updateAllItems(prevData);\n      });\n    } else {\n      // When turning off, reset all permissions to false so user must opt-in\n      setPendingChanges({});\n      setData((prevData) => {\n        const resetToNoAccess = (items: FileOrFolder[]): FileOrFolder[] => {\n          return items.map((item) => {\n            return {\n              ...item,\n              permissions: {\n                view: false,\n                download: false,\n                partialView: false,\n                partialDownload: false,\n              },\n              subItems: item.subItems\n                ? resetToNoAccess(item.subItems)\n                : undefined,\n            };\n          });\n        };\n\n        return resetToNoAccess(prevData);\n      });\n    }\n  };\n\n  const handleSave = async () => {\n    setIsSaving(true);\n    try {\n      // If sharing entire dataroom, pass null to indicate no specific permissions\n      if (shareEntireDataroom) {\n        await onSave(null);\n      } else {\n        // Merge existing permissions with pending changes\n        const completePermissions: ItemPermission = {};\n\n        // First, add all existing permissions\n        effectivePermissions.forEach((permission) => {\n          completePermissions[permission.itemId] = {\n            view: permission.canView,\n            download: permission.canDownload,\n            itemType: permission.itemType,\n          };\n        });\n\n        // Then, apply pending changes (this will override existing permissions for changed items)\n        Object.keys(pendingChanges).forEach((itemId) => {\n          completePermissions[itemId] = pendingChanges[itemId];\n        });\n\n        await onSave(completePermissions);\n      }\n\n      setIsOpen(false);\n      // Clear pending changes after the sheet is closed to avoid UI flickering\n      setPendingChanges({});\n    } catch (error) {\n      console.error(\"Error saving permissions:\", error);\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const columns = useMemo(\n    () => createColumns({ updatePermissions }),\n    [updatePermissions],\n  );\n\n  const table = useReactTable({\n    data,\n    columns,\n    getCoreRowModel: getCoreRowModel(),\n    getExpandedRowModel: getExpandedRowModel(),\n    getSubRows: (row) => row.subItems,\n    initialState: {\n      expanded: {\n        \"0\": true, // Always expand the root folder (first row)\n      },\n    },\n    getRowCanExpand: (row) => {\n      // Root folder is always expanded and cannot be collapsed\n      if (row.original.id === \"__dataroom_root__\") {\n        return true;\n      }\n      return (row.subRows?.length ?? 0) > 0;\n    },\n  });\n\n  if (loading) return null;\n\n  return (\n    <Sheet open={isOpen} onOpenChange={setIsOpen}>\n      <SheetContent className=\"flex w-[90%] flex-col justify-between border-l border-gray-200 bg-background px-4 text-foreground dark:border-gray-800 dark:bg-gray-900 sm:w-[780px] sm:max-w-3xl md:px-5\">\n        <SheetHeader className=\"text-start\">\n          <SheetTitle>Manage File Permissions</SheetTitle>\n          <p className=\"text-sm text-muted-foreground\">\n            Use the toggle below to share all dataroom contents or set specific\n            permissions.\n          </p>\n        </SheetHeader>\n\n        <div className=\"flex-1 overflow-auto py-4\">\n          {/* Share Entire Dataroom Toggle */}\n          <div className=\"mb-6 rounded-lg border bg-card p-4\">\n            <div className=\"flex items-center justify-between\">\n              <div className=\"space-y-1\">\n                <div className=\"flex items-center gap-2\">\n                  <h4 className=\"text-sm font-medium\">Share Entire Dataroom</h4>\n                  {!canSetCustomPermissions && (\n                    <UpgradePlanModal\n                      clickedPlan={PlanEnum.DataRooms}\n                      trigger=\"dataroom_permissions_sheet_toggle\"\n                    >\n                      <button\n                        type=\"button\"\n                        className=\"inline-flex cursor-pointer rounded-md transition-colors hover:bg-muted/50\"\n                      >\n                        <PlanBadge plan={PlanEnum.DataRooms} />\n                      </button>\n                    </UpgradePlanModal>\n                  )}\n                </div>\n                <p className=\"text-sm text-muted-foreground\">\n                  Turn off to set specific permissions for individual items.\n                  When turned off, no items are selected by default - you must\n                  choose what to share.\n                </p>\n              </div>\n              {!canSetCustomPermissions ? (\n                <Switch\n                  checked={true}\n                  className=\"cursor-not-allowed opacity-50\"\n                  onCheckedChange={undefined}\n                />\n              ) : (\n                <Switch\n                  checked={shareEntireDataroom}\n                  onCheckedChange={handleShareEntireDataroomToggle}\n                />\n              )}\n            </div>\n          </div>\n\n          {/* Permissions Table */}\n          <div\n            className={cn(\n              \"rounded-md border\",\n              (shareEntireDataroom || !canSetCustomPermissions) && \"opacity-50\",\n            )}\n          >\n            <Table>\n              <TableHeader>\n                {table.getHeaderGroups().map((headerGroup) => (\n                  <TableRow key={headerGroup.id}>\n                    {headerGroup.headers.map((header) => (\n                      <TableHead\n                        key={header.id}\n                        className=\"py-2 last:text-right\"\n                      >\n                        {header.isPlaceholder\n                          ? null\n                          : flexRender(\n                              header.column.columnDef.header,\n                              header.getContext(),\n                            )}\n                      </TableHead>\n                    ))}\n                  </TableRow>\n                ))}\n              </TableHeader>\n              <TableBody className=\"transition-all duration-200 ease-in-out\">\n                {table.getRowModel().rows?.length ? (\n                  table.getRowModel().rows.map((row) => {\n                    const isRoot = row.original.id === \"__dataroom_root__\";\n                    return (\n                      <TableRow\n                        key={row.id}\n                        data-state={row.getIsSelected() && \"selected\"}\n                        className={cn(\n                          \"transition-all duration-200 ease-in-out\",\n                          isRoot && \"bg-blue-50/50 dark:bg-blue-950/50\",\n                        )}\n                      >\n                        {row.getVisibleCells().map((cell, index) => (\n                          <TableCell\n                            key={cell.id}\n                            style={\n                              index === 0\n                                ? {\n                                    paddingLeft: `${row.depth * 1.25}rem`,\n                                  }\n                                : undefined\n                            }\n                            className=\"py-2 last:flex last:justify-end\"\n                          >\n                            <div\n                              className={cn(\n                                (shareEntireDataroom ||\n                                  !canSetCustomPermissions) &&\n                                  \"pointer-events-none\",\n                              )}\n                            >\n                              {flexRender(\n                                cell.column.columnDef.cell,\n                                cell.getContext(),\n                              )}\n                            </div>\n                          </TableCell>\n                        ))}\n                      </TableRow>\n                    );\n                  })\n                ) : (\n                  <TableRow>\n                    <TableCell\n                      colSpan={columns.length}\n                      className=\"h-24 text-center\"\n                    >\n                      No files found.\n                    </TableCell>\n                  </TableRow>\n                )}\n              </TableBody>\n            </Table>\n          </div>\n        </div>\n\n        <SheetFooter>\n          <div className=\"flex flex-row-reverse items-center gap-2 pt-2\">\n            <Button\n              type=\"button\"\n              loading={isSaving}\n              onClick={handleSave}\n              disabled={\n                shareEntireDataroom\n                  ? false\n                  : Object.keys(pendingChanges).length === 0\n              }\n            >\n              Save Permissions\n            </Button>\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={() => setIsOpen(false)}\n            >\n              Cancel\n            </Button>\n          </div>\n        </SheetFooter>\n      </SheetContent>\n    </Sheet>\n  );\n}\n"
  },
  {
    "path": "ee/features/security/index.ts",
    "content": "export * from \"./lib/ratelimit\";\nexport * from \"./lib/fraud-prevention\";\n"
  },
  {
    "path": "ee/features/security/lib/fraud-prevention.ts",
    "content": "import { NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { get } from \"@vercel/edge-config\";\nimport { Stripe } from \"stripe\";\n\nimport { log } from \"@/lib/utils\";\n\n/**\n * High-risk decline codes that indicate potential fraud\n */\nconst FRAUD_DECLINE_CODES = [\n  \"fraudulent\",\n  \"stolen_card\",\n  \"pickup_card\",\n  \"restricted_card\",\n  \"security_violation\",\n];\n\n/**\n * Add email to Stripe Radar value list for blocking\n */\nexport async function addEmailToStripeRadar(email: string): Promise<boolean> {\n  try {\n    const stripeClient = stripeInstance();\n    await stripeClient.radar.valueListItems.create({\n      value_list: process.env.STRIPE_LIST_ID!,\n      value: email,\n    });\n\n    log({\n      message: `Added email ${email} to Stripe Radar blocklist`,\n      type: \"info\",\n    });\n    return true;\n  } catch (error) {\n    log({\n      message: `Failed to add email ${email} to Stripe Radar: ${error}`,\n      type: \"error\",\n    });\n    return false;\n  }\n}\n\n/**\n * Add email to Vercel Edge Config blocklist\n */\nexport async function addEmailToEdgeConfig(email: string): Promise<boolean> {\n  try {\n    // 1. Read current emails from Edge Config\n    const currentEmails = (await get(\"emails\")) || [];\n\n    // Check if email already exists\n    if (Array.isArray(currentEmails) && currentEmails.includes(email)) {\n      log({\n        message: `Email ${email} already in Edge Config blocklist`,\n        type: \"info\",\n      });\n      return true;\n    }\n\n    // 2. Add new email\n    const updatedEmails = Array.isArray(currentEmails)\n      ? [...currentEmails, email]\n      : [email];\n\n    // 3. Update via Vercel REST API\n    const response = await fetch(\n      `https://api.vercel.com/v1/edge-config/${process.env.EDGE_CONFIG_ID}/items`,\n      {\n        method: \"PATCH\",\n        headers: {\n          Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          items: [\n            {\n              operation: \"update\",\n              key: \"emails\",\n              value: updatedEmails,\n            },\n          ],\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      throw new Error(`Vercel API error: ${response.status}`);\n    }\n\n    log({\n      message: `Added email ${email} to Edge Config blocklist`,\n      type: \"info\",\n    });\n    return true;\n  } catch (error) {\n    log({\n      message: `Failed to add email to Edge Config: ${error}`,\n      type: \"error\",\n    });\n    return false;\n  }\n}\n\n/**\n * Process Stripe payment failure for fraud indicators\n */\nexport async function processPaymentFailure(\n  event: Stripe.Event,\n): Promise<void> {\n  const paymentFailure = event.data.object as Stripe.PaymentIntent;\n  const email = paymentFailure.receipt_email;\n  const declineCode = paymentFailure.last_payment_error?.decline_code;\n\n  if (!email || !declineCode) {\n    return;\n  }\n\n  // Check if decline code indicates fraud\n  if (FRAUD_DECLINE_CODES.includes(declineCode)) {\n    log({\n      message: `Fraud indicator detected: ${declineCode} for email: ${email}`,\n      type: \"info\",\n    });\n\n    // Add to both Stripe Radar and Edge Config in parallel\n    const [stripeResult, edgeConfigResult] = await Promise.allSettled([\n      addEmailToStripeRadar(email),\n      addEmailToEdgeConfig(email),\n    ]);\n\n    // Log results\n    if (stripeResult.status === \"fulfilled\" && stripeResult.value) {\n      log({\n        message: `Successfully added ${email} to Stripe Radar`,\n        type: \"info\",\n      });\n    } else {\n      log({\n        message: `Failed to add ${email} to Stripe Radar:`,\n        type: \"error\",\n      });\n    }\n\n    if (edgeConfigResult.status === \"fulfilled\" && edgeConfigResult.value) {\n      log({\n        message: `Successfully added ${email} to Edge Config`,\n        type: \"info\",\n      });\n    } else {\n      log({\n        message: `Failed to add ${email} to Edge Config:`,\n        type: \"error\",\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "ee/features/security/lib/ratelimit.ts",
    "content": "import { Ratelimit } from \"@upstash/ratelimit\";\n\nimport { redis } from \"@/lib/redis\";\n\n/**\n * Simple rate limiters for core endpoints\n */\nexport const rateLimiters = {\n  // 3 auth attempts per hour per IP\n  auth: new Ratelimit({\n    redis,\n    limiter: Ratelimit.slidingWindow(10, \"20 m\"),\n    prefix: \"rl:auth\",\n    enableProtection: true,\n    analytics: true,\n  }),\n\n  // 5 billing operations per hour per IP\n  billing: new Ratelimit({\n    redis,\n    limiter: Ratelimit.slidingWindow(10, \"20 m\"),\n    prefix: \"rl:billing\",\n    enableProtection: true,\n    analytics: true,\n  }),\n};\n\n/**\n * Apply rate limiting with error handling\n */\nexport async function checkRateLimit(\n  limiter: Ratelimit,\n  identifier: string,\n): Promise<{ success: boolean; remaining?: number; error?: string }> {\n  try {\n    const result = await limiter.limit(identifier);\n    return {\n      success: result.success,\n      remaining: result.remaining,\n    };\n  } catch (error) {\n    console.error(\"Rate limiting error:\", error);\n    // Fail open - allow request if rate limiting fails\n    return { success: true, error: \"Rate limiting unavailable\" };\n  }\n}\n"
  },
  {
    "path": "ee/features/security/sso/components/directory-sync-config-modal.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { Copy, Eye, EyeOff, FolderSync, Trash2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport useSCIM from \"@/lib/swr/use-scim\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\nimport { SAML_PROVIDERS, type SCIMProviderKey } from \"../constants\";\n\ninterface DirectorySyncConfigModalProps {\n  teamId: string;\n}\n\nexport function DirectorySyncConfigModal({\n  teamId,\n}: DirectorySyncConfigModalProps) {\n  const { directories, configured, mutate, loading } = useSCIM();\n\n  const [open, setOpen] = useState(false);\n  const [submitting, setSubmitting] = useState(false);\n  const [selectedProvider, setSelectedProvider] = useState<\n    SCIMProviderKey | undefined\n  >();\n  const [showBearerToken, setShowBearerToken] = useState(false);\n\n  const currentProvider = SAML_PROVIDERS.find(\n    (p) => p.scim === selectedProvider,\n  );\n\n  async function handleSubmit(e: React.FormEvent) {\n    e.preventDefault();\n    setSubmitting(true);\n\n    try {\n      const res = await fetch(`/api/teams/${teamId}/directory-sync`, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          type: selectedProvider,\n          ...(configured && { currentDirectoryId: directories[0]?.id }),\n        }),\n      });\n\n      if (!res.ok) {\n        const data = await res.json();\n        throw new Error(data.error || \"Failed to create directory sync\");\n      }\n\n      toast.success(\"Directory sync configured successfully!\");\n      await mutate();\n    } catch (error: any) {\n      toast.error(error.message || \"Failed to configure directory sync\");\n    } finally {\n      setSubmitting(false);\n    }\n  }\n\n  async function handleDelete(directoryId: string) {\n    if (\n      !confirm(\n        \"Are you sure you want to remove this directory sync connection? User provisioning will stop.\",\n      )\n    ) {\n      return;\n    }\n\n    try {\n      const res = await fetch(`/api/teams/${teamId}/directory-sync`, {\n        method: \"DELETE\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ directoryId }),\n      });\n\n      if (!res.ok) {\n        const data = await res.json();\n        throw new Error(data.error || \"Failed to delete directory sync\");\n      }\n\n      toast.success(\"Directory sync connection removed\");\n      await mutate();\n    } catch (error: any) {\n      toast.error(\n        error.message || \"Failed to remove directory sync connection\",\n      );\n    }\n  }\n\n  function copyToClipboard(text: string, label: string) {\n    navigator.clipboard.writeText(text);\n    toast.success(`${label} copied to clipboard`);\n  }\n\n  if (loading) {\n    return (\n      <div className=\"flex items-center justify-center py-8\">\n        <div className=\"text-sm text-muted-foreground\">\n          Loading directory sync configuration...\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Existing directories */}\n      {directories.map((dir: any) => (\n        <div\n          key={dir.id}\n          className=\"space-y-3 rounded-lg border p-4\"\n        >\n          <div className=\"flex items-center justify-between\">\n            <div className=\"space-y-1\">\n              <div className=\"flex items-center space-x-2\">\n                <FolderSync className=\"h-4 w-4 text-green-600\" />\n                <span className=\"text-sm font-medium\">\n                  {dir.name || \"Directory Sync\"}\n                </span>\n              </div>\n              <p className=\"text-xs text-muted-foreground\">\n                Type: {dir.type}\n              </p>\n            </div>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"text-destructive hover:text-destructive\"\n              onClick={() => handleDelete(dir.id)}\n            >\n              <Trash2 className=\"h-4 w-4\" />\n            </Button>\n          </div>\n\n          {/* SCIM endpoint + token */}\n          {dir.scim && (\n            <div className=\"space-y-2 rounded-md border bg-muted/50 p-3\">\n              <div>\n                <Label className=\"text-xs font-medium\">SCIM Base URL</Label>\n                <div className=\"mt-1 flex items-center justify-between rounded-md border bg-white px-3 py-1.5\">\n                  <p className=\"overflow-hidden text-ellipsis whitespace-nowrap text-xs text-muted-foreground\">\n                    {dir.scim.endpoint}\n                  </p>\n                  <button\n                    type=\"button\"\n                    onClick={() =>\n                      copyToClipboard(dir.scim.endpoint, \"SCIM Base URL\")\n                    }\n                    className=\"ml-2 shrink-0\"\n                  >\n                    <Copy className=\"h-3 w-3 text-muted-foreground\" />\n                  </button>\n                </div>\n              </div>\n\n              <div>\n                <Label className=\"text-xs font-medium\">Bearer Token</Label>\n                <div className=\"mt-1 flex items-center justify-between rounded-md border bg-white px-3 py-1.5\">\n                  <input\n                    type={showBearerToken ? \"text\" : \"password\"}\n                    readOnly\n                    className=\"w-full border-none bg-transparent p-0 text-xs focus:outline-none focus:ring-0\"\n                    value={dir.scim.secret}\n                  />\n                  <div className=\"flex shrink-0 space-x-1.5 pl-2\">\n                    <button\n                      type=\"button\"\n                      onClick={() =>\n                        copyToClipboard(dir.scim.secret, \"Bearer Token\")\n                      }\n                    >\n                      <Copy className=\"h-3 w-3 text-muted-foreground\" />\n                    </button>\n                    <button\n                      type=\"button\"\n                      onClick={() => setShowBearerToken(!showBearerToken)}\n                    >\n                      {showBearerToken ? (\n                        <Eye className=\"h-3 w-3 text-muted-foreground\" />\n                      ) : (\n                        <EyeOff className=\"h-3 w-3 text-muted-foreground\" />\n                      )}\n                    </button>\n                  </div>\n                </div>\n              </div>\n            </div>\n          )}\n        </div>\n      ))}\n\n      {/* Add new directory dialog */}\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>\n          <Button\n            className=\"w-full\"\n            variant={configured ? \"outline\" : \"default\"}\n          >\n            <FolderSync className=\"mr-2 h-4 w-4\" />\n            {configured\n              ? \"Replace Directory Provider\"\n              : \"Configure Directory Sync\"}\n          </Button>\n        </DialogTrigger>\n        <DialogContent className=\"sm:max-w-lg\">\n          <DialogHeader>\n            <DialogTitle>Configure SCIM Directory Sync</DialogTitle>\n            <DialogDescription>\n              Select your directory provider to enable automatic user\n              provisioning and deprovisioning.\n            </DialogDescription>\n          </DialogHeader>\n\n          <form onSubmit={handleSubmit} className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <Label>Directory Provider</Label>\n              <Select\n                value={selectedProvider}\n                onValueChange={(v) =>\n                  setSelectedProvider(v as SCIMProviderKey)\n                }\n              >\n                <SelectTrigger>\n                  <SelectValue placeholder=\"Select a provider\" />\n                </SelectTrigger>\n                <SelectContent>\n                  {SAML_PROVIDERS.map((p) => (\n                    <SelectItem key={p.scim} value={p.scim}>\n                      {p.name}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n\n            {currentProvider && (\n              <div className=\"rounded-lg border bg-muted/30 p-3 text-xs text-muted-foreground\">\n                <p>\n                  After creating the directory, you&apos;ll receive a SCIM Base\n                  URL and Bearer Token. Configure these in your{\" \"}\n                  {currentProvider.name} admin console.\n                </p>\n              </div>\n            )}\n\n            <DialogFooter>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                onClick={() => setOpen(false)}\n              >\n                Cancel\n              </Button>\n              <Button\n                type=\"submit\"\n                loading={submitting}\n                disabled={submitting || !selectedProvider}\n              >\n                {configured ? \"Replace & Configure\" : \"Create Directory\"}\n              </Button>\n            </DialogFooter>\n          </form>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/security/sso/components/saml-config-modal.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { Copy, Shield, Trash2, UploadCloud } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport useSAML from \"@/lib/swr/use-saml\";\nimport { copyToClipboard } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\nimport { type SAMLProviderKey, SAML_PROVIDERS } from \"../constants\";\n\ninterface SAMLConfigModalProps {\n  teamId: string;\n}\n\nexport function SAMLConfigModal({ teamId }: SAMLConfigModalProps) {\n  const { connections, issuer, acs, mutate, loading } = useSAML();\n\n  const [open, setOpen] = useState(false);\n  const [submitting, setSubmitting] = useState(false);\n  const [selectedProvider, setSelectedProvider] = useState<\n    SAMLProviderKey | undefined\n  >();\n  const [metadataUrl, setMetadataUrl] = useState(\"\");\n  const [domain, setDomain] = useState(\"\");\n  const [file, setFile] = useState<File | null>(null);\n  const [fileContent, setFileContent] = useState(\"\");\n\n  const currentProvider = SAML_PROVIDERS.find(\n    (p) => p.saml === selectedProvider,\n  );\n\n  async function handleSubmit(e: React.FormEvent) {\n    e.preventDefault();\n    setSubmitting(true);\n\n    try {\n      const body: Record<string, string> = {};\n\n      if (selectedProvider === \"google\") {\n        if (!fileContent) {\n          toast.error(\"Please upload the XML metadata file\");\n          setSubmitting(false);\n          return;\n        }\n        body.encodedRawMetadata = btoa(fileContent);\n      } else {\n        if (!metadataUrl) {\n          toast.error(\"Please enter a metadata URL\");\n          setSubmitting(false);\n          return;\n        }\n        body.metadataUrl = metadataUrl;\n      }\n\n      // Include the explicit SSO email domain if provided by the admin\n      if (domain.trim()) {\n        body.domain = domain.trim();\n      }\n\n      const res = await fetch(`/api/teams/${teamId}/saml`, {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify(body),\n      });\n\n      if (!res.ok) {\n        const data = await res.json();\n        throw new Error(data.error || \"Failed to create SAML connection\");\n      }\n\n      toast.success(\"SAML SSO configured successfully!\");\n      setOpen(false);\n      setMetadataUrl(\"\");\n      setDomain(\"\");\n      setFile(null);\n      setFileContent(\"\");\n      setSelectedProvider(undefined);\n      await mutate();\n    } catch (error: any) {\n      toast.error(error.message || \"Failed to configure SAML SSO\");\n    } finally {\n      setSubmitting(false);\n    }\n  }\n\n  async function handleDelete(conn: any) {\n    if (\n      !confirm(\n        \"Are you sure you want to remove this SAML connection? Users will no longer be able to sign in via SSO.\",\n      )\n    ) {\n      return;\n    }\n\n    try {\n      const res = await fetch(`/api/teams/${teamId}/saml`, {\n        method: \"DELETE\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          clientID: conn.clientID,\n          clientSecret: conn.clientSecret,\n        }),\n      });\n\n      if (!res.ok) {\n        const data = await res.json();\n        throw new Error(data.error || \"Failed to delete SAML connection\");\n      }\n\n      toast.success(\"SAML connection removed\");\n      await mutate();\n    } catch (error: any) {\n      toast.error(error.message || \"Failed to remove SAML connection\");\n    }\n  }\n\n\n  if (loading) {\n    return (\n      <div className=\"flex items-center justify-center py-8\">\n        <div className=\"text-sm text-muted-foreground\">\n          Loading SAML configuration...\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"space-y-4\">\n      {/* Existing connections */}\n      {connections.length > 0 && (\n        <div className=\"space-y-3\">\n          {connections.map((conn: any) => (\n            <div\n              key={conn.clientID}\n              className=\"flex items-center justify-between rounded-lg border p-4\"\n            >\n              <div className=\"space-y-1\">\n                <div className=\"flex items-center space-x-2\">\n                  <Shield className=\"h-4 w-4 text-green-600\" />\n                  <span className=\"text-sm font-medium\">\n                    {conn.idpMetadata?.provider || \"SAML Connection\"}\n                  </span>\n                </div>\n                <p className=\"text-xs text-muted-foreground\">\n                  Client ID: {conn.clientID}\n                </p>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"text-destructive hover:text-destructive\"\n                onClick={() => handleDelete(conn)}\n              >\n                <Trash2 className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ))}\n        </div>\n      )}\n\n      {/* IdP Configuration Info */}\n      {connections.length > 0 && (\n        <div className=\"rounded-lg border bg-muted/50 p-4\">\n          <h4 className=\"mb-2 text-sm font-medium\">\n            Identity Provider Configuration\n          </h4>\n          <p className=\"mb-3 text-xs text-muted-foreground\">\n            Use these values when configuring your Identity Provider:\n          </p>\n          <div className=\"space-y-2\">\n            <div className=\"flex items-center justify-between\">\n              <div>\n                <Label className=\"text-xs font-medium\">\n                  Entity ID (Identifier)\n                </Label>\n                <p className=\"text-xs text-muted-foreground\">{issuer}</p>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => copyToClipboard(issuer, \"Entity ID copied to clipboard\")}\n              >\n                <Copy className=\"h-3 w-3\" />\n              </Button>\n            </div>\n            <div className=\"flex items-center justify-between\">\n              <div>\n                <Label className=\"text-xs font-medium\">\n                  Reply URL (ACS URL)\n                </Label>\n                <p className=\"text-xs text-muted-foreground\">{acs}</p>\n              </div>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => copyToClipboard(acs, \"ACS URL copied to clipboard\")}\n              >\n                <Copy className=\"h-3 w-3\" />\n              </Button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Add new connection dialog */}\n      <Dialog open={open} onOpenChange={setOpen}>\n        <DialogTrigger asChild>\n          <Button\n            className=\"w-full\"\n            variant={connections.length > 0 ? \"outline\" : \"default\"}\n          >\n            <Shield className=\"mr-2 h-4 w-4\" />\n            {connections.length > 0\n              ? \"Add Another Connection\"\n              : \"Configure SAML SSO\"}\n          </Button>\n        </DialogTrigger>\n        <DialogContent className=\"sm:max-w-lg\">\n          <DialogHeader>\n            <DialogTitle>Configure SAML Single Sign-On</DialogTitle>\n            <DialogDescription>\n              Connect your Identity Provider to enable SSO for your team.\n            </DialogDescription>\n          </DialogHeader>\n\n          <form onSubmit={handleSubmit} className=\"space-y-4\">\n            {/* Step 1: Show ACS URL and Entity ID */}\n            <div className=\"rounded-lg border bg-muted/50 p-3\">\n              <p className=\"mb-2 text-xs font-medium text-muted-foreground\">\n                Step 1: Add these to your IdP&apos;s SAML configuration\n              </p>\n              <div className=\"space-y-2\">\n                <div className=\"flex items-center justify-between text-xs\">\n                  <span className=\"font-medium\">Entity ID:</span>\n                  <div className=\"flex items-center space-x-1\">\n                    <code className=\"rounded bg-muted px-1.5 py-0.5 text-[10px]\">\n                      {issuer || \"https://saml.papermark.com\"}\n                    </code>\n                    <button\n                      type=\"button\"\n                      onClick={() =>\n                        copyToClipboard(\n                          issuer || \"https://saml.papermark.com\",\n                          \"Entity ID copied to clipboard\",\n                        )\n                      }\n                      className=\"text-muted-foreground hover:text-foreground\"\n                    >\n                      <Copy className=\"h-3 w-3\" />\n                    </button>\n                  </div>\n                </div>\n                <div className=\"flex items-center justify-between text-xs\">\n                  <span className=\"font-medium\">ACS URL:</span>\n                  <div className=\"flex items-center space-x-1\">\n                    <code className=\"rounded bg-muted px-1.5 py-0.5 text-[10px]\">\n                      {acs ||\n                        `${window.location.origin}/api/auth/saml/callback`}\n                    </code>\n                    <button\n                      type=\"button\"\n                      onClick={() =>\n                        copyToClipboard(\n                          acs ||\n                            `${window.location.origin}/api/auth/saml/callback`,\n                          \"ACS URL copied to clipboard\",\n                        )\n                      }\n                      className=\"text-muted-foreground hover:text-foreground\"\n                    >\n                      <Copy className=\"h-3 w-3\" />\n                    </button>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            {/* Step 2: Provider selection */}\n            <div className=\"space-y-2\">\n              <Label>Step 2: Select SAML Provider</Label>\n              <Select\n                value={selectedProvider}\n                onValueChange={(v) => setSelectedProvider(v as SAMLProviderKey)}\n              >\n                <SelectTrigger>\n                  <SelectValue placeholder=\"Select a provider\" />\n                </SelectTrigger>\n                <SelectContent>\n                  {SAML_PROVIDERS.map((p) => (\n                    <SelectItem key={p.saml} value={p.saml}>\n                      {p.name}\n                    </SelectItem>\n                  ))}\n                </SelectContent>\n              </Select>\n            </div>\n\n            {/* Step 3: Metadata input */}\n            {currentProvider && (\n              <div className=\"space-y-2\">\n                <Label>Step 3: {currentProvider.samlModalCopy}</Label>\n\n                {selectedProvider === \"google\" ? (\n                  <label\n                    htmlFor=\"metadataRaw\"\n                    className=\"group relative flex h-24 w-full cursor-pointer flex-col items-center justify-center rounded-md border border-dashed bg-muted/30 transition-all hover:bg-muted/50\"\n                  >\n                    {file ? (\n                      <>\n                        <UploadCloud className=\"h-5 w-5 text-green-600\" />\n                        <p className=\"mt-2 text-sm text-muted-foreground\">\n                          {file.name}\n                        </p>\n                      </>\n                    ) : (\n                      <>\n                        <UploadCloud className=\"h-5 w-5 text-muted-foreground\" />\n                        <p className=\"mt-2 text-sm text-muted-foreground\">\n                          Upload .xml metadata file\n                        </p>\n                      </>\n                    )}\n                    <input\n                      id=\"metadataRaw\"\n                      type=\"file\"\n                      accept=\"text/xml,.xml\"\n                      className=\"sr-only\"\n                      onChange={(e) => {\n                        const f = e.target?.files?.[0];\n                        setFile(f || null);\n                        if (f) {\n                          const reader = new FileReader();\n                          reader.onload = (ev) => {\n                            setFileContent(ev.target?.result as string);\n                          };\n                          reader.readAsText(f);\n                        }\n                      }}\n                    />\n                  </label>\n                ) : (\n                  <Input\n                    placeholder={\n                      selectedProvider === \"azure\"\n                        ? \"https://login.microsoftonline.com/.../federationmetadata/...\"\n                        : \"https://your-idp.example.com/metadata\"\n                    }\n                    value={metadataUrl}\n                    onChange={(e) => setMetadataUrl(e.target.value)}\n                    disabled={submitting}\n                  />\n                )}\n              </div>\n            )}\n\n            {/* Step 4: SSO Email Domain */}\n            {currentProvider && (\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"ssoDomain\">\n                  Step 4: SSO Email Domain\n                </Label>\n                <Input\n                  id=\"ssoDomain\"\n                  placeholder=\"example.com\"\n                  value={domain}\n                  onChange={(e) => setDomain(e.target.value)}\n                  disabled={submitting}\n                />\n                <p className=\"text-xs text-muted-foreground\">\n                  The email domain used by your organization (e.g.,\n                  example.com). Only users with this domain will be able to\n                  sign in via SSO.\n                </p>\n              </div>\n            )}\n\n            <DialogFooter>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                onClick={() => setOpen(false)}\n              >\n                Cancel\n              </Button>\n              <Button\n                type=\"submit\"\n                loading={submitting}\n                disabled={submitting || !selectedProvider}\n              >\n                Save Configuration\n              </Button>\n            </DialogFooter>\n          </form>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/security/sso/components/sso-enforcement-toggle.tsx",
    "content": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { AlertTriangle, Lock, LockOpen } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport useSAML from \"@/lib/swr/use-saml\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"@/components/ui/dialog\";\n\ninterface SSOEnforcementToggleProps {\n  teamId: string;\n}\n\nexport function SSOEnforcementToggle({ teamId }: SSOEnforcementToggleProps) {\n  const { configured, ssoEmailDomain, ssoEnforcedAt, slug, mutate, loading } =\n    useSAML();\n  const [submitting, setSubmitting] = useState(false);\n  const [confirmOpen, setConfirmOpen] = useState(false);\n\n  const isEnforced = !!ssoEnforcedAt;\n\n  async function toggleEnforcement(enforced: boolean) {\n    setSubmitting(true);\n\n    try {\n      const res = await fetch(`/api/teams/${teamId}/saml`, {\n        method: \"PATCH\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ enforced }),\n      });\n\n      if (!res.ok) {\n        const data = await res.json();\n        throw new Error(data.error || \"Failed to update enforcement\");\n      }\n\n      toast.success(\n        enforced\n          ? `SSO is now enforced for @${ssoEmailDomain} users`\n          : \"SSO enforcement has been disabled\",\n      );\n      setConfirmOpen(false);\n      await mutate();\n    } catch (error: any) {\n      toast.error(error.message || \"Failed to update SSO enforcement\");\n    } finally {\n      setSubmitting(false);\n    }\n  }\n\n  if (loading || !configured) {\n    return null;\n  }\n\n  return (\n    <div className=\"rounded-lg border p-4\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-start space-x-3\">\n          {isEnforced ? (\n            <Lock className=\"mt-0.5 h-4 w-4 text-orange-600\" />\n          ) : (\n            <LockOpen className=\"mt-0.5 h-4 w-4 text-muted-foreground\" />\n          )}\n          <div className=\"space-y-1\">\n            <h3 className=\"text-sm font-medium\">SSO Enforcement</h3>\n            {isEnforced ? (\n              <p className=\"text-xs text-muted-foreground\">\n                All users with <strong>@{ssoEmailDomain}</strong> must sign in\n                via SSO. Email, Google, and other login methods are blocked for\n                this domain.\n              </p>\n            ) : (\n              <p className=\"text-xs text-muted-foreground\">\n                SSO is available but not required.{\" \"}\n                {ssoEmailDomain\n                  ? `Users with @${ssoEmailDomain} can still sign in with other methods.`\n                  : \"Configure SAML first to set up enforcement.\"}\n              </p>\n            )}\n            {slug && (\n              <p className=\"mt-1 text-xs text-muted-foreground\">\n                Team slug for SSO login:{\" \"}\n                <code className=\"rounded bg-muted px-1 py-0.5 font-mono text-[10px]\">\n                  {slug}\n                </code>\n              </p>\n            )}\n          </div>\n        </div>\n\n        {isEnforced ? (\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() => toggleEnforcement(false)}\n            loading={submitting}\n            disabled={submitting}\n          >\n            Disable\n          </Button>\n        ) : (\n          <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>\n            <DialogTrigger asChild>\n              <Button\n                variant=\"default\"\n                size=\"sm\"\n                disabled={!ssoEmailDomain || !configured}\n              >\n                Enforce SSO\n              </Button>\n            </DialogTrigger>\n            <DialogContent className=\"sm:max-w-md\">\n              <DialogHeader>\n                <DialogTitle className=\"flex items-center space-x-2\">\n                  <AlertTriangle className=\"h-5 w-5 text-orange-500\" />\n                  <span>Enforce SSO for @{ssoEmailDomain}?</span>\n                </DialogTitle>\n                <DialogDescription className=\"space-y-2 pt-2\">\n                  <p>\n                    Once enforced, <strong>all users</strong> with an{\" \"}\n                    <strong>@{ssoEmailDomain}</strong> email address will be{\" \"}\n                    <strong>required</strong> to sign in through your Identity\n                    Provider.\n                  </p>\n                  <p>\n                    Email magic links, Google, LinkedIn, and passkey login will\n                    be <strong>blocked</strong> for these users.\n                  </p>\n                  <p className=\"font-medium\">\n                    Make sure your SAML configuration is working correctly before\n                    enabling enforcement.\n                  </p>\n                </DialogDescription>\n              </DialogHeader>\n              <DialogFooter className=\"gap-2 sm:gap-0\">\n                <Button\n                  variant=\"outline\"\n                  onClick={() => setConfirmOpen(false)}\n                >\n                  Cancel\n                </Button>\n                <Button\n                  variant=\"destructive\"\n                  onClick={() => toggleEnforcement(true)}\n                  loading={submitting}\n                  disabled={submitting}\n                >\n                  Enforce SSO\n                </Button>\n              </DialogFooter>\n            </DialogContent>\n          </Dialog>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/security/sso/components/sso-login.tsx",
    "content": "\"use client\";\n\nimport { useRef, useState } from \"react\";\n\nimport { Shield } from \"lucide-react\";\nimport { signIn } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nimport { JACKSON_PRODUCT } from \"../product\";\n\ninterface SSOLoginProps {\n  autoExpand?: boolean;\n}\n\nexport function SSOLogin({ autoExpand = false }: SSOLoginProps) {\n  const [teamSlug, setTeamSlug] = useState(\"\");\n  const [loading, setLoading] = useState(false);\n  const [expanded, setExpanded] = useState(autoExpand);\n  const [step, setStep] = useState<\"idle\" | \"redirecting\">(\"idle\");\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  async function handleSSOLogin(e: React.FormEvent) {\n    e.preventDefault();\n\n    const value = teamSlug.trim().toLowerCase();\n    if (!value) {\n      toast.error(\"Please enter your team identifier\");\n      return;\n    }\n\n    setLoading(true);\n\n    try {\n      // Step 1: Verify SSO is configured for this team\n      const verifyRes = await fetch(\"/api/auth/saml/verify\", {\n        method: \"POST\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ slug: value }),\n      });\n\n      const verifyData = await verifyRes.json();\n\n      if (!verifyRes.ok || verifyData.error) {\n        toast.error(\n          verifyData.error ||\n            \"SSO is not configured for this team. Please contact your admin.\",\n        );\n        setLoading(false);\n        return;\n      }\n\n      setStep(\"redirecting\");\n\n      // Step 2: Initiate SAML SSO via NextAuth's OAuth provider (with PKCE + state)\n      await signIn(\"saml\", undefined, {\n        tenant: verifyData.data.teamId,\n        product: JACKSON_PRODUCT,\n      });\n    } catch (error) {\n      console.error(\"[SSO] Login error:\", error);\n      toast.error(\"Something went wrong. Please try again.\");\n      setLoading(false);\n      setStep(\"idle\");\n    }\n  }\n\n  if (step === \"redirecting\") {\n    return (\n      <div className=\"flex flex-col items-center space-y-2 py-4\">\n        <div className=\"h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900\" />\n        <p className=\"text-sm text-gray-600\">\n          Redirecting to your identity provider...\n        </p>\n      </div>\n    );\n  }\n\n  if (!expanded) {\n    return (\n      <Button\n        type=\"button\"\n        onClick={() => {\n          setExpanded(true);\n          // Focus the input after it renders\n          requestAnimationFrame(() => inputRef.current?.focus());\n        }}\n        className=\"flex w-full items-center justify-center space-x-2 border border-gray-300 bg-gray-100 font-normal text-gray-900 hover:bg-gray-200\"\n      >\n        <Shield className=\"h-4 w-4\" />\n        <span>Continue with SAML SSO</span>\n      </Button>\n    );\n  }\n\n  return (\n    <form onSubmit={handleSSOLogin} className=\"flex flex-col space-y-3\">\n      <Label className=\"sr-only\" htmlFor=\"sso-team-slug\">\n        Team Identifier\n      </Label>\n      <Input\n        ref={inputRef}\n        id=\"sso-team-slug\"\n        placeholder=\"your-team-slug\"\n        type=\"text\"\n        autoCapitalize=\"none\"\n        autoComplete=\"off\"\n        autoCorrect=\"off\"\n        autoFocus\n        disabled={loading}\n        value={teamSlug}\n        onChange={(e) => setTeamSlug(e.target.value)}\n        className=\"flex h-10 w-full rounded-md border-0 bg-background bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white\"\n      />\n      <Button\n        type=\"submit\"\n        loading={loading}\n        disabled={!teamSlug.trim() || loading}\n        className=\"flex w-full items-center justify-center space-x-2 border border-gray-300 bg-gray-100 font-normal text-gray-900 hover:bg-gray-200\"\n      >\n        <Shield className=\"h-4 w-4\" />\n        <span>Continue with SAML SSO</span>\n      </Button>\n    </form>\n  );\n}\n"
  },
  {
    "path": "ee/features/security/sso/constants.ts",
    "content": "export const SAML_PROVIDERS = [\n  {\n    name: \"Okta\",\n    saml: \"okta\",\n    samlModalCopy: \"Metadata URL\",\n    scim: \"okta-scim-v2\",\n    scimModalCopy: {\n      url: \"SCIM 2.0 Base URL\",\n      token: \"OAuth Bearer Token\",\n    },\n  },\n  {\n    name: \"Entra ID (formerly Azure AD)\",\n    saml: \"azure\",\n    samlModalCopy: \"App Federation Metadata URL\",\n    scim: \"azure-scim-v2\",\n    scimModalCopy: {\n      url: \"Tenant URL\",\n      token: \"Secret Token\",\n    },\n  },\n  {\n    name: \"Google Workspace\",\n    saml: \"google\",\n    samlModalCopy: \"XML Metadata File\",\n    scim: \"google\",\n    scimModalCopy: {\n      url: \"SCIM 2.0 Base URL\",\n      token: \"OAuth Bearer Token\",\n    },\n  },\n] as const;\n\nexport type SAMLProviderKey = (typeof SAML_PROVIDERS)[number][\"saml\"];\nexport type SCIMProviderKey = (typeof SAML_PROVIDERS)[number][\"scim\"];\n"
  },
  {
    "path": "ee/features/security/sso/index.ts",
    "content": "export { SSOLogin } from \"./components/sso-login\";\nexport { SAMLConfigModal } from \"./components/saml-config-modal\";\nexport { DirectorySyncConfigModal } from \"./components/directory-sync-config-modal\";\nexport { SSOEnforcementToggle } from \"./components/sso-enforcement-toggle\";\nexport { SAML_PROVIDERS } from \"./constants\";\n"
  },
  {
    "path": "ee/features/security/sso/product.ts",
    "content": "/**\n * Jackson product identifier — must match on server and client.\n * Used as the `product` parameter when creating SAML connections\n * and when initiating SSO login.\n */\nexport const JACKSON_PRODUCT = \"papermark\";\n"
  },
  {
    "path": "ee/features/storage/config.ts",
    "content": "import { getFeatureFlags } from \"@/lib/featureFlags\";\n\nexport interface StorageConfig {\n  bucket: string;\n  advancedBucket?: string;\n  region: string;\n  accessKeyId: string;\n  secretAccessKey: string;\n  endpoint?: string;\n  distributionHost?: string;\n  advancedDistributionHost?: string;\n  distributionKeyId?: string;\n  distributionKeyContents?: string;\n  lambdaFunctionName?: string;\n}\n\nexport type StorageRegion = \"eu-central-1\" | \"us-east-2\";\n\n/**\n * Gets AWS storage configuration based on the storage region.\n * Uses environment variables with _US suffix for US region, no suffix for EU region.\n *\n * @param storageRegion - The storage region ('us-east-2' for US, defaults to 'eu-central-1' for EU)\n * @returns StorageConfig object with all necessary AWS configuration\n */\nexport function getStorageConfig(storageRegion?: string): StorageConfig {\n  const isUS = storageRegion === \"us-east-2\";\n  const suffix = isUS ? \"_US\" : \"\";\n\n  // Get base environment variables with optional suffix\n  const getBucket = () => {\n    const bucketVar = `NEXT_PRIVATE_UPLOAD_BUCKET${suffix}`;\n    const bucket = process.env[bucketVar];\n    if (!bucket) {\n      throw new Error(`Missing environment variable: ${bucketVar}`);\n    }\n    return bucket;\n  };\n\n  const getAccessKeyId = () => {\n    const keyVar = `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID${suffix}`;\n    const key = process.env[keyVar];\n    if (!key) {\n      throw new Error(`Missing environment variable: ${keyVar}`);\n    }\n    return key;\n  };\n\n  const getSecretAccessKey = () => {\n    const secretVar = `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY${suffix}`;\n    const secret = process.env[secretVar];\n    if (!secret) {\n      throw new Error(`Missing environment variable: ${secretVar}`);\n    }\n    return secret;\n  };\n\n  const getRegion = () => {\n    const regionVar = `NEXT_PRIVATE_UPLOAD_REGION${suffix}`;\n    return process.env[regionVar] || (isUS ? \"us-east-2\" : \"eu-central-1\");\n  };\n\n  return {\n    bucket: getBucket(),\n    advancedBucket: process.env[`NEXT_PRIVATE_ADVANCED_UPLOAD_BUCKET${suffix}`],\n    region: getRegion(),\n    accessKeyId: getAccessKeyId(),\n    secretAccessKey: getSecretAccessKey(),\n    endpoint: process.env[`NEXT_PRIVATE_UPLOAD_ENDPOINT${suffix}`],\n    distributionHost:\n      process.env[`NEXT_PRIVATE_UPLOAD_DISTRIBUTION_HOST${suffix}`],\n    advancedDistributionHost:\n      process.env[`NEXT_PRIVATE_ADVANCED_UPLOAD_DISTRIBUTION_HOST${suffix}`],\n    distributionKeyId:\n      process.env[`NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID${suffix}`],\n    distributionKeyContents:\n      process.env[`NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS${suffix}`],\n    lambdaFunctionName:\n      process.env[`NEXT_PRIVATE_LAMBDA_FUNCTION_NAME${suffix}`],\n  };\n}\n\n/**\n * Gets storage configuration for a team using feature flags.\n * This is the main function that should be used by file operations.\n *\n * @param teamId - The team ID to get storage configuration for\n * @returns Promise<StorageConfig> - The storage configuration for the team\n */\nexport async function getTeamStorageConfigById(\n  teamId: string,\n): Promise<StorageConfig> {\n  try {\n    const features = await getFeatureFlags({ teamId });\n\n    // If team has usStorage feature flag enabled, use US region\n    const storageRegion = features.usStorage ? \"us-east-2\" : undefined;\n\n    return getStorageConfig(storageRegion);\n  } catch (error) {\n    console.warn(\n      \"Failed to resolve storage region for team %s:\",\n      teamId,\n      error,\n    );\n    return getStorageConfig(); // Default to EU region on error\n  }\n}\n"
  },
  {
    "path": "ee/features/storage/s3-store.ts",
    "content": "import {\n  type StorageConfig,\n  getStorageConfig,\n} from \"@/ee/features/storage/config\";\nimport { S3 } from \"@aws-sdk/client-s3\";\nimport { S3Store } from \"@tus/s3-store\";\nimport type { Upload } from \"@tus/server\";\nimport type { Readable } from \"stream\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\n\n/**\n * Team-aware S3Store that routes uploads to different S3 buckets\n * based on team storage preferences. Extends S3Store and dynamically\n * switches the S3 client and bucket based on team feature flags.\n */\nexport class MultiRegionS3Store extends S3Store {\n  private euConfig: StorageConfig;\n  private usConfig: StorageConfig;\n  private euClient: S3;\n  private usClient: S3;\n  private teamStorageCache = new Map<string, boolean>(); // teamId -> useUSStorage\n\n  constructor() {\n    // Initialize with EU config as default\n    const euConfig = getStorageConfig();\n\n    // Create S3 client config for super() call (omit endpoint if empty/undefined)\n    const superS3Config: any = {\n      bucket: euConfig.bucket,\n      region: euConfig.region,\n      credentials: {\n        accessKeyId: euConfig.accessKeyId,\n        secretAccessKey: euConfig.secretAccessKey,\n      },\n    };\n\n    super({\n      partSize: 8 * 1024 * 1024, // 8MiB parts\n      s3ClientConfig: superS3Config,\n    });\n\n    // Store configurations\n    this.euConfig = euConfig;\n\n    // Create EU S3 client configuration (omit endpoint if empty/undefined)\n    const euS3Config: any = {\n      bucket: euConfig.bucket,\n      region: euConfig.region,\n      credentials: {\n        accessKeyId: euConfig.accessKeyId,\n        secretAccessKey: euConfig.secretAccessKey,\n      },\n    };\n\n    this.euClient = new S3(euS3Config);\n\n    // Initialize US configuration and client\n    try {\n      this.usConfig = getStorageConfig(\"us-east-2\");\n\n      // Create US S3 client configuration (omit endpoint if empty/undefined)\n      const usS3Config: any = {\n        bucket: this.usConfig.bucket,\n        region: this.usConfig.region,\n        credentials: {\n          accessKeyId: this.usConfig.accessKeyId,\n          secretAccessKey: this.usConfig.secretAccessKey,\n        },\n      };\n\n      this.usClient = new S3(usS3Config);\n    } catch (error) {\n      this.usConfig = euConfig;\n      this.usClient = this.euClient;\n    }\n  }\n\n  /**\n   * Extracts teamId from upload ID (format: teamId/docId/filename)\n   */\n  private extractTeamIdFromUploadId(uploadId: string): string | null {\n    const parts = uploadId.split(\"/\");\n    return parts.length > 0 ? parts[0] : null;\n  }\n\n  /**\n   * Determines if team should use US storage, with caching\n   */\n  private async shouldUseUSStorage(teamId: string): Promise<boolean> {\n    // Check cache first\n    if (this.teamStorageCache.has(teamId)) {\n      const cached = this.teamStorageCache.get(teamId)!;\n\n      return cached;\n    }\n\n    try {\n      const features = await getFeatureFlags({ teamId });\n      const useUS = features.usStorage || false;\n\n      // Cache the result for 5 minutes\n      this.teamStorageCache.set(teamId, useUS);\n      setTimeout(() => this.teamStorageCache.delete(teamId), 5 * 60 * 1000);\n\n      return useUS;\n    } catch (error) {\n      return false; // Default to EU\n    }\n  }\n\n  /**\n   * Sets the S3 client and bucket for the appropriate region\n   */\n  private async ensureCorrectRegion(uploadId: string): Promise<void> {\n    const teamId = this.extractTeamIdFromUploadId(uploadId);\n\n    if (!teamId) {\n      // Default to EU - ensure we're using EU client and bucket\n      this.client = this.euClient;\n      this.bucket = this.euConfig.bucket;\n      return;\n    }\n\n    const useUS = await this.shouldUseUSStorage(teamId);\n    if (useUS) {\n      // Switch to US client and bucket\n      this.client = this.usClient;\n      this.bucket = this.usConfig.bucket;\n    } else {\n      // Use EU client and bucket\n      this.client = this.euClient;\n      this.bucket = this.euConfig.bucket;\n    }\n  }\n\n  // Override key S3Store methods to ensure correct region\n  async create(upload: Upload): Promise<Upload> {\n    try {\n      await this.ensureCorrectRegion(upload.id);\n      return await super.create(upload);\n    } catch (error) {\n      throw error;\n    }\n  }\n\n  async write(stream: Readable, id: string, offset: number): Promise<number> {\n    try {\n      await this.ensureCorrectRegion(id);\n      return await super.write(stream, id, offset);\n    } catch (error) {\n      throw error;\n    }\n  }\n\n  async getUpload(id: string): Promise<Upload> {\n    try {\n      await this.ensureCorrectRegion(id);\n      return await super.getUpload(id);\n    } catch (error) {\n      throw error;\n    }\n  }\n\n  async remove(id: string): Promise<void> {\n    try {\n      await this.ensureCorrectRegion(id);\n      // Clean up cache entry\n      const teamId = this.extractTeamIdFromUploadId(id);\n      if (teamId) {\n        this.teamStorageCache.delete(teamId);\n      }\n      await super.remove(id);\n    } catch (error) {\n      throw error;\n    }\n  }\n\n  async declareUploadLength(id: string, length: number): Promise<void> {\n    try {\n      await this.ensureCorrectRegion(id);\n      await super.declareUploadLength(id, length);\n    } catch (error) {\n      throw error;\n    }\n  }\n\n  async read(id: string): Promise<Readable> {\n    try {\n      await this.ensureCorrectRegion(id);\n      return await super.read(id);\n    } catch (error) {\n      throw error;\n    }\n  }\n}\n"
  },
  {
    "path": "ee/features/templates/api/datarooms/apply-template.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport {\n  DATAROOM_TEMPLATES,\n  FolderTemplate,\n} from \"@/ee/features/templates/constants/dataroom-templates\";\nimport { applyTemplateSchema } from \"@/ee/features/templates/schemas/dataroom-templates\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/apply-template\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    // Validate request body using Zod schema for SSRF protection\n    const validationResult = applyTemplateSchema.safeParse(req.body);\n\n    if (!validationResult.success) {\n      return res.status(400).json({\n        message: \"Invalid template type.\",\n        errors: validationResult.error.flatten().fieldErrors,\n      });\n    }\n\n    const { type } = validationResult.data;\n\n    try {\n      // Check if the user is part of the team and has access to the dataroom\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n          team: {\n            users: {\n              some: {\n                userId: userId,\n              },\n            },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const template = DATAROOM_TEMPLATES[type];\n\n      // Update dataroom name and create folders in a transaction\n      await prisma.$transaction(async (tx) => {\n        // Update dataroom name to match template\n        await tx.dataroom.update({\n          where: { id: dataroom.id },\n          data: {\n            name: `${template.name} Data Room`,\n          },\n        });\n\n        // Helper function to create folders recursively\n        const createFolders = async (\n          folders: FolderTemplate[],\n          parentPath: string = \"\",\n          parentId: string | null = null,\n        ): Promise<void> => {\n          for (const folder of folders) {\n            const folderPath = parentPath + \"/\" + safeSlugify(folder.name);\n\n            // Create the folder\n            const createdFolder = await tx.dataroomFolder.create({\n              data: {\n                name: folder.name,\n                path: folderPath,\n                parentId: parentId,\n                dataroomId: dataroom.id,\n              },\n            });\n\n            // If the folder has subfolders, create them recursively\n            if (folder.subfolders && folder.subfolders.length > 0) {\n              await createFolders(\n                folder.subfolders,\n                folderPath,\n                createdFolder.id,\n              );\n            }\n          }\n        };\n\n        await createFolders(template.folders);\n      });\n\n      res.status(200).json({\n        message: \"Template applied successfully\",\n      });\n    } catch (error) {\n      console.error(\"Error applying template:\", error);\n      return res.status(500).json({ error: \"Error applying template\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/templates/api/datarooms/generate.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport {\n  DATAROOM_TEMPLATES,\n  FolderTemplate,\n} from \"@/ee/features/templates/constants/dataroom-templates\";\nimport { generateDataroomSchema } from \"@/ee/features/templates/schemas/dataroom-templates\";\nimport { getLimits } from \"@/ee/limits/server\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/generate\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    const { teamId } = req.query as { teamId: string };\n\n    // Validate request body using Zod schema for SSRF protection\n    const validationResult = generateDataroomSchema.safeParse(req.body);\n\n    if (!validationResult.success) {\n      return res.status(400).json({\n        message: \"Invalid dataroom type. Please select a valid type.\",\n        errors: validationResult.error.flatten().fieldErrors,\n      });\n    }\n\n    const { name, type } = validationResult.data;\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          plan: {\n            in: [\n              \"business\",\n              \"datarooms\",\n              \"datarooms-plus\",\n              \"datarooms-premium\",\n              \"business+old\",\n              \"datarooms+old\",\n              \"datarooms-plus+old\",\n              \"datarooms-premium+old\",\n              \"free+drtrial\",\n              \"datarooms+drtrial\",\n              \"business+drtrial\",\n              \"datarooms-plus+drtrial\",\n              \"datarooms-premium+drtrial\",\n            ],\n          },\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New dataroom creation is not available.\",\n        });\n      }\n\n      // Limits: Check if the user has reached the limit of datarooms in the team\n      const dataroomCount = await prisma.dataroom.count({\n        where: {\n          teamId: teamId,\n        },\n      });\n\n      const limits = await getLimits({ teamId, userId });\n\n      if (limits && dataroomCount >= limits.datarooms) {\n        return res\n          .status(403)\n          .json({ message: \"You have reached the limit of datarooms\" });\n      }\n\n      const pId = newId(\"dataroom\");\n\n      // Create folders based on the selected template\n      const template = DATAROOM_TEMPLATES[type];\n\n      // Use template name + \"Data Room\" if no name is provided\n      const dataroomName = name?.trim() || `${template.name} Data Room`;\n\n      // Create the dataroom and folders in a transaction to prevent hanging results\n      const dataroom = await prisma.$transaction(async (tx) => {\n        // Create the dataroom\n        const createdDataroom = await tx.dataroom.create({\n          data: {\n            name: dataroomName,\n            teamId: teamId,\n            pId: pId,\n          },\n        });\n\n        // Helper function to create folders recursively\n        const createFolders = async (\n          folders: FolderTemplate[],\n          parentPath: string = \"\",\n          parentId: string | null = null,\n        ): Promise<void> => {\n          for (const folder of folders) {\n            const folderPath = parentPath + \"/\" + safeSlugify(folder.name);\n\n            // Create the folder\n            const createdFolder = await tx.dataroomFolder.create({\n              data: {\n                name: folder.name,\n                path: folderPath,\n                parentId: parentId,\n                dataroomId: createdDataroom.id,\n              },\n            });\n\n            // If the folder has subfolders, create them recursively\n            if (folder.subfolders && folder.subfolders.length > 0) {\n              await createFolders(\n                folder.subfolders,\n                folderPath,\n                createdFolder.id,\n              );\n            }\n          }\n        };\n\n        await createFolders(template.folders);\n\n        return createdDataroom;\n      });\n\n      const dataroomWithCount = {\n        ...dataroom,\n        _count: { documents: 0 },\n      };\n\n      res.status(201).json({\n        dataroom: dataroomWithCount,\n        message: \"Dataroom generated successfully\",\n      });\n    } catch (error) {\n      console.error(\"Error generating dataroom:\", error);\n      return res.status(500).json({ error: \"Error generating dataroom\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/features/templates/components/dataroom-templates.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  BriefcaseIcon,\n  BuildingIcon,\n  FileTextIcon,\n  FolderKanbanIcon,\n  HomeIcon,\n  LineChartIcon,\n  RocketIcon,\n  ShoppingCartIcon,\n  TrendingUpIcon,\n} from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { STAGGER_CHILD_VARIANTS } from \"@/lib/constants\";\n\nconst TEMPLATES = [\n  {\n    id: \"startup-fundraising\",\n    name: \"Startup Fundraising\",\n    icon: RocketIcon,\n  },\n  {\n    id: \"series-a-plus\",\n    name: \"Series A+ Fundraising\",\n    icon: TrendingUpIcon,\n  },\n  {\n    id: \"raising-first-fund\",\n    name: \"Raising a Fund\",\n    icon: LineChartIcon,\n  },\n  {\n    id: \"ma-acquisition\",\n    name: \"M&A / Acquisition\",\n    icon: BriefcaseIcon,\n  },\n  {\n    id: \"sales-dataroom\",\n    name: \"Sales Data Room\",\n    icon: ShoppingCartIcon,\n  },\n  {\n    id: \"real-estate-transaction\",\n    name: \"Real Estate\",\n    icon: HomeIcon,\n  },\n  {\n    id: \"fund-management\",\n    name: \"Fund Management\",\n    icon: BuildingIcon,\n  },\n  {\n    id: \"portfolio-management\",\n    name: \"Portfolio Management\",\n    icon: FolderKanbanIcon,\n  },\n  {\n    id: \"project-management\",\n    name: \"Project Management\",\n    icon: FileTextIcon,\n  },\n];\n\nexport default function DataroomTemplates({\n  dataroomId,\n}: {\n  dataroomId: string;\n}) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const analytics = useAnalytics();\n  const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [isValidDataroomId, setIsValidDataroomId] = useState(false);\n\n  // Validate dataroomId on mount\n  useEffect(() => {\n    try {\n      z.string().cuid().parse(dataroomId);\n      setIsValidDataroomId(true);\n    } catch (error) {\n      console.error(\"Invalid dataroom ID:\", error);\n      toast.error(\"Invalid dataroom ID. Redirecting...\");\n      router.push(\"/documents\");\n    }\n  }, [dataroomId, router]);\n\n  const handleTemplateSelect = async (templateType: string) => {\n    // Validate dataroomId before making API call\n    try {\n      const dataroomIdParsed = z.string().cuid().parse(dataroomId);\n\n      setSelectedTemplate(templateType);\n      setLoading(true);\n\n      const response = await fetch(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomIdParsed}/apply-template`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            type: templateType,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const { message } = await response.json();\n        toast.error(message);\n        setLoading(false);\n        setSelectedTemplate(null);\n        return;\n      }\n\n      analytics.capture(\"Dataroom Template Applied\", {\n        dataroomId: dataroomIdParsed,\n        templateType,\n      });\n\n      toast.success(\"Template applied successfully! 🎉\");\n      router.push(`/datarooms/${dataroomIdParsed}/documents`);\n    } catch (error) {\n      if (error instanceof z.ZodError) {\n        console.error(\"Invalid dataroom ID:\", error);\n        toast.error(\"Invalid dataroom ID.\");\n      } else {\n        console.error(\"Error applying template:\", error);\n        toast.error(\"Error applying template. Please try again.\");\n      }\n      setLoading(false);\n      setSelectedTemplate(null);\n    }\n  };\n\n  // Don't render until dataroomId is validated\n  if (!isValidDataroomId) {\n    return null;\n  }\n\n  return (\n    <motion.div\n      className=\"z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto\"\n      variants={{\n        hidden: { opacity: 0, scale: 0.95 },\n        show: {\n          opacity: 1,\n          scale: 1,\n          transition: {\n            staggerChildren: 0.2,\n          },\n        },\n      }}\n      initial=\"hidden\"\n      animate=\"show\"\n      exit=\"hidden\"\n      transition={{ duration: 0.3, type: \"spring\" }}\n    >\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"flex flex-col items-center space-y-10 text-center\"\n      >\n        <p className=\"text-2xl font-bold tracking-tighter text-foreground\">\n          Papermark\n        </p>\n        <h1 className=\"font-display max-w-md text-3xl font-semibold transition-colors sm:text-4xl\">\n          Select a template to get started\n        </h1>\n      </motion.div>\n\n      <motion.div\n        variants={STAGGER_CHILD_VARIANTS}\n        className=\"grid w-full grid-cols-3 divide-x divide-y divide-border overflow-hidden rounded-md border border-border text-foreground\"\n      >\n        {TEMPLATES.map((template) => {\n          const Icon = template.icon;\n          const isSelected = selectedTemplate === template.id;\n          const isLoading = loading && isSelected;\n\n          return (\n            <button\n              key={template.id}\n              onClick={() => handleTemplateSelect(template.id)}\n              disabled={loading}\n              className=\"relative flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10\"\n            >\n              <Icon className=\"pointer-events-none h-auto w-12 sm:w-12\" />\n              <p>{template.name}</p>\n              {isLoading && (\n                <div className=\"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-gray-900/50\">\n                  <div className=\"h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-[#fb7a00]\" />\n                </div>\n              )}\n            </button>\n          );\n        })}\n      </motion.div>\n    </motion.div>\n  );\n}\n"
  },
  {
    "path": "ee/features/templates/constants/dataroom-templates.ts",
    "content": "export interface FolderTemplate {\n  name: string;\n  subfolders?: FolderTemplate[];\n}\n\nexport const DATAROOM_TEMPLATE_TYPES = [\n  \"startup-fundraising\",\n  \"raising-first-fund\",\n  \"ma-acquisition\",\n  \"series-a-plus\",\n  \"real-estate-transaction\",\n  \"fund-management\",\n  \"portfolio-management\",\n  \"project-management\",\n  \"sales-dataroom\",\n] as const;\n\nexport const DATAROOM_TEMPLATES: Record<\n  string,\n  { name: string; folders: FolderTemplate[] }\n> = {\n  \"startup-fundraising\": {\n    name: \"Startup Fundraising\",\n    folders: [\n      { name: \"Corporate or Investment Memo\" },\n      {\n        name: \"Corporate Documents\",\n        subfolders: [\n          { name: \"Company Registration\" },\n          { name: \"Group Structure\" },\n          { name: \"Shareholder Overview\" },\n        ],\n      },\n      {\n        name: \"Financial Forecast and Actuals\",\n        subfolders: [{ name: \"Forecasts\" }, { name: \"Actuals\" }],\n      },\n      {\n        name: \"Legal and Tax Documents\",\n        subfolders: [\n          { name: \"Contracts\" },\n          { name: \"IP Agreements\" },\n          { name: \"Capitalization Table\" },\n        ],\n      },\n      {\n        name: \"Go-to-Market and Marketing Strategy\",\n        subfolders: [\n          { name: \"Business Plan\" },\n          { name: \"Organizational Chart\" },\n        ],\n      },\n      { name: \"Product Roadmap\" },\n      { name: \"Pitch Deck\" },\n    ],\n  },\n  \"raising-first-fund\": {\n    name: \"Raising First Fund\",\n    folders: [\n      {\n        name: \"1 Introduction\",\n        subfolders: [{ name: \"Presentations\" }],\n      },\n      {\n        name: \"2 Team\",\n        subfolders: [{ name: \"CVs & References\" }],\n      },\n      {\n        name: \"3 Track Record\",\n        subfolders: [{ name: \"Track record & portfolio references\" }],\n      },\n      {\n        name: \"4 Fund Model\",\n        subfolders: [{ name: \"XLSX granular track record\" }],\n      },\n      {\n        name: \"5 Legal\",\n        subfolders: [{ name: \"Executed LPA, bylaws\" }],\n      },\n      {\n        name: \"6 Portfolio\",\n        subfolders: [{ name: \"Investment memos of portfolio companies\" }],\n      },\n    ],\n  },\n  \"ma-acquisition\": {\n    name: \"M&A / Acquisition\",\n    folders: [\n      {\n        name: \"1 Executive Summary\",\n        subfolders: [\n          { name: \"Transaction Overview\" },\n          { name: \"Investment Highlights\" },\n        ],\n      },\n      {\n        name: \"2 Corporate Structure & Governance\",\n        subfolders: [\n          { name: \"Corporate Documents\" },\n          { name: \"Board Materials\" },\n          { name: \"Shareholder Agreements\" },\n        ],\n      },\n      {\n        name: \"3 Financial Information\",\n        subfolders: [\n          { name: \"Historical Financials\" },\n          { name: \"Audited Statements\" },\n          { name: \"Management Accounts\" },\n          { name: \"Financial Projections\" },\n        ],\n      },\n      {\n        name: \"4 Legal & Compliance\",\n        subfolders: [\n          { name: \"Material Contracts\" },\n          { name: \"Litigation & Disputes\" },\n          { name: \"Regulatory Compliance\" },\n          { name: \"Permits & Licenses\" },\n        ],\n      },\n      {\n        name: \"5 Intellectual Property\",\n        subfolders: [\n          { name: \"Patents & Trademarks\" },\n          { name: \"Licenses & Assignments\" },\n          { name: \"IP Agreements\" },\n        ],\n      },\n      {\n        name: \"6 Contracts & Agreements\",\n        subfolders: [\n          { name: \"Customer Contracts\" },\n          { name: \"Supplier Agreements\" },\n          { name: \"Partnership Agreements\" },\n        ],\n      },\n      {\n        name: \"7 Human Resources\",\n        subfolders: [\n          { name: \"Employee List\" },\n          { name: \"Employment Agreements\" },\n          { name: \"Benefits & Compensation\" },\n          { name: \"Organization Chart\" },\n        ],\n      },\n      {\n        name: \"8 Tax Documents\",\n        subfolders: [{ name: \"Tax Returns\" }, { name: \"Tax Assessments\" }],\n      },\n      {\n        name: \"9 Assets & Liabilities\",\n        subfolders: [\n          { name: \"Real Estate\" },\n          { name: \"Equipment & Inventory\" },\n          { name: \"Debt & Obligations\" },\n        ],\n      },\n      {\n        name: \"10 Insurance\",\n        subfolders: [\n          { name: \"Insurance Policies\" },\n          { name: \"Claims History\" },\n        ],\n      },\n    ],\n  },\n  \"series-a-plus\": {\n    name: \"Series A+ Fundraising\",\n    folders: [\n      {\n        name: \"1 Investment Memorandum\",\n        subfolders: [{ name: \"Executive Summary\" }, { name: \"Pitch Deck\" }],\n      },\n      {\n        name: \"2 Financial Information\",\n        subfolders: [\n          { name: \"Historical Financials\" },\n          { name: \"Financial Projections\" },\n          { name: \"Unit Economics\" },\n          { name: \"KPI Dashboard\" },\n        ],\n      },\n      {\n        name: \"3 Corporate Documents\",\n        subfolders: [\n          { name: \"Incorporation Documents\" },\n          { name: \"Board Materials\" },\n          { name: \"Shareholder Agreements\" },\n        ],\n      },\n      {\n        name: \"4 Cap Table & Term Sheets\",\n        subfolders: [\n          { name: \"Capitalization Table\" },\n          { name: \"Previous Rounds\" },\n          { name: \"Stock Option Pool\" },\n        ],\n      },\n      {\n        name: \"5 Product & Technology\",\n        subfolders: [\n          { name: \"Product Roadmap\" },\n          { name: \"Technical Documentation\" },\n          { name: \"Product Demos\" },\n        ],\n      },\n      {\n        name: \"6 Market & Traction\",\n        subfolders: [\n          { name: \"Market Analysis\" },\n          { name: \"Customer Data\" },\n          { name: \"Growth Metrics\" },\n          { name: \"Case Studies\" },\n        ],\n      },\n      {\n        name: \"7 Team & Organization\",\n        subfolders: [\n          { name: \"Team Bios\" },\n          { name: \"Organizational Chart\" },\n          { name: \"Advisory Board\" },\n          { name: \"Key Hires Plan\" },\n        ],\n      },\n      {\n        name: \"8 Legal & IP\",\n        subfolders: [\n          { name: \"IP Portfolio\" },\n          { name: \"Material Contracts\" },\n          { name: \"Compliance Documents\" },\n        ],\n      },\n      {\n        name: \"9 Competitive Analysis\",\n        subfolders: [\n          { name: \"Competitive Landscape\" },\n          { name: \"Differentiation\" },\n        ],\n      },\n      {\n        name: \"10 Use of Funds\",\n        subfolders: [{ name: \"Budget Allocation\" }, { name: \"Milestones\" }],\n      },\n    ],\n  },\n  \"real-estate-transaction\": {\n    name: \"Real Estate Transaction\",\n    folders: [\n      {\n        name: \"1 Property Information\",\n        subfolders: [\n          { name: \"Property Overview\" },\n          { name: \"Location & Site Plans\" },\n          { name: \"Building Specifications\" },\n        ],\n      },\n      {\n        name: \"2 Title & Ownership\",\n        subfolders: [\n          { name: \"Title Documents\" },\n          { name: \"Ownership Structure\" },\n          { name: \"Deed & Transfer Documents\" },\n        ],\n      },\n      {\n        name: \"3 Legal Documents\",\n        subfolders: [\n          { name: \"Purchase Agreements\" },\n          { name: \"Easements & Restrictions\" },\n          { name: \"Zoning & Permits\" },\n        ],\n      },\n      {\n        name: \"4 Financial Information\",\n        subfolders: [\n          { name: \"Operating Statements\" },\n          { name: \"Rent Roll\" },\n          { name: \"Expense Reports\" },\n          { name: \"Tax Assessments\" },\n        ],\n      },\n      {\n        name: \"5 Leases & Tenancies\",\n        subfolders: [\n          { name: \"Tenant Leases\" },\n          { name: \"Tenant Correspondence\" },\n          { name: \"Lease Abstracts\" },\n        ],\n      },\n      {\n        name: \"6 Property Surveys & Plans\",\n        subfolders: [\n          { name: \"Survey Reports\" },\n          { name: \"Floor Plans\" },\n          { name: \"As-Built Drawings\" },\n        ],\n      },\n      {\n        name: \"7 Environmental Reports\",\n        subfolders: [\n          { name: \"Environmental Assessments\" },\n          { name: \"Soil Reports\" },\n          { name: \"Remediation Documents\" },\n        ],\n      },\n      {\n        name: \"8 Building Inspections\",\n        subfolders: [\n          { name: \"Structural Inspections\" },\n          { name: \"Engineering Reports\" },\n          { name: \"Maintenance Records\" },\n        ],\n      },\n      {\n        name: \"9 Property Management\",\n        subfolders: [\n          { name: \"Management Agreements\" },\n          { name: \"Vendor Contracts\" },\n          { name: \"Service Agreements\" },\n        ],\n      },\n      {\n        name: \"10 Insurance & Warranties\",\n        subfolders: [\n          { name: \"Insurance Policies\" },\n          { name: \"Warranties\" },\n          { name: \"Claims History\" },\n        ],\n      },\n    ],\n  },\n  \"fund-management\": {\n    name: \"Fund Management\",\n    folders: [\n      {\n        name: \"1 Fund Documents\",\n        subfolders: [\n          { name: \"Fund Formation Documents\" },\n          { name: \"LPA & Side Letters\" },\n          { name: \"Fund Policies\" },\n        ],\n      },\n      {\n        name: \"2 LP Relations\",\n        subfolders: [\n          { name: \"LP Commitments\" },\n          { name: \"Capital Calls\" },\n          { name: \"Distribution Notices\" },\n          { name: \"LP Communications\" },\n        ],\n      },\n      {\n        name: \"3 Financial Reporting\",\n        subfolders: [\n          { name: \"Quarterly Reports\" },\n          { name: \"Annual Reports\" },\n          { name: \"NAV Statements\" },\n          { name: \"Cash Flow Reports\" },\n        ],\n      },\n      {\n        name: \"4 Compliance & Legal\",\n        subfolders: [\n          { name: \"Regulatory Filings\" },\n          { name: \"Audit Reports\" },\n          { name: \"Tax Documents\" },\n          { name: \"Legal Opinions\" },\n        ],\n      },\n      {\n        name: \"5 Investment Activities\",\n        subfolders: [\n          { name: \"Investment Memos\" },\n          { name: \"Deal Pipeline\" },\n          { name: \"Investment Committee Materials\" },\n        ],\n      },\n      {\n        name: \"6 Portfolio Monitoring\",\n        subfolders: [\n          { name: \"Portfolio Company Updates\" },\n          { name: \"Board Materials\" },\n          { name: \"Valuations\" },\n        ],\n      },\n      {\n        name: \"7 Operations\",\n        subfolders: [\n          { name: \"Fund Administration\" },\n          { name: \"Service Provider Agreements\" },\n          { name: \"Policies & Procedures\" },\n        ],\n      },\n      {\n        name: \"8 Investor Communications\",\n        subfolders: [\n          { name: \"Investor Letters\" },\n          { name: \"Meeting Materials\" },\n          { name: \"AGM Documents\" },\n        ],\n      },\n    ],\n  },\n  \"portfolio-management\": {\n    name: \"Portfolio Management\",\n    folders: [\n      {\n        name: \"1 Portfolio Overview\",\n        subfolders: [\n          { name: \"Portfolio Summary\" },\n          { name: \"Portfolio Strategy\" },\n          { name: \"Performance Dashboard\" },\n        ],\n      },\n      {\n        name: \"2 Portfolio Companies\",\n        subfolders: [\n          { name: \"Company Profiles\" },\n          { name: \"Investment Theses\" },\n          { name: \"Ownership Information\" },\n        ],\n      },\n      {\n        name: \"3 Financial Performance\",\n        subfolders: [\n          { name: \"Company Financials\" },\n          { name: \"KPI Reports\" },\n          { name: \"Valuations\" },\n          { name: \"Return Analysis\" },\n        ],\n      },\n      {\n        name: \"4 Board & Governance\",\n        subfolders: [\n          { name: \"Board Decks\" },\n          { name: \"Meeting Minutes\" },\n          { name: \"Board Observer Rights\" },\n        ],\n      },\n      {\n        name: \"5 Operational Support\",\n        subfolders: [\n          { name: \"Value Creation Plans\" },\n          { name: \"Strategic Initiatives\" },\n          { name: \"Operational Reviews\" },\n        ],\n      },\n      {\n        name: \"6 Deal Documents\",\n        subfolders: [\n          { name: \"Investment Agreements\" },\n          { name: \"Shareholder Agreements\" },\n          { name: \"Cap Tables\" },\n        ],\n      },\n      {\n        name: \"7 Follow-on & Exits\",\n        subfolders: [\n          { name: \"Follow-on Analysis\" },\n          { name: \"Exit Planning\" },\n          { name: \"M&A Materials\" },\n        ],\n      },\n      {\n        name: \"8 Portfolio Monitoring\",\n        subfolders: [\n          { name: \"Monthly Updates\" },\n          { name: \"Risk Assessments\" },\n          { name: \"Action Items\" },\n        ],\n      },\n    ],\n  },\n  \"project-management\": {\n    name: \"Project Management\",\n    folders: [\n      {\n        name: \"1 Project Overview\",\n        subfolders: [\n          { name: \"Project Charter\" },\n          { name: \"Scope & Objectives\" },\n          { name: \"Project Plan\" },\n        ],\n      },\n      {\n        name: \"2 Requirements & Specifications\",\n        subfolders: [\n          { name: \"Requirements Documentation\" },\n          { name: \"Technical Specifications\" },\n          { name: \"User Stories\" },\n        ],\n      },\n      {\n        name: \"3 Project Planning\",\n        subfolders: [\n          { name: \"Work Breakdown Structure\" },\n          { name: \"Timeline & Milestones\" },\n          { name: \"Resource Plan\" },\n          { name: \"Budget\" },\n        ],\n      },\n      {\n        name: \"4 Team & Stakeholders\",\n        subfolders: [\n          { name: \"Team Directory\" },\n          { name: \"Roles & Responsibilities\" },\n          { name: \"Stakeholder Matrix\" },\n        ],\n      },\n      {\n        name: \"5 Project Execution\",\n        subfolders: [\n          { name: \"Sprint Plans\" },\n          { name: \"Task Tracking\" },\n          { name: \"Deliverables\" },\n        ],\n      },\n      {\n        name: \"6 Communication\",\n        subfolders: [\n          { name: \"Status Reports\" },\n          { name: \"Meeting Notes\" },\n          { name: \"Stakeholder Updates\" },\n        ],\n      },\n      {\n        name: \"7 Risk & Issues\",\n        subfolders: [\n          { name: \"Risk Register\" },\n          { name: \"Issue Log\" },\n          { name: \"Change Requests\" },\n        ],\n      },\n      {\n        name: \"8 Quality & Testing\",\n        subfolders: [\n          { name: \"Quality Standards\" },\n          { name: \"Test Plans\" },\n          { name: \"Acceptance Criteria\" },\n        ],\n      },\n      {\n        name: \"9 Documentation\",\n        subfolders: [\n          { name: \"Process Documentation\" },\n          { name: \"User Guides\" },\n          { name: \"Training Materials\" },\n        ],\n      },\n      {\n        name: \"10 Project Closure\",\n        subfolders: [\n          { name: \"Final Reports\" },\n          { name: \"Lessons Learned\" },\n          { name: \"Handover Documents\" },\n        ],\n      },\n    ],\n  },\n  \"sales-dataroom\": {\n    name: \"Sales Data Room\",\n    folders: [\n      {\n        name: \"1 Sales Materials\",\n        subfolders: [\n          { name: \"Sales Decks\" },\n          { name: \"Product Brochures\" },\n          { name: \"One Pagers\" },\n        ],\n      },\n      {\n        name: \"2 Proposals & Quotes\",\n        subfolders: [{ name: \"Proposals\" }, { name: \"Pricing\" }],\n      },\n      {\n        name: \"3 Contracts & Agreements\",\n        subfolders: [\n          { name: \"Master Service Agreements\" },\n          { name: \"NDAs\" },\n          { name: \"Terms & Conditions\" },\n        ],\n      },\n      {\n        name: \"4 Product Information\",\n        subfolders: [\n          { name: \"Product Specs\" },\n          { name: \"Demo Videos\" },\n          { name: \"Case Studies\" },\n        ],\n      },\n      {\n        name: \"5 Customer References\",\n        subfolders: [{ name: \"Testimonials\" }, { name: \"Reference Letters\" }],\n      },\n      {\n        name: \"6 Security & Compliance\",\n        subfolders: [\n          { name: \"Security Documentation\" },\n          { name: \"Compliance Certificates\" },\n          { name: \"Insurance Certificates\" },\n        ],\n      },\n    ],\n  },\n};\n"
  },
  {
    "path": "ee/features/templates/lib/prompts.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\ninterface Prompts {\n  \"generate-dataroom-system\": string;\n  \"generate-dataroom-user\": string;\n}\n\nasync function getPrompts(): Promise<Prompts> {\n  if (!process.env.EDGE_CONFIG) {\n    throw new Error(\"Edge Config not configured\");\n  }\n\n  const prompts = await get<Prompts>(\"prompts\");\n\n  if (!prompts) {\n    throw new Error(\"Prompts not found in Edge Config\");\n  }\n\n  return prompts;\n}\n\nexport async function getDataroomSystemPrompt(): Promise<string> {\n  const prompts = await getPrompts();\n\n  const prompt = prompts[\"generate-dataroom-system\"];\n\n  if (!prompt || typeof prompt !== \"string\") {\n    throw new Error(\"Dataroom system prompt not found in Edge Config\");\n  }\n\n  return prompt;\n}\n\nexport async function getDataroomUserPrompt(\n  description: string,\n): Promise<string> {\n  const prompts = await getPrompts();\n\n  const template = prompts[\"generate-dataroom-user\"];\n\n  if (!template || typeof template !== \"string\") {\n    throw new Error(\"Dataroom user prompt not found in Edge Config\");\n  }\n\n  return template.replace(\"{{DESCRIPTION}}\", description.trim());\n}\n\n"
  },
  {
    "path": "ee/features/templates/schemas/dataroom-templates.ts",
    "content": "import { z } from \"zod\";\n\nimport { DATAROOM_TEMPLATE_TYPES } from \"../constants/dataroom-templates\";\n\nexport const applyTemplateSchema = z.object({\n  type: z\n    .enum(DATAROOM_TEMPLATE_TYPES, {\n      errorMap: () => ({\n        message: `Invalid template type. Must be one of: ${DATAROOM_TEMPLATE_TYPES.join(\", \")}`,\n      }),\n    })\n    .refine(\n      (type) => {\n        // Additional validation: ensure no path traversal or special characters\n        return (\n          !type.includes(\"..\") &&\n          !type.includes(\"/\") &&\n          !type.includes(\"\\\\\") &&\n          !type.includes(\"\\0\")\n        );\n      },\n      {\n        message: \"Template type contains invalid characters\",\n      },\n    ),\n});\n\n/**\n * Schema for validating dataroom generation request\n * Includes both name validation and template type validation\n * Name is optional - if not provided, template name will be used\n */\nexport const generateDataroomSchema = z.object({\n  name: z\n    .string()\n    .max(255, \"Dataroom name is too long\")\n    .optional()\n    .refine(\n      (name) => {\n        // If name is provided, ensure no malicious characters\n        if (!name) return true;\n        return (\n          !name.includes(\"\\0\") &&\n          !name.includes(\"..\") &&\n          !name.includes(\"<script>\")\n        );\n      },\n      {\n        message: \"Dataroom name contains invalid characters\",\n      },\n    ),\n  type: z\n    .enum(DATAROOM_TEMPLATE_TYPES, {\n      errorMap: () => ({\n        message: `Invalid template type. Must be one of: ${DATAROOM_TEMPLATE_TYPES.join(\", \")}`,\n      }),\n    })\n    .refine(\n      (type) => {\n        // Additional validation: ensure no path traversal or special characters\n        return (\n          !type.includes(\"..\") &&\n          !type.includes(\"/\") &&\n          !type.includes(\"\\\\\") &&\n          !type.includes(\"\\0\")\n        );\n      },\n      {\n        message: \"Template type contains invalid characters\",\n      },\n    ),\n});\n\n/**\n * Type exports for TypeScript\n */\nexport type ApplyTemplateInput = z.infer<typeof applyTemplateSchema>;\nexport type GenerateDataroomInput = z.infer<typeof generateDataroomSchema>;\nexport type DataroomTemplateType = (typeof DATAROOM_TEMPLATE_TYPES)[number];\n"
  },
  {
    "path": "ee/features/workflows/components/index.ts",
    "content": "export { WorkflowList } from \"./workflow-list\";\nexport { WorkflowEmptyState } from \"./workflow-empty-state\";\nexport { WorkflowHeader } from \"./workflow-header\";\nexport { StepList } from \"./step-list\";\nexport { StepFormDialog } from \"./step-form-dialog\";\nexport { default as WorkflowAccessView } from \"./workflow-access-view\";\n\n"
  },
  {
    "path": "ee/features/workflows/components/step-form-dialog.tsx",
    "content": "import { useEffect, useState } from \"react\";\n\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { SingleSelect } from \"@/components/ui/single-select\";\nimport { Textarea } from \"@/components/ui/textarea\";\n\ninterface WorkflowStep {\n  id: string;\n  name: string;\n  stepOrder: number;\n  conditions: {\n    logic: \"AND\" | \"OR\";\n    items: Array<{\n      type: \"email\" | \"domain\";\n      operator: string;\n      value: string | string[];\n    }>;\n  };\n  actions: Array<{\n    type: \"route\";\n    targetLinkId: string;\n  }>;\n}\n\ninterface Link {\n  id: string;\n  name: string | null;\n  slug: string | null;\n  domainSlug: string | null;\n  linkType: \"DOCUMENT_LINK\" | \"DATAROOM_LINK\";\n  documentId: string | null;\n  dataroomId: string | null;\n  allowList: string[]; // Pre-populate from link\n  resourceName: string | null; // Document or Dataroom name\n  displayName: string; // Human-readable label\n}\n\ninterface StepFormDialogProps {\n  workflowId: string;\n  teamId: string;\n  step: WorkflowStep | null;\n  links: Link[];\n  open: boolean;\n  onClose: () => void;\n  onSuccess: () => void;\n}\n\nexport function StepFormDialog({\n  workflowId,\n  teamId,\n  step,\n  links,\n  open,\n  onClose,\n  onSuccess,\n}: StepFormDialogProps) {\n  const [name, setName] = useState(step?.name || \"\");\n  const [targetLinkId, setTargetLinkId] = useState(\n    step?.actions[0]?.targetLinkId || \"\",\n  );\n  const [allowListInput, setAllowListInput] = useState(() => {\n    // Pre-populate from existing step when editing\n    if (step && step.conditions.items.length > 0) {\n      const allValues: string[] = [];\n      step.conditions.items.forEach((condition) => {\n        const values = Array.isArray(condition.value)\n          ? condition.value\n          : [condition.value];\n        if (condition.type === \"domain\") {\n          // Prefix domains with @\n          allValues.push(...values.map((v) => `@${v}`));\n        } else {\n          // Email addresses as-is\n          allValues.push(...values);\n        }\n      });\n      return allValues.join(\"\\n\");\n    }\n    return \"\";\n  });\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  // Re-populate state when step or open changes to prevent stale values\n  useEffect(() => {\n    if (open) {\n      // Reset name\n      setName(step?.name || \"\");\n\n      // Reset targetLinkId\n      setTargetLinkId(step?.actions?.[0]?.targetLinkId || \"\");\n\n      // Reset allowListInput\n      if (step && step.conditions?.items && step.conditions.items.length > 0) {\n        const allValues: string[] = [];\n        step.conditions.items.forEach((condition) => {\n          const values = Array.isArray(condition.value)\n            ? condition.value\n            : [condition.value];\n          if (condition.type === \"domain\") {\n            // Prefix domains with @\n            allValues.push(...values.map((v) => `@${v}`));\n          } else {\n            // Email addresses as-is\n            allValues.push(...values);\n          }\n        });\n        setAllowListInput(allValues.join(\"\\n\"));\n      } else {\n        setAllowListInput(\"\");\n      }\n    }\n  }, [step, open]);\n\n  // When link is selected, pre-fill allowList\n  const handleLinkChange = (linkId: string) => {\n    setTargetLinkId(linkId);\n    const selectedLink = links.find((l) => l.id === linkId);\n    if (selectedLink && selectedLink.allowList.length > 0) {\n      setAllowListInput(selectedLink.allowList.join(\"\\n\"));\n      // Auto-fill step name if empty\n      if (!name && selectedLink.resourceName) {\n        setName(`Route to ${selectedLink.resourceName}`);\n      }\n    } else {\n      setAllowListInput(\"\");\n    }\n  };\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!name.trim()) {\n      toast.error(\"Step name is required\");\n      return;\n    }\n\n    if (!targetLinkId) {\n      toast.error(\"Target link is required\");\n      return;\n    }\n\n    if (!allowListInput.trim()) {\n      toast.error(\"At least one email or domain is required\");\n      return;\n    }\n\n    // Validate IDs to prevent SSRF\n    const workflowIdValidation = z.string().cuid().safeParse(workflowId);\n    const teamIdValidation = z.string().cuid().safeParse(teamId);\n    if (!workflowIdValidation.success || !teamIdValidation.success) {\n      toast.error(\"Invalid workflow or team ID\");\n      return;\n    }\n\n    if (step) {\n      const stepIdValidation = z.string().cuid().safeParse(step.id);\n      if (!stepIdValidation.success) {\n        toast.error(\"Invalid step ID\");\n        return;\n      }\n    }\n\n    setIsSubmitting(true);\n\n    try {\n      // Parse allowList input - separate emails and domains\n      const lines = allowListInput\n        .split(\"\\n\")\n        .map((line) => line.trim())\n        .filter((line) => line.length > 0);\n\n      const emails: string[] = [];\n      const domains: string[] = [];\n\n      lines.forEach((line) => {\n        if (line.startsWith(\"@\")) {\n          // Domain (remove @ prefix)\n          domains.push(line.substring(1));\n        } else {\n          // Email address\n          emails.push(line);\n        }\n      });\n\n      // Build conditions array\n      const conditionItems = [];\n      if (emails.length > 0) {\n        conditionItems.push({\n          type: \"email\",\n          operator: \"in_list\",\n          value: emails,\n        });\n      }\n      if (domains.length > 0) {\n        conditionItems.push({\n          type: \"domain\",\n          operator: \"in_list\",\n          value: domains,\n        });\n      }\n\n      const payload = {\n        name: name.trim(),\n        conditions: {\n          logic: \"OR\", // Match ANY email or domain\n          items: conditionItems,\n        },\n        actions: [\n          {\n            type: \"route\",\n            targetLinkId,\n          },\n        ],\n      };\n\n      const url = step\n        ? `/api/workflows/${workflowId}/steps/${step.id}?teamId=${teamId}`\n        : `/api/workflows/${workflowId}/steps?teamId=${teamId}`;\n\n      const response = await fetch(url, {\n        method: step ? \"PATCH\" : \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(payload),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error || \"Failed to save step\");\n      }\n\n      toast.success(step ? \"Step updated\" : \"Step created\");\n      onSuccess();\n    } catch (error) {\n      console.error(\"Error saving step:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to save step\",\n      );\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onClose}>\n      <DialogContent className=\"max-w-2xl\">\n        <DialogHeader>\n          <DialogTitle>{step ? \"Edit Step\" : \"Add Step\"}</DialogTitle>\n          <DialogDescription>\n            Configure the routing condition and target link\n          </DialogDescription>\n        </DialogHeader>\n\n        <form onSubmit={handleSubmit} className=\"space-y-4\">\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"step-name\">Step Name *</Label>\n            <Input\n              id=\"step-name\"\n              value={name}\n              onChange={(e) => setName(e.target.value)}\n              placeholder=\"e.g., Route Company A\"\n              required\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"target-link\">Route to Link *</Label>\n            <SingleSelect\n              options={links.map((link) => ({\n                value: link.id,\n                label: link.displayName,\n                searchableText: `${link.displayName} ${link.resourceName || \"\"} ${link.slug || \"\"} ${link.domainSlug || \"\"} ${link.id}`,\n                meta: link,\n              }))}\n              value={targetLinkId}\n              onValueChange={handleLinkChange}\n              placeholder=\"Select a link...\"\n              searchPlaceholder=\"Search links by name, slug, domain, or ID...\"\n              emptyText=\"No links found.\"\n              className=\"w-full\"\n              renderOption={(option, isSelected) => {\n                const link = option.meta as Link;\n                if (!link) return null;\n\n                return (\n                  <div className=\"flex w-full flex-col gap-1.5\">\n                    <div className=\"flex items-center gap-2\">\n                      <span className=\"font-medium\">{link.displayName}</span>\n                      <Badge\n                        variant=\"outline\"\n                        className={cn(\n                          \"text-xs\",\n                          isSelected && \"bg-primary/10 text-primary\",\n                        )}\n                      >\n                        {link.linkType === \"DOCUMENT_LINK\"\n                          ? \"Document\"\n                          : \"Dataroom\"}\n                      </Badge>\n                    </div>\n                    {link.resourceName && (\n                      <span className=\"text-xs text-muted-foreground\">\n                        {link.resourceName}\n                      </span>\n                    )}\n                    {link.domainSlug && link.slug && (\n                      <span className=\"font-mono text-xs text-muted-foreground\">\n                        {link.domainSlug}/{link.slug}\n                      </span>\n                    )}\n                    {!link.domainSlug && link.slug && (\n                      <span className=\"font-mono text-xs text-muted-foreground\">\n                        papermark.com/{link.slug}\n                      </span>\n                    )}\n                    <span className=\"font-mono text-xs text-muted-foreground\">\n                      ID: {link.id.substring(0, 15)}...\n                    </span>\n                  </div>\n                );\n              }}\n              renderTrigger={(selectedOption) => {\n                if (!selectedOption) return null;\n                const link = selectedOption.meta as Link;\n                if (!link) return selectedOption.label;\n\n                return (\n                  <div className=\"flex w-full flex-col gap-1 overflow-hidden\">\n                    <div className=\"flex items-center gap-2\">\n                      <span className=\"truncate font-medium text-foreground\">\n                        {link.displayName}\n                      </span>\n                      <Badge variant=\"outline\" className=\"shrink-0 text-xs\">\n                        {link.linkType === \"DOCUMENT_LINK\"\n                          ? \"Document\"\n                          : \"Dataroom\"}\n                      </Badge>\n                    </div>\n                    {link.resourceName && (\n                      <div className=\"flex truncate text-xs text-muted-foreground\">\n                        {link.resourceName}\n                      </div>\n                    )}\n                  </div>\n                );\n              }}\n            />\n          </div>\n\n          <div className=\"space-y-2\">\n            <Label htmlFor=\"allow-list\">Allowed Emails & Domains *</Label>\n            <Textarea\n              id=\"allow-list\"\n              value={allowListInput}\n              onChange={(e) => setAllowListInput(e.target.value)}\n              placeholder={`Enter allowed emails/domains, one per line:\njohn@company-a.com\njane@company-a.com\n@company-b.com`}\n              rows={6}\n              required\n            />\n            <p className=\"text-xs text-muted-foreground\">\n              Prefix domains with @ (e.g., @company.com). One entry per line.\n            </p>\n          </div>\n\n          <DialogFooter>\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={onClose}\n              disabled={isSubmitting}\n            >\n              Cancel\n            </Button>\n            <Button type=\"submit\" disabled={isSubmitting}>\n              {isSubmitting\n                ? \"Saving...\"\n                : step\n                  ? \"Update Step\"\n                  : \"Create Step\"}\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "ee/features/workflows/components/step-list.tsx",
    "content": "import { PencilIcon, PlusIcon, TrashIcon } from \"lucide-react\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface WorkflowStep {\n  id: string;\n  name: string;\n  stepOrder: number;\n  conditions: {\n    logic: \"AND\" | \"OR\";\n    items: Array<{\n      type: \"email\" | \"domain\";\n      operator: string;\n      value: string | string[];\n    }>;\n  };\n  actions: Array<{\n    type: \"route\";\n    targetLinkId: string;\n  }>;\n  targetLink?: {\n    id: string;\n    name: string | null;\n    slug: string | null;\n    linkType: string;\n  };\n}\n\ninterface StepListProps {\n  steps: WorkflowStep[];\n  onAddStep: () => void;\n  onEditStep: (step: WorkflowStep) => void;\n  onDeleteStep: (stepId: string) => void;\n}\n\nexport function StepList({\n  steps,\n  onAddStep,\n  onEditStep,\n  onDeleteStep,\n}: StepListProps) {\n  return (\n    <div>\n      <div className=\"mb-4 flex items-center justify-between\">\n        <div>\n          <h3 className=\"text-lg font-medium\">Routing Steps</h3>\n          <p className=\"text-sm text-muted-foreground\">\n            Steps are evaluated in order (priority-based)\n          </p>\n        </div>\n        <Button onClick={onAddStep}>\n          <PlusIcon className=\"mr-2 h-4 w-4\" />\n          Add Step\n        </Button>\n      </div>\n\n      {steps.length === 0 ? (\n        <div className=\"rounded-lg border border-dashed py-12 text-center\">\n          <p className=\"text-sm text-muted-foreground\">\n            No routing steps yet. Add your first step to start routing visitors.\n          </p>\n        </div>\n      ) : (\n        <div className=\"space-y-3\">\n          {steps.map((step, index) => (\n            <div key={step.id} className=\"rounded-lg border bg-card p-4\">\n              <div className=\"flex items-start justify-between\">\n                <div className=\"flex-1\">\n                  <div className=\"flex items-center gap-2\">\n                    <Badge variant=\"outline\">Step {index + 1}</Badge>\n                    <span className=\"font-medium\">{step.name}</span>\n                  </div>\n                  <div className=\"mt-2 space-y-1 text-sm text-muted-foreground\">\n                    <p>\n                      <strong>If:</strong> Match{\" \"}\n                      {step.conditions.logic === \"AND\" ? \"all\" : \"any\"} of{\" \"}\n                      {step.conditions.items.length} condition(s)\n                    </p>\n                    {step.targetLink && (\n                      <p>\n                        <strong>Then:</strong> Route to{\" \"}\n                        {step.targetLink.name || step.targetLink.slug || \"link\"}\n                      </p>\n                    )}\n                  </div>\n                </div>\n                <div className=\"flex gap-2\">\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => onEditStep(step)}\n                  >\n                    <PencilIcon className=\"h-4 w-4\" />\n                  </Button>\n                  <Button\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    onClick={() => onDeleteStep(step.id)}\n                  >\n                    <TrashIcon className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n              </div>\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/workflows/components/workflow-access-view.tsx",
    "content": "import { useState } from \"react\";\n\nimport NotFound from \"@/pages/404\";\nimport { Brand, DataroomBrand } from \"@prisma/client\";\nimport { toast } from \"sonner\";\n\nimport AccessForm, {\n  DEFAULT_ACCESS_FORM_TYPE,\n} from \"@/components/view/access-form\";\nimport EmailVerificationMessage from \"@/components/view/access-form/email-verification-form\";\n\ninterface WorkflowAccessViewProps {\n  entryLinkId: string;\n  domain?: string;\n  slug?: string;\n  brand?: Partial<Brand> | Partial<DataroomBrand> | null;\n}\n\nexport default function WorkflowAccessView({\n  entryLinkId,\n  domain,\n  slug,\n  brand,\n}: WorkflowAccessViewProps) {\n  const [data, setData] = useState<DEFAULT_ACCESS_FORM_TYPE>({\n    email: \"\",\n    password: \"\",\n  });\n  const [code, setCode] = useState<string | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n  const [showVerification, setShowVerification] = useState(false);\n  const [isInvalidCode, setIsInvalidCode] = useState(false);\n  const [showNotFound, setShowNotFound] = useState(false);\n\n  // Build API endpoints based on whether we have domain/slug or just linkId\n  const verifyEndpoint =\n    domain && slug\n      ? `/api/workflow-entry/domains/${domain}/${slug}/verify`\n      : `/api/workflow-entry/link/${entryLinkId}/verify`;\n\n  const accessEndpoint =\n    domain && slug\n      ? `/api/workflow-entry/domains/${domain}/${slug}/access`\n      : `/api/workflow-entry/link/${entryLinkId}/access`;\n\n  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {\n    e.preventDefault();\n\n    if (!data.email) {\n      toast.error(\"Email is required\");\n      return;\n    }\n\n    // If we're in verification mode, verify the code\n    if (showVerification && code) {\n      await handleVerifyCode();\n      return;\n    }\n\n    // Otherwise, request OTP\n    await handleRequestOTP();\n  };\n\n  const handleRequestOTP = async () => {\n    setIsLoading(true);\n\n    try {\n      const response = await fetch(verifyEndpoint, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          email: data.email,\n        }),\n      });\n\n      const result = await response.json();\n\n      if (!response.ok) {\n        // Show 404 for inactive/deleted workflows\n        if (response.status === 403 && result.error?.includes(\"inactive\")) {\n          setShowNotFound(true);\n          return;\n        }\n        if (response.status === 404) {\n          setShowNotFound(true);\n          return;\n        }\n        throw new Error(result.error || \"Failed to send verification code\");\n      }\n\n      setShowVerification(true);\n      toast.success(\"Verification code sent to your email\");\n    } catch (error) {\n      console.error(\"Error requesting OTP:\", error);\n      toast.error(\n        error instanceof Error\n          ? error.message\n          : \"Failed to send verification code\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleVerifyCode = async () => {\n    if (!code || code.length !== 6) {\n      setIsInvalidCode(true);\n      return;\n    }\n\n    setIsLoading(true);\n    setIsInvalidCode(false);\n\n    try {\n      const response = await fetch(accessEndpoint, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          email: data.email,\n          code: code,\n        }),\n      });\n\n      const result = await response.json();\n\n      console.log(\"--------------------------------\");\n      console.log(\"result\", result);\n      console.log(\"--------------------------------\");\n\n      if (!response.ok) {\n        // Show 404 for inactive/deleted workflows\n        if (response.status === 403 && result.error?.includes(\"inactive\")) {\n          setShowNotFound(true);\n          return;\n        }\n        if (response.status === 404) {\n          setShowNotFound(true);\n          return;\n        }\n        if (result.resetVerification) {\n          // Reset to email entry if verification expired/invalid\n          setShowVerification(false);\n          setCode(null);\n        }\n        throw new Error(result.error || \"Failed to verify code\");\n      }\n\n      // Redirect to target URL\n      if (result.targetUrl) {\n        window.location.href = result.targetUrl;\n      } else {\n        throw new Error(\"No target URL provided\");\n      }\n    } catch (error) {\n      console.error(\"Error verifying code:\", error);\n      setIsInvalidCode(true);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to verify code\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  if (showNotFound) {\n    return <NotFound message=\"Sorry, this workflow is no longer available.\" />;\n  }\n\n  if (showVerification) {\n    return (\n      <EmailVerificationMessage\n        onSubmitHandler={handleSubmit}\n        data={data}\n        isLoading={isLoading}\n        code={code}\n        setCode={setCode}\n        isInvalidCode={isInvalidCode}\n        setIsInvalidCode={setIsInvalidCode}\n        brand={brand}\n      />\n    );\n  }\n\n  return (\n    <AccessForm\n      data={data}\n      email={data.email}\n      setData={setData}\n      onSubmitHandler={handleSubmit}\n      brand={brand}\n      requireEmail={true}\n      requirePassword={false}\n      isLoading={isLoading}\n      linkId={entryLinkId}\n      disableEditEmail={false}\n      linkWelcomeMessage=\"Enter your email to access the workflow\"\n    />\n  );\n}\n"
  },
  {
    "path": "ee/features/workflows/components/workflow-empty-state.tsx",
    "content": "import { WorkflowIcon } from \"lucide-react\";\n\ninterface WorkflowEmptyStateProps {\n  title: string;\n  description: string;\n}\n\nexport function WorkflowEmptyState({\n  title,\n  description,\n}: WorkflowEmptyStateProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center space-y-4 rounded-lg border border-dashed py-12\">\n      <div className=\"rounded-full bg-muted p-3\">\n        <WorkflowIcon className=\"h-6 w-6 text-muted-foreground\" />\n      </div>\n      <div className=\"text-center\">\n        <h3 className=\"font-medium\">{title}</h3>\n        <p className=\"mt-1 max-w-sm text-sm text-muted-foreground\">\n          {description}\n        </p>\n      </div>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "ee/features/workflows/components/workflow-header.tsx",
    "content": "import { useState } from \"react\";\nimport { useRouter } from \"next/router\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\nimport { TrashIcon, CopyIcon } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\n\ninterface WorkflowHeaderProps {\n  workflowId: string;\n  teamId: string;\n  name: string;\n  description: string | null;\n  isActive: boolean;\n  entryUrl: string;\n  onUpdate: () => void;\n}\n\nexport function WorkflowHeader({\n  workflowId,\n  teamId,\n  name,\n  description,\n  isActive,\n  entryUrl,\n  onUpdate,\n}: WorkflowHeaderProps) {\n  const router = useRouter();\n  const [showDeleteDialog, setShowDeleteDialog] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n\n  const handleToggleActive = async () => {\n    // Validate IDs to prevent SSRF\n    const workflowIdValidation = z.string().cuid().safeParse(workflowId);\n    const teamIdValidation = z.string().cuid().safeParse(teamId);\n    if (!workflowIdValidation.success || !teamIdValidation.success) {\n      toast.error(\"Invalid workflow or team ID\");\n      return;\n    }\n\n    try {\n      const response = await fetch(`/api/workflows/${workflowId}?teamId=${teamId}`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          isActive: !isActive,\n        }),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to update workflow\");\n      }\n\n      onUpdate();\n      toast.success(isActive ? \"Workflow deactivated\" : \"Workflow activated\");\n    } catch (error) {\n      toast.error(\"Failed to update workflow\");\n    }\n  };\n\n  const handleDeleteWorkflow = async () => {\n    // Validate IDs to prevent SSRF\n    const workflowIdValidation = z.string().cuid().safeParse(workflowId);\n    const teamIdValidation = z.string().cuid().safeParse(teamId);\n    if (!workflowIdValidation.success || !teamIdValidation.success) {\n      toast.error(\"Invalid workflow or team ID\");\n      setShowDeleteDialog(false);\n      return;\n    }\n\n    setIsDeleting(true);\n\n    try {\n      const response = await fetch(`/api/workflows/${workflowId}?teamId=${teamId}`, {\n        method: \"DELETE\",\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to delete workflow\");\n      }\n\n      toast.success(\"Workflow deleted\");\n      router.push(\"/workflows\");\n    } catch (error) {\n      toast.error(\"Failed to delete workflow\");\n      setIsDeleting(false);\n    }\n  };\n\n  const copyToClipboard = (text: string) => {\n    navigator.clipboard.writeText(text);\n    toast.success(\"Copied to clipboard\");\n  };\n\n  return (\n    <>\n      <div className=\"space-y-6\">\n        {/* Header */}\n        <div className=\"flex items-start justify-between\">\n          <div>\n            <h1 className=\"text-2xl font-semibold tracking-tight sm:text-3xl\">\n              {name}\n            </h1>\n            {description && (\n              <p className=\"mt-1 text-sm text-muted-foreground\">\n                {description}\n              </p>\n            )}\n          </div>\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex items-center gap-2\">\n              <Label htmlFor=\"active-toggle\" className=\"text-sm\">\n                {isActive ? \"Active\" : \"Inactive\"}\n              </Label>\n              <Switch\n                id=\"active-toggle\"\n                checked={isActive}\n                onCheckedChange={handleToggleActive}\n              />\n            </div>\n            <Button\n              variant=\"destructive\"\n              size=\"sm\"\n              onClick={() => setShowDeleteDialog(true)}\n            >\n              <TrashIcon className=\"mr-2 h-4 w-4\" />\n              Delete\n            </Button>\n          </div>\n        </div>\n\n        {/* Entry Link */}\n        <div className=\"rounded-lg border bg-card p-4\">\n          <h3 className=\"text-sm font-medium\">Entry Link</h3>\n          <div className=\"mt-2 flex items-center gap-2\">\n            <code className=\"flex-1 rounded bg-muted px-3 py-2 text-sm\">\n              {entryUrl}\n            </code>\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={() => copyToClipboard(entryUrl)}\n            >\n              <CopyIcon className=\"h-4 w-4\" />\n            </Button>\n          </div>\n        </div>\n      </div>\n\n      {/* Delete Dialog */}\n      <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>Delete Workflow</DialogTitle>\n            <DialogDescription>\n              Are you sure you want to delete this workflow? This action cannot\n              be undone.\n            </DialogDescription>\n          </DialogHeader>\n          <DialogFooter>\n            <Button\n              variant=\"outline\"\n              onClick={() => setShowDeleteDialog(false)}\n              disabled={isDeleting}\n            >\n              Cancel\n            </Button>\n            <Button\n              variant=\"destructive\"\n              onClick={handleDeleteWorkflow}\n              disabled={isDeleting}\n            >\n              {isDeleting ? \"Deleting...\" : \"Delete Workflow\"}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n\n"
  },
  {
    "path": "ee/features/workflows/components/workflow-list.tsx",
    "content": "import Link from \"next/link\";\n\nimport { timeAgo } from \"@/lib/utils\";\n\nimport { Badge } from \"@/components/ui/badge\";\n\ninterface Workflow {\n  id: string;\n  name: string;\n  description: string | null;\n  isActive: boolean;\n  createdAt: string;\n  entryLink: {\n    id: string;\n    slug: string | null;\n    domainSlug: string | null;\n  };\n  _count: {\n    steps: number;\n    executions: number;\n  };\n}\n\ninterface WorkflowListProps {\n  workflows: Workflow[];\n}\n\nexport function WorkflowList({ workflows }: WorkflowListProps) {\n  const getEntryUrl = (workflow: Workflow) => {\n    if (workflow.entryLink.domainSlug && workflow.entryLink.slug) {\n      return `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`;\n    }\n    return `${process.env.NEXT_PUBLIC_MARKETING_URL || \"https://www.papermark.com\"}/view/${workflow.entryLink.id}`;\n  };\n\n  return (\n    <div className=\"grid grid-cols-1 gap-3\">\n      {workflows.map((workflow) => (\n        <Link\n          key={workflow.id}\n          href={`/workflows/${workflow.id}`}\n          className=\"group rounded-xl border bg-card p-5 transition-all hover:border-gray-400 hover:shadow-sm dark:border-gray-700 dark:bg-secondary dark:hover:border-gray-600\"\n        >\n          <div className=\"flex items-start justify-between\">\n            <div className=\"flex-1\">\n              <div className=\"flex items-center gap-2\">\n                <h4 className=\"font-semibold text-foreground group-hover:text-primary\">\n                  {workflow.name}\n                </h4>\n                {!workflow.isActive && (\n                  <Badge variant=\"secondary\">Inactive</Badge>\n                )}\n              </div>\n              {workflow.description && (\n                <p className=\"mt-1 text-sm text-muted-foreground\">\n                  {workflow.description}\n                </p>\n              )}\n              <div className=\"mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n                <span className=\"font-mono text-xs\">\n                  {getEntryUrl(workflow)}\n                </span>\n                <span>•</span>\n                <span>{workflow._count.steps} steps</span>\n                <span>•</span>\n                <span>{workflow._count.executions} executions</span>\n                <span>•</span>\n                <span>Created {timeAgo(workflow.createdAt)}</span>\n              </div>\n            </div>\n          </div>\n        </Link>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "ee/features/workflows/index.ts",
    "content": "// Workflow Components\nexport * from \"./components\";\n\n// Workflow Engine\nexport { WorkflowEngine } from \"./lib/engine\";\n\n// Workflow Types & Validation\nexport * from \"./lib/types\";\nexport * from \"./lib/validation\";\n"
  },
  {
    "path": "ee/features/workflows/lib/engine.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\nimport {\n  type Action,\n  ActionSchema,\n  type Condition,\n  ConditionsConfigSchema,\n  type StepExecutionResult,\n  type WorkflowExecutionContext,\n  type WorkflowExecutionResult,\n} from \"./types\";\n\nexport class WorkflowEngine {\n  /**\n   * Execute a workflow by entry link ID\n   */\n  async execute(\n    entryLinkId: string,\n    context: WorkflowExecutionContext,\n  ): Promise<WorkflowExecutionResult> {\n    try {\n      // 1. Find workflow by entry link\n      const workflow = await prisma.workflow.findUnique({\n        where: { entryLinkId },\n        include: {\n          steps: {\n            orderBy: { stepOrder: \"asc\" },\n          },\n        },\n      });\n\n      if (!workflow) {\n        return {\n          success: false,\n          error: \"Workflow not found\",\n        };\n      }\n\n      if (!workflow.isActive) {\n        return {\n          success: false,\n          error: \"Workflow is not active\",\n        };\n      }\n\n      if (workflow.steps.length === 0) {\n        return {\n          success: false,\n          error: \"Workflow has no routing steps configured\",\n        };\n      }\n\n      // 2. Create execution record\n      const execution = await prisma.workflowExecution.create({\n        data: {\n          workflowId: workflow.id,\n          visitorEmail: context.visitorEmail,\n          visitorIp: context.visitorIp,\n          status: \"IN_PROGRESS\",\n          metadata: context.metadata || {},\n        },\n      });\n\n      try {\n        // 3. Execute steps in order until a match is found\n        let targetLinkId: string | undefined;\n        let targetDocumentId: string | undefined;\n        let targetDataroomId: string | undefined;\n\n        for (const step of workflow.steps) {\n          const startTime = Date.now();\n\n          try {\n            // Parse step data\n            const conditions = ConditionsConfigSchema.parse(step.conditions);\n            const actions = (step.actions as any[]).map((action) =>\n              ActionSchema.parse(action),\n            );\n\n            // Evaluate conditions\n            const conditionsMatched = await this.evaluateConditions(\n              conditions,\n              context,\n            );\n\n            let actionsResult: (Action & { routed: boolean }) | undefined;\n\n            // If conditions matched, execute the route action\n            if (conditionsMatched && actions.length > 0) {\n              const routeAction = actions[0]; // Only support single route action for now\n              if (routeAction.type === \"route\") {\n                targetLinkId = routeAction.targetLinkId;\n                targetDocumentId = routeAction.targetDocumentId;\n                targetDataroomId = routeAction.targetDataroomId;\n                actionsResult = { ...routeAction, routed: true };\n              }\n            }\n\n            // Log step execution\n            await prisma.workflowStepLog.create({\n              data: {\n                executionId: execution.id,\n                workflowStepId: step.id,\n                conditionsMatched,\n                conditionResults: conditions,\n                ...(actionsResult ? { actionsExecuted: [actionsResult] } : {}),\n                duration: Date.now() - startTime,\n              },\n            });\n\n            // If we found a match, stop processing further steps\n            if (conditionsMatched && targetLinkId) {\n              break;\n            }\n          } catch (stepError) {\n            // Log step error\n            await prisma.workflowStepLog.create({\n              data: {\n                executionId: execution.id,\n                workflowStepId: step.id,\n                conditionsMatched: false,\n                error:\n                  stepError instanceof Error\n                    ? stepError.message\n                    : \"Unknown error\",\n                duration: Date.now() - startTime,\n              },\n            });\n            console.error(`Error executing step ${step.id}:`, stepError);\n          }\n        }\n\n        // 4. Check if we found a target link\n        if (!targetLinkId) {\n          await prisma.workflowExecution.update({\n            where: { id: execution.id },\n            data: {\n              status: \"COMPLETED\",\n              completedAt: new Date(),\n              result: {\n                success: false,\n                error: \"No matching routing rule found for this email\",\n              },\n            },\n          });\n\n          return {\n            success: false,\n            error: \"No matching routing rule found for this email\",\n            executionId: execution.id,\n          };\n        }\n\n        // 5. Fetch target link details\n        const targetLink = await prisma.link.findUnique({\n          where: { id: targetLinkId },\n          select: {\n            id: true,\n            linkType: true,\n            documentId: true,\n            dataroomId: true,\n            domainSlug: true,\n            slug: true,\n          },\n        });\n\n        if (!targetLink) {\n          await prisma.workflowExecution.update({\n            where: { id: execution.id },\n            data: {\n              status: \"FAILED\",\n              completedAt: new Date(),\n              result: {\n                success: false,\n                error: \"Target link not found\",\n              },\n            },\n          });\n\n          return {\n            success: false,\n            error: \"Target link not found\",\n            executionId: execution.id,\n          };\n        }\n\n        // 6. Build target URL\n        let targetUrl: string;\n        if (targetLink.domainSlug && targetLink.slug) {\n          targetUrl = `https://${targetLink.domainSlug}/${targetLink.slug}`;\n        } else {\n          targetUrl = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${targetLink.id}`;\n        }\n\n        // 7. Mark execution as completed\n        await prisma.workflowExecution.update({\n          where: { id: execution.id },\n          data: {\n            status: \"COMPLETED\",\n            completedAt: new Date(),\n            result: {\n              success: true,\n              targetLinkId: targetLink.id,\n              targetLinkType: targetLink.linkType,\n              targetUrl,\n            },\n          },\n        });\n\n        return {\n          success: true,\n          targetLinkId: targetLink.id,\n          targetLinkType: targetLink.linkType as\n            | \"DOCUMENT_LINK\"\n            | \"DATAROOM_LINK\",\n          targetDocumentId: targetLink.documentId || undefined,\n          targetDataroomId: targetLink.dataroomId || undefined,\n          targetUrl,\n          executionId: execution.id,\n        };\n      } catch (error) {\n        // Mark execution as failed\n        await prisma.workflowExecution.update({\n          where: { id: execution.id },\n          data: {\n            status: \"FAILED\",\n            completedAt: new Date(),\n            result: {\n              success: false,\n              error: error instanceof Error ? error.message : \"Unknown error\",\n            },\n          },\n        });\n        throw error;\n      }\n    } catch (error) {\n      console.error(\"Workflow execution error:\", error);\n      return {\n        success: false,\n        error:\n          error instanceof Error ? error.message : \"Workflow execution failed\",\n      };\n    }\n  }\n\n  /**\n   * Evaluate conditions for a workflow step\n   */\n  private async evaluateConditions(\n    conditionsConfig: { logic: \"AND\" | \"OR\"; items: Condition[] },\n    context: WorkflowExecutionContext,\n  ): Promise<boolean> {\n    if (!conditionsConfig || !conditionsConfig.items?.length) {\n      return true; // No conditions = always pass\n    }\n\n    const results = await Promise.all(\n      conditionsConfig.items.map((condition) =>\n        this.evaluateCondition(condition, context),\n      ),\n    );\n\n    return conditionsConfig.logic === \"AND\"\n      ? results.every((r) => r)\n      : results.some((r) => r);\n  }\n\n  /**\n   * Evaluate a single condition\n   */\n  private async evaluateCondition(\n    condition: Condition,\n    context: WorkflowExecutionContext,\n  ): Promise<boolean> {\n    switch (condition.type) {\n      case \"email\":\n        return this.evaluateEmailCondition(condition, context.visitorEmail);\n\n      case \"domain\":\n        return this.evaluateDomainCondition(condition, context.visitorEmail);\n\n      default:\n        return false;\n    }\n  }\n\n  /**\n   * Evaluate email condition\n   */\n  private evaluateEmailCondition(\n    condition: { operator: string; value: string | string[] },\n    email: string,\n  ): boolean {\n    const normalizedEmail = email.toLowerCase();\n\n    switch (condition.operator) {\n      case \"equals\":\n        return normalizedEmail === (condition.value as string).toLowerCase();\n\n      case \"contains\":\n        return normalizedEmail.includes(\n          (condition.value as string).toLowerCase(),\n        );\n\n      case \"matches\":\n        try {\n          return new RegExp(condition.value as string).test(normalizedEmail);\n        } catch {\n          return false;\n        }\n\n      case \"in_list\":\n        return (condition.value as string[]).some(\n          (v) => normalizedEmail === v.toLowerCase(),\n        );\n\n      default:\n        return false;\n    }\n  }\n\n  /**\n   * Evaluate domain condition\n   */\n  private evaluateDomainCondition(\n    condition: { operator: string; value: string | string[] },\n    email: string,\n  ): boolean {\n    const domain = email.split(\"@\")[1]?.toLowerCase();\n    if (!domain) return false;\n\n    switch (condition.operator) {\n      case \"equals\":\n        return domain === (condition.value as string).toLowerCase();\n\n      case \"contains\":\n        return domain.includes((condition.value as string).toLowerCase());\n\n      case \"in_list\":\n        return (condition.value as string[]).some(\n          (v) => domain === v.toLowerCase(),\n        );\n\n      default:\n        return false;\n    }\n  }\n}\n"
  },
  {
    "path": "ee/features/workflows/lib/types.ts",
    "content": "import { z } from \"zod\";\n\n// ============ CONDITIONS ============\n\nexport const EmailConditionSchema = z.object({\n  type: z.literal(\"email\"),\n  operator: z.enum([\"equals\", \"contains\", \"matches\", \"in_list\"]),\n  value: z.union([z.string(), z.array(z.string())]),\n});\n\nexport const DomainConditionSchema = z.object({\n  type: z.literal(\"domain\"),\n  operator: z.enum([\"equals\", \"contains\", \"in_list\"]),\n  value: z.union([z.string(), z.array(z.string())]),\n});\n\nexport const ConditionSchema = z.discriminatedUnion(\"type\", [\n  EmailConditionSchema,\n  DomainConditionSchema,\n]);\n\nexport type EmailCondition = z.infer<typeof EmailConditionSchema>;\nexport type DomainCondition = z.infer<typeof DomainConditionSchema>;\nexport type Condition = z.infer<typeof ConditionSchema>;\n\n// ============ ACTIONS ============\n\nexport const RouteActionSchema = z.object({\n  type: z.literal(\"route\"),\n  targetLinkId: z.string().cuid(),\n  // These are populated from the target link for reference\n  targetDocumentId: z.string().cuid().optional(),\n  targetDataroomId: z.string().cuid().optional(),\n});\n\nexport const ActionSchema = z.discriminatedUnion(\"type\", [RouteActionSchema]);\n\nexport type RouteAction = z.infer<typeof RouteActionSchema>;\nexport type Action = z.infer<typeof ActionSchema>;\n\n// ============ WORKFLOW STEP DEFINITION ============\n\nexport const ConditionsConfigSchema = z.object({\n  logic: z.enum([\"AND\", \"OR\"]),\n  items: z.array(ConditionSchema),\n});\n\nexport const WorkflowStepDefinitionSchema = z.object({\n  name: z.string().min(1, \"Step name is required\"),\n  stepOrder: z.number().int().nonnegative(),\n  stepType: z.literal(\"ROUTER\"),\n  conditions: ConditionsConfigSchema,\n  actions: z.array(ActionSchema).min(1, \"At least one action is required\"),\n});\n\nexport type ConditionsConfig = z.infer<typeof ConditionsConfigSchema>;\nexport type WorkflowStepDefinition = z.infer<\n  typeof WorkflowStepDefinitionSchema\n>;\n\n// ============ WORKFLOW API SCHEMAS ============\n\nexport const CreateWorkflowRequestSchema = z.object({\n  name: z.string().min(1, \"Workflow name is required\").max(100),\n  description: z.string().max(500).optional(),\n  // Entry link details\n  domain: z.string().nullish(), // null or undefined means papermark.com\n  slug: z\n    .string()\n    .regex(\n      /^[a-zA-Z0-9-]+$/,\n      \"Slug must contain only letters, numbers, and hyphens\",\n    )\n    .min(1)\n    .max(100)\n    .nullish(), // slug is only required for custom domains, not for papermark.com\n});\n\nexport const UpdateWorkflowRequestSchema = z.object({\n  name: z.string().min(1).max(100).optional(),\n  description: z.string().max(500).optional().nullable(),\n  isActive: z.boolean().optional(),\n});\n\nexport const CreateWorkflowStepRequestSchema = z.object({\n  name: z.string().min(1, \"Step name is required\"),\n  conditions: ConditionsConfigSchema,\n  actions: z.array(ActionSchema).min(1, \"At least one action is required\"),\n});\n\nexport const UpdateWorkflowStepRequestSchema = z.object({\n  name: z.string().min(1).optional(),\n  conditions: ConditionsConfigSchema.optional(),\n  actions: z.array(ActionSchema).min(1).optional(),\n});\n\nexport const ReorderStepsRequestSchema = z.object({\n  steps: z.array(\n    z.object({\n      stepId: z.string().cuid(),\n      stepOrder: z.number().int().nonnegative(),\n    }),\n  ),\n});\n\nexport const VerifyEmailRequestSchema = z.object({\n  email: z.string().email(\"Invalid email address\"),\n});\n\nexport const AccessRequestSchema = z.object({\n  email: z.string().email(\"Invalid email address\"),\n  code: z.string().regex(/^\\d{6}$/, \"Verification code must be 6 digits\"),\n});\n\nexport type CreateWorkflowRequest = z.infer<typeof CreateWorkflowRequestSchema>;\nexport type UpdateWorkflowRequest = z.infer<typeof UpdateWorkflowRequestSchema>;\nexport type CreateWorkflowStepRequest = z.infer<\n  typeof CreateWorkflowStepRequestSchema\n>;\nexport type UpdateWorkflowStepRequest = z.infer<\n  typeof UpdateWorkflowStepRequestSchema\n>;\nexport type ReorderStepsRequest = z.infer<typeof ReorderStepsRequestSchema>;\nexport type VerifyEmailRequest = z.infer<typeof VerifyEmailRequestSchema>;\nexport type AccessRequest = z.infer<typeof AccessRequestSchema>;\n\n// ============ WORKFLOW ENGINE TYPES ============\n\nexport interface WorkflowExecutionContext {\n  visitorEmail: string;\n  visitorIp?: string;\n  userAgent?: string;\n  referrer?: string;\n  metadata?: Record<string, any>;\n}\n\nexport interface WorkflowExecutionResult {\n  success: boolean;\n  targetLinkId?: string;\n  targetLinkType?: \"DOCUMENT_LINK\" | \"DATAROOM_LINK\";\n  targetDocumentId?: string;\n  targetDataroomId?: string;\n  targetUrl?: string;\n  error?: string;\n  executionId?: string;\n}\n\nexport interface StepExecutionResult {\n  success: boolean;\n  conditionsMatched: boolean;\n  actionsResult?: any;\n  error?: string;\n}\n"
  },
  {
    "path": "ee/features/workflows/lib/validation.ts",
    "content": "import { ZodError } from \"zod\";\n\nimport {\n  type Action,\n  ActionSchema,\n  type ConditionsConfig,\n  ConditionsConfigSchema,\n  CreateWorkflowRequestSchema,\n  CreateWorkflowStepRequestSchema,\n  ReorderStepsRequestSchema,\n  UpdateWorkflowRequestSchema,\n  UpdateWorkflowStepRequestSchema,\n  type WorkflowStepDefinition,\n  WorkflowStepDefinitionSchema,\n} from \"./types\";\n\nexport {\n  CreateWorkflowRequestSchema,\n  UpdateWorkflowRequestSchema,\n  CreateWorkflowStepRequestSchema,\n  UpdateWorkflowStepRequestSchema,\n  ReorderStepsRequestSchema,\n};\n\n/**\n * Validates workflow step conditions JSON against the schema\n */\nexport function validateConditions(\n  conditions: unknown,\n): { valid: true; data: ConditionsConfig } | { valid: false; error: string } {\n  try {\n    const data = ConditionsConfigSchema.parse(conditions);\n    return { valid: true, data };\n  } catch (error) {\n    if (error instanceof ZodError) {\n      return {\n        valid: false,\n        error: error.errors\n          .map((e) => `${e.path.join(\".\")}: ${e.message}`)\n          .join(\", \"),\n      };\n    }\n    return { valid: false, error: \"Invalid conditions format\" };\n  }\n}\n\n/**\n * Validates workflow step actions JSON against the schema\n */\nexport function validateActions(\n  actions: unknown,\n): { valid: true; data: Action[] } | { valid: false; error: string } {\n  try {\n    if (!Array.isArray(actions)) {\n      return { valid: false, error: \"Actions must be an array\" };\n    }\n    const data = actions.map((action) => ActionSchema.parse(action));\n    return { valid: true, data };\n  } catch (error) {\n    if (error instanceof ZodError) {\n      return {\n        valid: false,\n        error: error.errors\n          .map((e) => `${e.path.join(\".\")}: ${e.message}`)\n          .join(\", \"),\n      };\n    }\n    return { valid: false, error: \"Invalid actions format\" };\n  }\n}\n\n/**\n * Validates complete workflow step definition\n */\nexport function validateWorkflowStep(\n  step: unknown,\n):\n  | { valid: true; data: WorkflowStepDefinition }\n  | { valid: false; error: string } {\n  try {\n    const data = WorkflowStepDefinitionSchema.parse(step);\n    return { valid: true, data };\n  } catch (error) {\n    if (error instanceof ZodError) {\n      return {\n        valid: false,\n        error: error.errors\n          .map((e) => `${e.path.join(\".\")}: ${e.message}`)\n          .join(\", \"),\n      };\n    }\n    return { valid: false, error: \"Invalid workflow step format\" };\n  }\n}\n\n/**\n * Helper to format Zod validation errors for API responses\n */\nexport function formatZodError(error: ZodError): string {\n  return error.errors\n    .map((e) => `${e.path.join(\".\")}: ${e.message}`)\n    .join(\", \");\n}\n"
  },
  {
    "path": "ee/features/workflows/pages/workflow-detail.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { z } from \"zod\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport AppLayout from \"@/components/layouts/app\";\n\nimport { StepFormDialog } from \"../components/step-form-dialog\";\nimport { StepList } from \"../components/step-list\";\nimport { WorkflowHeader } from \"../components/workflow-header\";\n\ninterface Workflow {\n  id: string;\n  name: string;\n  description: string | null;\n  isActive: boolean;\n  entryLink: {\n    id: string;\n    slug: string | null;\n    domainSlug: string | null;\n  };\n  steps: WorkflowStep[];\n}\n\ninterface WorkflowStep {\n  id: string;\n  name: string;\n  stepOrder: number;\n  conditions: {\n    logic: \"AND\" | \"OR\";\n    items: Array<{\n      type: \"email\" | \"domain\";\n      operator: string;\n      value: string | string[];\n    }>;\n  };\n  actions: Array<{\n    type: \"route\";\n    targetLinkId: string;\n  }>;\n  targetLink?: {\n    id: string;\n    name: string | null;\n    slug: string | null;\n    linkType: string;\n  };\n}\n\ninterface Link {\n  id: string;\n  name: string | null;\n  slug: string | null;\n  domainSlug: string | null;\n  linkType: \"DOCUMENT_LINK\" | \"DATAROOM_LINK\";\n  documentId: string | null;\n  dataroomId: string | null;\n  allowList: string[];\n  resourceName: string | null;\n  displayName: string;\n}\n\nexport default function WorkflowDetailPage() {\n  const router = useRouter();\n  const { id: workflowId } = router.query as { id: string };\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const [showStepDialog, setShowStepDialog] = useState(false);\n  const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);\n\n  // Validate IDs to prevent SSRF\n  const validWorkflowId =\n    workflowId && z.string().cuid().safeParse(workflowId).success\n      ? workflowId\n      : null;\n  const validTeamId =\n    teamId && z.string().cuid().safeParse(teamId).success ? teamId : null;\n\n  const {\n    data: workflow,\n    error,\n    mutate,\n  } = useSWR<Workflow>(\n    validWorkflowId && validTeamId\n      ? `/api/workflows/${validWorkflowId}?teamId=${validTeamId}`\n      : null,\n    fetcher,\n  );\n\n  const { data: links } = useSWR<Link[]>(\n    validTeamId ? `/api/teams/${validTeamId}/workflow-links` : null,\n    fetcher,\n  );\n\n  const getEntryUrl = () => {\n    if (!workflow) return \"\";\n    if (workflow.entryLink.domainSlug && workflow.entryLink.slug) {\n      return `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`;\n    }\n    return `${process.env.NEXT_PUBLIC_MARKETING_URL || \"https://www.papermark.com\"}/view/${workflow.entryLink.id}`;\n  };\n\n  const handleDeleteStep = async (stepId: string) => {\n    // Validate IDs to prevent SSRF\n    const workflowIdValidation = z.string().cuid().safeParse(workflowId);\n    const stepIdValidation = z.string().cuid().safeParse(stepId);\n    const teamIdValidation = z.string().cuid().safeParse(teamId);\n\n    if (\n      !workflowIdValidation.success ||\n      !stepIdValidation.success ||\n      !teamIdValidation.success\n    ) {\n      toast.error(\"Invalid workflow, step, or team ID\");\n      return;\n    }\n\n    try {\n      const response = await fetch(\n        `/api/workflows/${workflowId}/steps/${stepId}?teamId=${teamId}`,\n        {\n          method: \"DELETE\",\n        },\n      );\n\n      if (!response.ok) {\n        throw new Error(\"Failed to delete step\");\n      }\n\n      mutate();\n      toast.success(\"Step deleted\");\n    } catch (error) {\n      toast.error(\"Failed to delete step\");\n    }\n  };\n\n  if (error) {\n    return (\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 px-1\">\n          <div className=\"mt-8 text-center\">\n            <p className=\"text-sm text-muted-foreground\">\n              Failed to load workflow\n            </p>\n          </div>\n        </main>\n      </AppLayout>\n    );\n  }\n\n  if (!workflow) {\n    return (\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 px-1\">\n          <div className=\"mt-8 text-center\">\n            <p className=\"text-sm text-muted-foreground\">Loading workflow...</p>\n          </div>\n        </main>\n      </AppLayout>\n    );\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <div className=\"max-w-4xl space-y-6\">\n          <WorkflowHeader\n            workflowId={workflowId}\n            teamId={teamId!}\n            name={workflow.name}\n            description={workflow.description}\n            isActive={workflow.isActive}\n            entryUrl={getEntryUrl()}\n            onUpdate={mutate}\n          />\n\n          <StepList\n            steps={workflow.steps}\n            onAddStep={() => {\n              setEditingStep(null);\n              setShowStepDialog(true);\n            }}\n            onEditStep={(step) => {\n              setEditingStep(step);\n              setShowStepDialog(true);\n            }}\n            onDeleteStep={handleDeleteStep}\n          />\n        </div>\n\n        {/* Step Form Dialog */}\n        {showStepDialog && validWorkflowId && validTeamId && (\n          <StepFormDialog\n            workflowId={validWorkflowId}\n            teamId={validTeamId}\n            step={editingStep}\n            links={links || []}\n            open={showStepDialog}\n            onClose={() => {\n              setShowStepDialog(false);\n              setEditingStep(null);\n            }}\n            onSuccess={() => {\n              mutate();\n              setShowStepDialog(false);\n              setEditingStep(null);\n            }}\n          />\n        )}\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "ee/features/workflows/pages/workflow-new.tsx",
    "content": "import { useState } from \"react\";\nimport { useRouter } from \"next/router\";\nimport { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport { fetcher } from \"@/lib/utils\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\ninterface Domain {\n  id: string;\n  slug: string;\n}\n\nexport default function NewWorkflowPage() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const [name, setName] = useState(\"\");\n  const [description, setDescription] = useState(\"\");\n  const [domain, setDomain] = useState<string>(\"papermark.com\");\n  const [slug, setSlug] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n\n  const { data: domains } = useSWR<Domain[]>(\n    teamId ? `/api/teams/${teamId}/domains` : null,\n    fetcher,\n  );\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n\n    if (!name.trim()) {\n      toast.error(\"Workflow name is required\");\n      return;\n    }\n\n    if (domain !== \"papermark.com\" && !slug.trim()) {\n      toast.error(\"Entry link slug is required for custom domains\");\n      return;\n    }\n\n    setIsLoading(true);\n\n    try {\n      const response = await fetch(`/api/workflows?teamId=${teamId}`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name: name.trim(),\n          description: description.trim() || undefined,\n          domain: domain === \"papermark.com\" ? undefined : domain,\n          slug: domain === \"papermark.com\" ? undefined : slug.trim(),\n        }),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error || \"Failed to create workflow\");\n      }\n\n      const workflow = await response.json();\n      toast.success(\"Workflow created successfully\");\n      router.push(`/workflows/${workflow.id}`);\n    } catch (error) {\n      console.error(\"Error creating workflow:\", error);\n      toast.error(\n        error instanceof Error ? error.message : \"Failed to create workflow\",\n      );\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <div className=\"max-w-2xl\">\n          <div className=\"mb-6\">\n            <h1 className=\"text-2xl font-semibold tracking-tight sm:text-3xl\">\n              Create Workflow\n            </h1>\n            <p className=\"mt-1 text-sm text-muted-foreground\">\n              Set up a new routing workflow with an entry link\n            </p>\n          </div>\n\n          <form onSubmit={handleSubmit} className=\"space-y-6\">\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"name\">Workflow Name *</Label>\n              <Input\n                id=\"name\"\n                value={name}\n                onChange={(e) => setName(e.target.value)}\n                placeholder=\"e.g., Client Dashboard Routing\"\n                maxLength={100}\n                required\n              />\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"description\">Description</Label>\n              <Textarea\n                id=\"description\"\n                value={description}\n                onChange={(e) => setDescription(e.target.value)}\n                placeholder=\"Describe what this workflow does...\"\n                maxLength={500}\n                rows={3}\n              />\n            </div>\n\n            <div className=\"space-y-4\">\n              <h3 className=\"text-sm font-medium\">Entry Link</h3>\n              <p className=\"text-sm text-muted-foreground\">\n                This is the single URL visitors will use to access the workflow\n              </p>\n\n              <div className=\"space-y-2\">\n                <Label htmlFor=\"domain\">Domain</Label>\n                <Select value={domain} onValueChange={setDomain}>\n                  <SelectTrigger id=\"domain\">\n                    <SelectValue placeholder=\"papermark.com (default)\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    <SelectItem value=\"papermark.com\">papermark.com</SelectItem>\n                    {domains?.map((d) => (\n                      <SelectItem key={d.id} value={d.slug}>\n                        {d.slug}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              </div>\n\n              {domain !== \"papermark.com\" && (\n                <>\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"slug\">Slug *</Label>\n                    <Input\n                      id=\"slug\"\n                      value={slug}\n                      onChange={(e) =>\n                        setSlug(\n                          e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, \"\"),\n                        )\n                      }\n                      placeholder=\"dashboard\"\n                      pattern=\"[a-z0-9_-]+\"\n                      maxLength={100}\n                      required\n                    />\n                    <p className=\"text-xs text-muted-foreground\">\n                      Entry URL: {domain}/{slug || \"your-slug\"}\n                    </p>\n                  </div>\n                </>\n              )}\n\n              {domain === \"papermark.com\" && (\n                <p className=\"text-xs text-muted-foreground\">\n                  Entry URL will be generated automatically (e.g., papermark.com/view/clxxx...)\n                </p>\n              )}\n            </div>\n\n            <div className=\"flex gap-3 pt-4\">\n              <Button type=\"submit\" disabled={isLoading}>\n                {isLoading ? \"Creating...\" : \"Create Workflow\"}\n              </Button>\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                onClick={() => router.back()}\n                disabled={isLoading}\n              >\n                Cancel\n              </Button>\n            </div>\n          </form>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n\n"
  },
  {
    "path": "ee/features/workflows/pages/workflow-overview.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlusIcon } from \"lucide-react\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { fetcher } from \"@/lib/utils\";\n\nimport PlanBadge from \"@/components/billing/plan-badge\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\n\nimport { WorkflowEmptyState } from \"../components/workflow-empty-state\";\nimport { WorkflowList } from \"../components/workflow-list\";\n\ninterface Workflow {\n  id: string;\n  name: string;\n  description: string | null;\n  isActive: boolean;\n  createdAt: string;\n  entryLink: {\n    id: string;\n    slug: string | null;\n    domainSlug: string | null;\n  };\n  _count: {\n    steps: number;\n    executions: number;\n  };\n}\n\nexport default function WorkflowsPage() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { isFree, isPro, isTrial } = usePlan();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: workflows, error } = useSWR<Workflow[]>(\n    teamId ? `/api/workflows?teamId=${teamId}` : null,\n    fetcher,\n  );\n\n  const isLoading = !workflows && !error;\n  const requiresUpgrade = (isFree || isPro) && !isTrial;\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h1 className=\"flex items-center gap-2 text-2xl font-semibold tracking-tight text-foreground sm:text-3xl\">\n                Workflows\n                {requiresUpgrade ? <PlanBadge plan=\"Business\" /> : null}\n              </h1>\n              <p className=\"text-sm text-muted-foreground\">\n                Route visitors to different links based on their email or domain\n              </p>\n            </div>\n            {!requiresUpgrade && (\n              <Link href=\"/workflows/new\">\n                <Button>\n                  <PlusIcon className=\"mr-2 h-4 w-4\" />\n                  Create Workflow\n                </Button>\n              </Link>\n            )}\n          </div>\n\n          {requiresUpgrade ? (\n            <WorkflowEmptyState\n              title=\"Workflows require an upgrade\"\n              description=\"Upgrade to Business or Data Rooms plan to create routing workflows. Click the button below to upgrade your plan.\"\n            />\n          ) : isLoading ? (\n            <div className=\"flex items-center justify-center py-12\">\n              <div className=\"text-sm text-muted-foreground\">\n                Loading workflows...\n              </div>\n            </div>\n          ) : !workflows || workflows.length === 0 ? (\n            <WorkflowEmptyState\n              title=\"No workflows yet\"\n              description=\"Create your first routing workflow to intelligently direct visitors to different links\"\n            />\n          ) : (\n            <WorkflowList workflows={workflows} />\n          )}\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "ee/limits/constants.ts",
    "content": "// INFO: for numeric values,`null` means unlimited\n\nexport type TPlanLimits = {\n  users: number;\n  links: number | null;\n  documents: number | null;\n  domains: number;\n  datarooms: number;\n  customDomainOnPro: boolean;\n  customDomainInDataroom: boolean;\n  advancedLinkControlsOnPro: boolean | null;\n  watermarkOnBusiness?: boolean | null;\n  agreementOnBusiness?: boolean | null;\n  linkCustomFields?: number | null;\n};\n\nexport const FREE_PLAN_LIMITS = {\n  users: 1,\n  links: 50,\n  documents: 50,\n  domains: 0,\n  datarooms: 0,\n  customDomainOnPro: false,\n  customDomainInDataroom: false,\n  advancedLinkControlsOnPro: false,\n  linkCustomFields: 0,\n};\n\nexport const PRO_PLAN_LIMITS = {\n  users: 1,\n  links: null,\n  documents: 300,\n  domains: 0,\n  datarooms: 0,\n  customDomainOnPro: false,\n  customDomainInDataroom: false,\n  advancedLinkControlsOnPro: false,\n  linkCustomFields: 0,\n};\n\nexport const BUSINESS_PLAN_LIMITS = {\n  users: 3,\n  links: null,\n  documents: null,\n  domains: 5,\n  datarooms: 100,\n  customDomainOnPro: true,\n  customDomainInDataroom: false,\n  advancedLinkControlsOnPro: false,\n  linkCustomFields: 1,\n  fileSizeLimits: {\n    maxFiles: 500,\n  },\n};\n\nexport const DATAROOMS_PLAN_LIMITS = {\n  users: 3,\n  links: null,\n  documents: null,\n  domains: 10,\n  datarooms: 100,\n  customDomainOnPro: true,\n  customDomainInDataroom: true,\n  advancedLinkControlsOnPro: false,\n  linkCustomFields: 5,\n  fileSizeLimits: {\n    maxFiles: 1000,\n  },\n};\n\nexport const DATAROOMS_PLUS_PLAN_LIMITS = {\n  users: 5,\n  links: null,\n  documents: null,\n  domains: 1000,\n  datarooms: 1000,\n  customDomainOnPro: true,\n  customDomainInDataroom: true,\n  conversationsInDataroom: true,\n  advancedLinkControlsOnPro: false,\n  linkCustomFields: 5,\n  fileSizeLimits: {\n    maxFiles: 5000,\n    maxPages: 1000,\n  },\n};\n\nexport const DATAROOMS_PREMIUM_PLAN_LIMITS = {\n  users: 10,\n  links: null,\n  documents: null,\n  domains: 1000,\n  datarooms: 1000,\n  customDomainOnPro: true,\n  customDomainInDataroom: true,\n  conversationsInDataroom: true,\n  advancedLinkControlsOnPro: false,\n  linkCustomFields: 5,\n  fileSizeLimits: {\n    maxFiles: 5000,\n    maxPages: 1000,\n  },\n};\n\nexport const PAUSED_PLAN_LIMITS = {\n  // During pause: keep all data accessible but restrict new creations and views\n  canCreateLinks: false,\n  canReceiveViews: false,\n  canCreateDocuments: false,\n  canCreateDatarooms: false,\n  // Keep existing access\n  canViewAnalytics: true,\n  canAccessExistingContent: true,\n};\n"
  },
  {
    "path": "ee/limits/handler.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getLimits } from \"@/ee/limits/server\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/:teamId/limits\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // TODO: move this to a cache layer\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          plan: true,\n        },\n      });\n\n      const limits = await getLimits({ teamId, userId });\n      const isTrial = team?.plan.includes(\"drtrial\");\n      const featureFlags = await getFeatureFlags({ teamId });\n      const conversationsInDataroom =\n        featureFlags.conversations || limits.conversationsInDataroom || isTrial;\n      const dataroomUpload = featureFlags.dataroomUpload || isTrial;\n\n      return res.status(200).json({\n        ...limits,\n        conversationsInDataroom,\n        dataroomUpload,\n      });\n    } catch (error) {\n      return res.status(500).json((error as Error).message);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "ee/limits/server.ts",
    "content": "import { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\n\nimport {\n  BUSINESS_PLAN_LIMITS,\n  DATAROOMS_PLAN_LIMITS,\n  DATAROOMS_PLUS_PLAN_LIMITS,\n  DATAROOMS_PREMIUM_PLAN_LIMITS,\n  FREE_PLAN_LIMITS,\n  PRO_PLAN_LIMITS,\n  TPlanLimits,\n} from \"./constants\";\n\n// Function to determine if a plan is free or free+drtrial\nconst isFreePlan = (plan: string) => plan === \"free\" || plan === \"free+drtrial\";\nconst isTrialPlan = (plan: string) => plan.includes(\"drtrial\");\n\n// Function to get the base plan from a plan string\nconst getBasePlan = (plan: string) => plan.split(\"+\")[0];\n\nconst planLimitsMap: Record<string, TPlanLimits> = {\n  free: FREE_PLAN_LIMITS,\n  pro: PRO_PLAN_LIMITS,\n  business: BUSINESS_PLAN_LIMITS,\n  datarooms: DATAROOMS_PLAN_LIMITS,\n  \"datarooms-plus\": DATAROOMS_PLUS_PLAN_LIMITS,\n  \"datarooms-premium\": DATAROOMS_PREMIUM_PLAN_LIMITS,\n};\n\nexport const configSchema = z.object({\n  datarooms: z.number().optional(),\n  links: z\n    .preprocess((v) => (v === null ? Infinity : Number(v)), z.number())\n    .optional()\n    .default(50),\n  documents: z\n    .preprocess((v) => (v === null ? Infinity : Number(v)), z.number())\n    .optional()\n    .default(50),\n  users: z.number().optional(),\n  domains: z.number().optional(),\n  customDomainOnPro: z.boolean().optional(),\n  customDomainInDataroom: z.boolean().optional(),\n  advancedLinkControlsOnPro: z.boolean().nullish(),\n  watermarkOnBusiness: z.boolean().nullish(),\n  agreementOnBusiness: z.boolean().nullish(),\n  conversationsInDataroom: z.boolean().nullish(),\n  linkCustomFields: z.number().nullish(),\n  fileSizeLimits: z\n    .object({\n      video: z.number().optional(), // in MB\n      document: z.number().optional(), // in MB\n      image: z.number().optional(), // in MB\n      excel: z.number().optional(), // in MB\n      maxFiles: z.number().optional(), // in amount of files\n      maxPages: z.number().optional(), // in amount of pages\n    })\n    .optional(),\n});\n\nexport async function getLimits({\n  teamId,\n  userId,\n}: {\n  teamId: string;\n  userId: string;\n}) {\n  const team = await prisma.team.findUnique({\n    where: {\n      id: teamId,\n      users: {\n        some: {\n          userId: userId,\n        },\n      },\n    },\n    select: {\n      plan: true,\n      limits: true,\n      _count: {\n        select: {\n          documents: true,\n          links: true,\n          users: true,\n          invitations: true,\n        },\n      },\n    },\n  });\n\n  if (!team) {\n    throw new Error(\"Team not found\");\n  }\n\n  const documentCount = team._count.documents;\n  const linkCount = team._count.links;\n  const userCount = team._count.users + team._count.invitations;\n\n  // parse the limits json with zod and return the limits\n  // {datarooms: 1, users: 1, domains: 1, customDomainOnPro: boolean, customDomainInDataroom: boolean}\n\n  try {\n    let parsedData = configSchema.parse(team.limits);\n\n    const basePlan = getBasePlan(team.plan);\n    const isTrial = isTrialPlan(team.plan);\n    const defaultLimits = planLimitsMap[basePlan];\n\n    // Adjust limits based on the plan if they're at the default value\n    if (isFreePlan(team.plan)) {\n      return {\n        ...defaultLimits,\n        ...parsedData,\n        usage: { documents: documentCount, links: linkCount, users: userCount },\n        ...(isTrial && {\n          users: 3,\n          datarooms: Math.max(parsedData.datarooms ?? defaultLimits?.datarooms ?? 0, 1),\n        }),\n      };\n    } else {\n      return {\n        ...defaultLimits,\n        ...parsedData,\n        // if account is paid, but link and document limits are not set, then set them to Infinity\n        links: parsedData.links === 50 ? Infinity : parsedData.links,\n        documents:\n          parsedData.documents === 50 ? Infinity : parsedData.documents,\n        usage: { documents: documentCount, links: linkCount, users: userCount },\n      };\n    }\n  } catch (error) {\n    // if no limits set or parsing fails, return default limits based on the plan\n    const basePlan = getBasePlan(team.plan);\n    const isTrial = isTrialPlan(team.plan);\n    const defaultLimits = planLimitsMap[basePlan] || FREE_PLAN_LIMITS;\n    return {\n      ...defaultLimits,\n      conversationsInDataroom: false,\n      usage: { documents: documentCount, links: linkCount, users: userCount },\n      ...(isTrial && {\n        users: 3,\n        datarooms: Math.max(defaultLimits?.datarooms ?? 0, 1),\n      }),\n    };\n  }\n}\n"
  },
  {
    "path": "ee/limits/swr-handler.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\nimport { z } from \"zod\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { fetcher } from \"@/lib/utils\";\n\nimport { configSchema } from \"./server\";\n\nexport type LimitProps = z.infer<typeof configSchema> & {\n  usage: {\n    documents: number;\n    links: number;\n    users: number;\n  };\n  dataroomUpload: boolean;\n};\n\nexport function useLimits() {\n  const teamInfo = useTeam();\n  const { isFree, isTrial, isPaused } = usePlan();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data, error } = useSWR<LimitProps | null>(\n    teamId && `/api/teams/${teamId}/limits`,\n    fetcher,\n    {\n      dedupingInterval: 30000,\n    },\n  );\n\n  const canAddDocuments = data?.documents\n    ? data?.usage?.documents < data?.documents\n    : true;\n  const canAddLinks = data?.links ? data?.usage?.links < data?.links : true;\n  const canAddUsers = data?.users ? data?.usage?.users < data?.users : true;\n  const showUpgradePlanModal =\n    (isFree && !isTrial) || (isTrial && !canAddUsers);\n\n  return {\n    showUpgradePlanModal,\n    limits: data,\n    canAddDocuments,\n    canAddLinks,\n    canAddUsers,\n    isPaused,\n    error,\n    loading: !data && !error,\n  };\n}\n"
  },
  {
    "path": "ee/stripe/client.ts",
    "content": "// Stripe Client SDK\nimport { Stripe as StripeProps, loadStripe } from \"@stripe/stripe-js\";\n\nlet stripePromise: Promise<StripeProps | null>;\n\nexport const getStripe = (account: boolean = false) => {\n  if (!stripePromise) {\n    if (account) {\n      stripePromise = loadStripe(\n        process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE_OLD ??\n          process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_OLD ??\n          \"\",\n        {\n          apiVersion: \"2024-06-20\",\n        },\n      );\n    } else {\n      stripePromise = loadStripe(\n        process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_LIVE ??\n          process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ??\n          \"\",\n        {\n          apiVersion: \"2024-06-20\",\n        },\n      );\n    }\n  }\n\n  return stripePromise;\n};\n"
  },
  {
    "path": "ee/stripe/constants.ts",
    "content": "export enum PlanEnum {\n  Pro = \"Pro\",\n  Business = \"Business\",\n  DataRooms = \"Data Rooms\",\n  DataRoomsPlus = \"Data Rooms Plus\",\n  DataRoomsPremium = \"Data Rooms Premium\",\n}\n\nexport const PLAN_NAME_MAP: Record<string, string> = {\n  free: \"Free\",\n  starter: \"Starter\",\n  pro: \"Pro\",\n  business: \"Business\",\n  datarooms: \"Data Rooms\",\n  \"datarooms-plus\": \"Data Rooms Plus\",\n  \"datarooms-premium\": \"Data Rooms Premium\",\n};\n\nexport type PeriodType = \"monthly\" | \"yearly\";\n\nexport interface Feature {\n  id: string;\n  text: string;\n  highlight?: boolean;\n  tooltip?: string;\n  isCustomDomain?: boolean;\n  isUsers?: boolean;\n  usersIncluded?: number;\n  isHighlighted?: boolean;\n  isNotIncluded?: boolean;\n}\n\nexport interface PlanFeatures {\n  featureIntro: string;\n  features: Feature[];\n}\n\nexport const PLAN_PRICING = {\n  Pro: {\n    extraUserPrice: {\n      monthly: \"€29/month per additional team member\",\n      yearly: \"€24/month per additional team member\",\n    },\n  },\n  Business: {\n    extraUserPrice: {\n      monthly: \"€26/month per additional team member\",\n      yearly: \"€19/month per additional team member\",\n    },\n  },\n  \"Data Rooms\": {\n    extraUserPrice: {\n      monthly: \"€49/month per additional team member\",\n      yearly: \"€33/month per additional team member\",\n    },\n  },\n  \"Data Rooms Plus\": {\n    extraUserPrice: {\n      monthly: \"€69/month per additional team member\",\n      yearly: \"€49/month per additional team member\",\n    },\n  },\n  \"Data Rooms Premium\": {\n    extraUserPrice: {\n      monthly: \"€70/month per additional team member\",\n      yearly: \"€55/month per additional team member\",\n    },\n  },\n} as const;\n\nexport const BASE_FEATURES: Record<PlanEnum, PlanFeatures> = {\n  [PlanEnum.Pro]: {\n    featureIntro: \"Everything in Free, plus:\",\n    features: [\n      { id: \"users\", text: \"1 team member included\", isUsers: true, usersIncluded: 1 },\n      { id: \"documents\", text: \"300 documents\" },\n      { id: \"links\", text: \"Unlimited links\" },\n      { id: \"folder\", text: \"Folder organization\" },\n      { id: \"uploads\", text: \"Large file uploads\" },\n      { id: \"video\", text: \"Video sharing and analytics\" },\n      { id: \"visitors\", text: \"Visitors analytics\" },\n      { id: \"file-types-basic\", text: \"More file types: ppt, docx, excel\" },\n      { id: \"branding\", text: \"Remove Papermark branding\" },\n      { id: \"custom-branding\", text: \"Custom branding\" },\n      // { id: \"retention\", text: \"1-year analytics retention\" },\n      { id: \"no-datarooms\", text: \"No datarooms included\", isNotIncluded: true },\n    ],\n  },\n  [PlanEnum.Business]: {\n    featureIntro: \"Everything in Pro, plus:\",\n    features: [\n      {\n        id: \"users\",\n        text: \"3 team members included\",\n        isUsers: true,\n        usersIncluded: 3,\n      },\n      { \n        id: \"datarooms\", \n        text: \"Multi-file sharing\",\n        tooltip: \"Allow you to share multiple files and folders in a single links. Simplified data rooms settings for sharing multiple files and folders. \"\n      },\n      { id: \"documents\", text: \"1000 documents\" },\n      {\n        id: \"custom-domain\",\n        text: \"Custom domain for documents\",\n        isCustomDomain: true,\n      },\n     \n      \n      { id: \"custom-social-cards\", text: \"Custom social media cards\" },\n      { id: \"screenshot\", text: \"Screenshot protection\" },\n      { id: \"email-verify\", text: \"Require email verification\" },\n      { id: \"allow-block\", text: \"Allow/Block list\" },\n     \n      { id: \"webhooks\", text: \"Webhooks\" },\n      // { id: \"file-types-advanced\", text: \"More file types: dwg, kml, zip\" },\n      { id: \"retention\", text: \"2-year analytics retention\" },\n    ],\n  },\n  [PlanEnum.DataRooms]: {\n    featureIntro: \"Everything in Business, plus:\",\n    features: [\n      {\n        id: \"users\",\n        text: \"3 team members included\",\n        isUsers: true,\n        usersIncluded: 3,\n      },\n      { id: \"datarooms\", text: \"Unlimited data rooms\" },\n      // { id: \"documents\", text: \"Unlimited documents\" },\n       { id: \"folder-levels\", text: \"Unlimited folder levels\" },\n      {\n        id: \"custom-domain\",\n        text: \"Custom domain for data rooms\",\n        isCustomDomain: true,\n      },\n       { id: \"dataroom-branding\", text: \"Data room branding\" },\n      { id: \"analytics\", text: \"Data room analytics\" },\n      { id: \"nda\", text: \"NDA agreements\" },\n      { id: \"watermark\", text: \"Dynamic watermark\" },\n      { id: \"permissions\", text: \"Granular file level permissions\" },\n      { id: \"groups\", text: \"Data room groups\" },\n      // { id: \"audit\", text: \"Audit log for viewers\" },\n    \n      { id: \"onboarding\", text: \"Priority support\" },\n      // { id: \"retention\", text: \"2-year analytics retention\" },\n    ],\n  },\n  [PlanEnum.DataRoomsPlus]: {\n    featureIntro: \"Everything in Data Rooms, plus:\",\n    features: [\n      {\n        id: \"users\",\n        text: \"5 team members included\",\n        isUsers: true,\n        usersIncluded: 5,\n      },\n      { id: \"documents\", text: \"Unlimited documents in data rooms\" },\n      {\n        id: \"custom-domain\",\n        text: \"Unlimited custom domains for data rooms\",\n        isCustomDomain: true,\n        highlight: true,\n      },\n      { id: \"audit\", text: \"Audit log for visitors\" },\n      { id: \"email-invite\", text: \"Email invite viewers\" },\n\n      { id: \"qa\", text: \"Q&A module with custom permissions\" },\n      { id: \"requests\", text: \"File requests with permissions\" },\n      { id: \"indexing\", text: \"Automatic file indexing\" },\n      { id: \"invite\", text: \"Dataroom update notifications\" },\n      { id: \"soc2\", text: \"SOC 2 Type II certified\" },\n      { id: \"account-manager\", text: \"Dedicated account manager\" },\n      { id: \"support\", text: \"24/7 priority support\" },\n      // { id: \"retention\", text: \"3-year analytics retention\" },\n    ],\n  },\n  [PlanEnum.DataRoomsPremium]: {\n    featureIntro: \"Everything in Data Rooms Plus, plus:\",\n    features: [\n      {\n        id: \"teams\",\n        text: \"Multiple teams (up to 5 teams)\",\n      },\n      {\n        id: \"users\",\n        text: \"10 team members included\",\n        isUsers: true,\n        usersIncluded: 10,\n      },\n      { id: \"storage\", text: \"Unlimited encrypted storage\", highlight: true },\n      { id: \"file-size\", text: \"No file size limit\" },\n      { id: \"workflows\", text: \"Workflows\" },\n      { id: \"assign\", text: \"Assign team members\" },\n      { id: \"sso\", text: \"SSO (Single Sign-On) on request\" },\n      { id: \"whitelabel\", text: \"Whitelabeling\" },\n      { id: \"api\", text: \"Full API access\" },\n      { id: \"security\", text: \"Advanced security and certification\" },\n      { id: \"onboarding\", text: \"Priority onboarding & training\" },\n      { id: \"support\", text: \"Dedicated support team\" },\n    ],\n  },\n};\n\ninterface FeatureOptions {\n  period?: PeriodType;\n  showHighlighted?: boolean;\n  maxFeatures?: number;\n  excludeFeatures?: string[];\n  includeFeatures?: string[];\n  highlightFeatures?: string[];\n  showDataRoomsPlus?: boolean;\n}\n\nexport function getPlanFeatures(\n  plan: PlanEnum,\n  options: FeatureOptions = {},\n): PlanFeatures {\n  const {\n    period = \"monthly\",\n    showHighlighted = false,\n    maxFeatures,\n    excludeFeatures = [],\n    includeFeatures = [],\n    highlightFeatures = [],\n    showDataRoomsPlus = false,\n  } = options;\n\n  // If showing Data Rooms Plus features instead of Data Rooms\n  let effectivePlan = plan;\n  if (showDataRoomsPlus && plan === PlanEnum.DataRooms) {\n    effectivePlan = PlanEnum.DataRoomsPlus;\n  }\n\n  const basePlanFeatures = BASE_FEATURES[effectivePlan];\n  let features = [...basePlanFeatures.features];\n\n  // Filter features based on options\n  if (includeFeatures.length > 0) {\n    features = features.filter((feature) =>\n      includeFeatures.includes(feature.id),\n    );\n  }\n\n  if (excludeFeatures.length > 0) {\n    features = features.filter(\n      (feature) => !excludeFeatures.includes(feature.id),\n    );\n  }\n\n  // Add pricing tooltip for user features and handle highlighting\n  features = features.map((feature) => {\n    const newFeature = { ...feature };\n\n    if (feature.isUsers) {\n      // Use the pricing from the effective plan\n      newFeature.tooltip = PLAN_PRICING[effectivePlan].extraUserPrice[period];\n    }\n\n    // Add isHighlighted property if feature is in highlightFeatures\n    if (highlightFeatures.includes(feature.id)) {\n      newFeature.isHighlighted = true;\n    }\n\n    return newFeature;\n  });\n\n  // Only show highlighted features if specified\n  if (showHighlighted) {\n    features = features.filter((feature) => feature.highlight);\n  }\n\n  // Limit number of features if specified\n  if (maxFeatures) {\n    features = features.slice(0, maxFeatures);\n  }\n\n  return {\n    featureIntro: basePlanFeatures.featureIntro,\n    features,\n  };\n}\n"
  },
  {
    "path": "ee/stripe/functions/get-coupon-from-plan.ts",
    "content": "import { isOldAccount } from \"../utils\";\n\nconst COUPON_MAP = {\n  monthly: {\n    old: \"w15almTc\",\n    new: \"jTyAvVA6\",\n  },\n  yearly: {\n    old: \"qB9tm34e\",\n    new: \"EDTyNLr5\",\n  },\n};\n\nexport const getCouponFromPlan = (plan: string, isAnnualPlan: boolean) => {\n  const period = isAnnualPlan ? \"yearly\" : \"monthly\";\n  return isOldAccount(plan) ? COUPON_MAP[period].old : COUPON_MAP[period].new;\n};\n"
  },
  {
    "path": "ee/stripe/functions/get-display-name-from-plan.ts",
    "content": "import { PLANS } from \"../utils\";\n\nexport function getDisplayNameFromPlan(planSlug: string) {\n  const cleanPlanName = planSlug.split(\"+\")[0];\n  const plan = PLANS.find((p) => p.slug === cleanPlanName);\n  return plan?.name ?? \"paid\";\n}\n"
  },
  {
    "path": "ee/stripe/functions/get-price-id-from-plan.ts",
    "content": "import { PLANS, isOldAccount } from \"../utils\";\n\nexport function getPriceIdFromPlan({\n  planSlug,\n  planName,\n  isOld,\n  period,\n}: {\n  planSlug?: string;\n  planName?: string;\n  isOld?: boolean;\n  period: \"monthly\" | \"yearly\";\n}) {\n  if (!planSlug && !planName) {\n    throw new Error(\"Either planSlug or planName must be provided\");\n  }\n  const env =\n    process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\" ? \"production\" : \"test\";\n\n  const planIdentifier = planSlug || planName;\n  const accountType = isOld\n    ? \"old\"\n    : isOldAccount(planIdentifier!)\n      ? \"old\"\n      : \"new\";\n  const cleanPlan = planIdentifier!.split(\"+\")[0];\n\n  const plan = PLANS.find((p) =>\n    planSlug ? p.slug === cleanPlan : p.name === cleanPlan,\n  );\n\n  if (!plan) {\n    console.error(`Plan not found: ${cleanPlan}`);\n    return undefined;\n  }\n\n  return plan.price[period].priceIds[env][accountType];\n}\n"
  },
  {
    "path": "ee/stripe/functions/get-quantity-from-plan.ts",
    "content": "import { getPlanFromPriceId } from \"../utils\";\n\nexport function getQuantityFromPriceId(priceId?: string) {\n  if (!priceId) {\n    return 1;\n  }\n  try {\n    const plan = getPlanFromPriceId(priceId);\n    return plan?.minQuantity ?? 1;\n  } catch (error) {\n    console.error(\"Error getting quantity for priceId: %s\", priceId, error);\n    return 1;\n  }\n}\n"
  },
  {
    "path": "ee/stripe/functions/get-subscription-item.ts",
    "content": "import { stripeInstance } from \"..\";\n\nexport interface SubscriptionDiscount {\n  couponId: string;\n  percentOff?: number;\n  amountOff?: number;\n  duration: string;\n  durationInMonths?: number;\n  valid: boolean;\n  end?: number;\n}\n\nexport default async function getSubscriptionItem(\n  subscriptionId: string,\n  isOldAccount: boolean,\n) {\n  const stripe = stripeInstance(isOldAccount);\n  const subscription = await stripe.subscriptions.retrieve(subscriptionId, {\n    expand: [\"discount.coupon\"],\n  });\n  const subscriptionItem = subscription.items.data[0];\n\n  // Extract discount information if available\n  let discount: SubscriptionDiscount | null = null;\n  if (subscription.discount && subscription.discount.coupon) {\n    const coupon = subscription.discount.coupon;\n    discount = {\n      couponId: coupon.id,\n      percentOff: coupon.percent_off || undefined,\n      amountOff: coupon.amount_off || undefined,\n      duration: coupon.duration,\n      durationInMonths: coupon.duration_in_months || undefined,\n      valid: coupon.valid,\n      end: subscription.discount.end || undefined,\n    };\n  }\n\n  return {\n    id: subscriptionItem.id,\n    currentPeriodStart: subscription.current_period_start,\n    currentPeriodEnd: subscription.current_period_end,\n    discount,\n  };\n}\n"
  },
  {
    "path": "ee/stripe/index.ts",
    "content": "import Stripe from \"stripe\";\n\nconst stripeOld = new Stripe(\n  process.env.STRIPE_SECRET_KEY_LIVE_OLD ??\n    process.env.STRIPE_SECRET_KEY_OLD ??\n    \"\",\n  {\n    apiVersion: \"2024-06-20\",\n    appInfo: {\n      name: \"Papermark.io\",\n      version: \"0.1.0\",\n    },\n    typescript: true,\n  },\n);\n\nconst stripeNew = new Stripe(\n  process.env.STRIPE_SECRET_KEY_LIVE ?? process.env.STRIPE_SECRET_KEY ?? \"\",\n  {\n    apiVersion: \"2024-06-20\",\n    appInfo: {\n      name: \"Papermark.io\",\n      version: \"0.1.0\",\n    },\n    typescript: true,\n  },\n);\n\nexport const stripeInstance = (account: boolean = false) => {\n  return account ? stripeOld : stripeNew;\n};\n\nexport async function cancelSubscription(\n  customer?: string,\n  isOldAccount: boolean = false,\n) {\n  if (!customer) return;\n\n  try {\n    const stripe = stripeInstance(isOldAccount);\n    const subscriptionId = await stripe.subscriptions\n      .list({\n        customer,\n      })\n      .then((res) => res.data[0].id);\n\n    return await stripe.subscriptions.update(subscriptionId, {\n      cancel_at_period_end: true,\n      cancellation_details: {\n        comment: \"Customer deleted their Papermark instance.\",\n      },\n    });\n  } catch (error) {\n    return;\n  }\n}\n"
  },
  {
    "path": "ee/stripe/utils.ts",
    "content": "import Stripe from \"stripe\";\n\n// Historical price IDs that are no longer in the main PLANS configuration\n// but still need to be supported for existing subscriptions\nconst HISTORICAL_PRICE_IDS: Record<string, Record<string, string>> = {\n  production: {\n    // Business plan historical prices\n    price_1OuYeIFJyGSZ96lhwH58Y1kU: \"business\", // Old business plan\n    // Add more historical price IDs here as needed\n  },\n  test: {\n    // Add test environment historical price IDs if needed\n  },\n};\n\nfunction getHistoricalPlanFromPriceId(priceId: string, env: string) {\n  const planSlug = HISTORICAL_PRICE_IDS[env]?.[priceId];\n  if (!planSlug) {\n    return null;\n  }\n\n  // Find the current plan configuration for this slug\n  const currentPlan = PLANS.find((plan) => plan.slug === planSlug);\n  if (!currentPlan) {\n    return null;\n  }\n\n  // Return a plan object that maintains the current plan structure\n  // but indicates it's from a historical price ID\n  return {\n    ...currentPlan,\n    // Mark this as a historical price for logging purposes\n    _historical: true,\n  };\n}\n\nexport function getPlanFromPriceId(\n  priceId: string,\n  isOldAccount: boolean = false,\n) {\n  const env =\n    process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\" ? \"production\" : \"test\";\n  const accountType = isOldAccount ? \"old\" : \"new\";\n  const plan = PLANS.find(\n    (plan) =>\n      plan.price.monthly.priceIds[env][accountType] === priceId ||\n      plan.price.yearly.priceIds[env][accountType] === priceId,\n  );\n\n  if (!plan) {\n    // Check historical price IDs for known legacy prices\n    const historicalPlan = getHistoricalPlanFromPriceId(priceId, env);\n    if (historicalPlan) {\n      console.log(\n        `Found historical plan mapping for priceId: ${priceId} -> ${historicalPlan.slug}`,\n      );\n      return historicalPlan;\n    }\n\n    console.error(\n      `Plan not found for priceId: ${priceId}, isOldAccount: ${isOldAccount}, env: ${env}`,\n    );\n    // Return null instead of a fake free plan to prevent unintended downgrades\n    return null;\n  }\n\n  return plan;\n}\n\n// custom type coercion because Stripe's types are wrong\nexport function isNewCustomer(\n  previousAttributes: Partial<Stripe.Subscription> | undefined,\n) {\n  let isNewCustomer = false;\n  try {\n    if (\n      // if the user is upgrading from free to pro\n      previousAttributes?.default_payment_method === null\n    ) {\n      isNewCustomer = true;\n    }\n  } catch (error) {\n    console.error(\"An error occurred:\", error);\n  }\n  return isNewCustomer;\n}\n\nexport function isUpgradedCustomer(\n  previousAttributes: Partial<Stripe.Subscription> | undefined,\n) {\n  let isUpgradedUser = false;\n  try {\n    if (\n      // if user has items in their subscription\n      previousAttributes?.items !== undefined\n    ) {\n      isUpgradedUser = true;\n    }\n  } catch (error) {\n    console.error(\"An error occurred:\", error);\n  }\n  return isUpgradedUser;\n}\n\nexport const PLANS = [\n  {\n    name: \"Pro\",\n    slug: \"pro\",\n    minQuantity: 1,\n    price: {\n      monthly: {\n        amount: 29,\n        unitPrice: 1950,\n        priceIds: {\n          test: {\n            old: \"price_1Q3bcHFJyGSZ96lhElXBA5C1\",\n            // new: \"price_1Q8aUBBYvhH6u7U7LPIVxYpz\",\n            new: \"price_1QvgdNBYvhH6u7U7drrXAXM3\", // exp\n          },\n          production: {\n            old: \"price_1P3FK4FJyGSZ96lhD67yF3lj\",\n            // new: \"price_1Q8egtBYvhH6u7U7gq1Pbp5Z\",\n            new: \"price_1Qvk3LBYvhH6u7U7JE4V6JY0\", // exp\n          },\n        },\n      },\n      yearly: {\n        amount: 24,\n        unitPrice: 1450,\n        priceIds: {\n          test: {\n            old: \"price_1Q3bV9FJyGSZ96lhCYWIcmg5\",\n            // new: \"price_1Q8aTkBYvhH6u7U7kUiNTSLX\",\n            new: \"price_1QviTtBYvhH6u7U79PQ2rzMI\", // exp\n          },\n          production: {\n            old: \"price_1Q3gfNFJyGSZ96lh2jGhEadm\",\n            // new: \"price_1Q8egtBYvhH6u7U7T4ehn7SM\",\n            new: \"price_1Qvk3LBYvhH6u7U7kppryTjV\", // exp\n          },\n        },\n      },\n    },\n  },\n  {\n    name: \"Business\",\n    slug: \"business\",\n    minQuantity: 3,\n    price: {\n      monthly: {\n        amount: 79,\n        unitPrice: 2633,\n        priceIds: {\n          test: {\n            old: \"price_1Q3bPhFJyGSZ96lhnxpiJMwz\",\n            new: \"price_1Q8aWlBYvhH6u7U7gTeKJJ0Y\",\n          },\n          production: {\n            old: \"price_1Q3gbVFJyGSZ96lhf7hsZciQ\",\n            new: \"price_1Q8egwBYvhH6u7U7XKLGjgHL\",\n          },\n        },\n      },\n      yearly: {\n        amount: 59,\n        unitPrice: 1967,\n        priceIds: {\n          test: {\n            old: \"price_1Q3bQ5FJyGSZ96lhoS8QbYXr\",\n            new: \"price_1Q8aVSBYvhH6u7U72mn6iPfK\",\n          },\n          production: {\n            old: \"price_1Q3gbVFJyGSZ96lhqqLhBNDv\",\n            new: \"price_1Q8egwBYvhH6u7U7wRU6iPcW\",\n          },\n        },\n      },\n    },\n  },\n  {\n    name: \"Data Rooms\",\n    slug: \"datarooms\",\n    minQuantity: 3,\n    price: {\n      monthly: {\n        amount: 149,\n        unitPrice: 4967,\n        priceIds: {\n          test: {\n            old: \"price_1Q3bHPFJyGSZ96lhpQD0lMdU\",\n            new: \"price_1Q8aYLBYvhH6u7U7RUqHnsBh\",\n          },\n          production: {\n            old: \"price_1Q3gbbFJyGSZ96lhvmEwjZtm\",\n            new: \"price_1Q8egzBYvhH6u7U7IQUGzwoZ\",\n          },\n        },\n      },\n      yearly: {\n        amount: 99,\n        unitPrice: 3300,\n        priceIds: {\n          test: {\n            old: \"price_1Q3bJUFJyGSZ96lhLiEJlXlt\",\n            new: \"price_1Q8aXWBYvhH6u7U7unPGTnfy\",\n          },\n          production: {\n            old: \"price_1Q3gbbFJyGSZ96lhnk1CtnIZ\",\n            new: \"price_1Q8egzBYvhH6u7U7M2uoROMa\",\n          },\n        },\n      },\n    },\n  },\n  {\n    name: \"Data Rooms Plus\",\n    slug: \"datarooms-plus\",\n    minQuantity: 5,\n    price: {\n      monthly: {\n        amount: 349,\n        unitPrice: 6980,\n        priceIds: {\n          test: {\n            old: \"price_1QojZuFJyGSZ96lhNwiD1y2r\",\n            new: \"price_1Qw63uBYvhH6u7U7dHVZ0kWZ\",\n          },\n          production: {\n            old: \"price_1QwMmmFJyGSZ96lhhaDXmzkY\",\n            new: \"price_1QwMkABYvhH6u7U74ccUfWkq\",\n          },\n        },\n      },\n      yearly: {\n        amount: 249,\n        unitPrice: 4980,\n        priceIds: {\n          test: {\n            old: \"price_1QojaPFJyGSZ96lhods9TOxh\",\n            new: \"price_1Qw63ABYvhH6u7U7MXK3UOJF\",\n          },\n          production: {\n            old: \"price_1QwMmeFJyGSZ96lh934mFNPA\",\n            new: \"price_1QwMjABYvhH6u7U7ccxGJXKN\",\n          },\n        },\n      },\n    },\n  },\n  {\n    name: \"Data Rooms Premium\",\n    slug: \"datarooms-premium\",\n    minQuantity: 10,\n    price: {\n      monthly: {\n        amount: 699,\n        unitPrice: 6990,\n        priceIds: {\n          test: {\n            old: \"price_placeholder_test_old\",\n            new: \"price_1SUWeXBYvhH6u7U7u7CJgsRE\",\n          },\n          production: {\n            old: \"price_placeholder_prod_old\",\n            new: \"price_1SUWXqBYvhH6u7U7SJKKOCKU\",\n          },\n        },\n      },\n      yearly: {\n        amount: 549,\n        unitPrice: 5490,\n        priceIds: {\n          test: {\n            old: \"price_placeholder_test_yearly_old\",\n            new: \"price_1SUWhQBYvhH6u7U7BE6vVLcf\",\n          },\n          production: {\n            old: \"price_placeholder_prod_yearly_old\",\n            new: \"price_1SUWWqBYvhH6u7U7I5MpZ43K\",\n          },\n        },\n      },\n    },\n  },\n];\n\nexport const isOldAccount = (plan: string) => {\n  return plan.includes(\"old\");\n};\n"
  },
  {
    "path": "ee/stripe/webhooks/checkout-session-completed.ts",
    "content": "import {\n  BUSINESS_PLAN_LIMITS,\n  DATAROOMS_PLAN_LIMITS,\n  DATAROOMS_PLUS_PLAN_LIMITS,\n  DATAROOMS_PREMIUM_PLAN_LIMITS,\n  PRO_PLAN_LIMITS,\n} from \"@/ee/limits/constants\";\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { waitUntil } from \"@vercel/functions\";\nimport Stripe from \"stripe\";\n\nimport { sendUpgradePersonalEmail } from \"@/lib/emails/send-upgrade-personal-welcome\";\nimport { sendUpgradePlanEmail } from \"@/lib/emails/send-upgrade-plan\";\nimport prisma from \"@/lib/prisma\";\nimport { sendUpgradeOneMonthCheckinEmailTask } from \"@/lib/trigger/send-scheduled-email\";\nimport { log } from \"@/lib/utils\";\n\nimport { getPlanFromPriceId } from \"../utils\";\n\nexport async function checkoutSessionCompleted(\n  event: Stripe.Event,\n  isOldAccount: boolean = false,\n) {\n  const checkoutSession = event.data.object as Stripe.Checkout.Session;\n\n  if (\n    checkoutSession.client_reference_id === null ||\n    checkoutSession.customer === null\n  ) {\n    await log({\n      message: \"Missing items in Stripe webhook callback\",\n      type: \"error\",\n    });\n    return;\n  }\n\n  const stripe = stripeInstance(isOldAccount);\n  const subscription = await stripe.subscriptions.retrieve(\n    checkoutSession.subscription as string,\n  );\n  const priceId = subscription.items.data[0].price.id;\n  const subscriptionId = subscription.id;\n  const subscriptionStart = new Date(subscription.current_period_start * 1000);\n  const subscriptionEnd = new Date(subscription.current_period_end * 1000);\n  const quantity = subscription.items.data[0].quantity;\n\n  console.log(\"subscription\", subscription);\n  console.log(\"subscription items\", subscription.items.data);\n\n  const plan = getPlanFromPriceId(priceId, isOldAccount);\n\n  if (!plan) {\n    await log({\n      message: `Invalid price ID in checkout.session.completed event: ${priceId}, isOldAccount: ${isOldAccount}. Skipping webhook processing to prevent unintended plan changes.`,\n      type: \"error\",\n    });\n    return;\n  }\n\n  const stripeId = checkoutSession.customer.toString();\n  const teamId = checkoutSession.client_reference_id;\n\n  let planLimits:\n    | typeof PRO_PLAN_LIMITS\n    | typeof BUSINESS_PLAN_LIMITS\n    | typeof DATAROOMS_PLAN_LIMITS\n    | typeof DATAROOMS_PLUS_PLAN_LIMITS\n    | typeof DATAROOMS_PREMIUM_PLAN_LIMITS = structuredClone(PRO_PLAN_LIMITS);\n  if (plan.slug === \"pro\") {\n    planLimits = structuredClone(PRO_PLAN_LIMITS);\n  } else if (plan.slug === \"business\") {\n    planLimits = structuredClone(BUSINESS_PLAN_LIMITS);\n  } else if (plan.slug === \"datarooms\") {\n    planLimits = structuredClone(DATAROOMS_PLAN_LIMITS);\n  } else if (plan.slug === \"datarooms-plus\") {\n    planLimits = structuredClone(DATAROOMS_PLUS_PLAN_LIMITS);\n  } else if (plan.slug === \"datarooms-premium\") {\n    planLimits = structuredClone(DATAROOMS_PREMIUM_PLAN_LIMITS);\n  }\n\n  // Update the user limit in planLimits based on the subscription quantity\n  planLimits.users =\n    typeof quantity === \"number\" && quantity > 1 ? quantity : planLimits.users;\n\n  // Update the user with the subscription information and stripeId\n  const team = await prisma.team.update({\n    where: {\n      id: teamId,\n    },\n    data: {\n      stripeId,\n      plan: `${plan.slug}${isOldAccount ? \"+old\" : \"\"}`,\n      subscriptionId,\n      startsAt: subscriptionStart,\n      endsAt: subscriptionEnd,\n      limits: planLimits,\n      // Clear cancellation and pause state when purchasing a new plan\n      cancelledAt: null,\n      pausedAt: null,\n      pauseStartsAt: null,\n      pauseEndsAt: null,\n    },\n    select: {\n      id: true,\n      users: {\n        where: { role: \"ADMIN\" },\n        select: {\n          user: { select: { id: true, email: true, name: true } },\n        },\n      },\n    },\n  });\n\n  // if event creation time more than 1 hour ago, return\n  if (event.created < Date.now() / 1000 - 1 * 60 * 60) {\n    await log({\n      message: `Checkout session completed event created more than 1 hour ago: ${event.id}`,\n      type: \"error\",\n    });\n    return;\n  }\n\n  // Send thank you email to project owner if they are a new customer\n  waitUntil(\n    sendUpgradePlanEmail({\n      user: {\n        email: team.users[0].user.email as string,\n        name: team.users[0].user.name as string,\n      },\n      planType: plan.slug,\n    }),\n  );\n\n  // send personal welcome email\n  waitUntil(\n    sendUpgradePersonalEmail({\n      user: {\n        email: team.users[0].user.email as string,\n        name: team.users[0].user.name as string,\n      },\n      planSlug: plan.slug,\n    }),\n  );\n\n  waitUntil(\n    sendUpgradeOneMonthCheckinEmailTask.trigger(\n      {\n        to: team.users[0].user.email as string,\n        name: team.users[0].user.name as string,\n        teamId,\n      },\n      {\n        delay: \"40d\",\n      },\n    ),\n  );\n}\n"
  },
  {
    "path": "ee/stripe/webhooks/customer-subscription-deleted.ts",
    "content": "import { NextApiResponse } from \"next\";\n\nimport { FREE_PLAN_LIMITS } from \"@/ee/limits/constants\";\nimport { Prisma } from \"@prisma/client\";\nimport Stripe from \"stripe\";\n\nimport { clearTeamDomainRedirects } from \"@/lib/api/domains/clear-team-redirects\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nexport async function customerSubscriptionDeleted(\n  event: Stripe.Event,\n  res: NextApiResponse,\n) {\n  const subscriptionDeleted = event.data.object as Stripe.Subscription;\n\n  const stripeId = subscriptionDeleted.customer.toString();\n  const subscriptionId = subscriptionDeleted.id;\n\n  // get free plan limits\n  const freePlanLimits = structuredClone(FREE_PLAN_LIMITS);\n\n  try {\n    // If a team cancels their subscription, reset their limits to free\n    const team = await prisma.team.update({\n      where: {\n        stripeId,\n        subscriptionId,\n      },\n      data: {\n        plan: \"free\",\n        subscriptionId: null,\n        endsAt: null,\n        startsAt: null,\n        limits: freePlanLimits,\n        cancelledAt: null,\n        pausedAt: null,\n        pauseStartsAt: null,\n        pauseEndsAt: null,\n      },\n      select: { id: true },\n    });\n\n    await clearTeamDomainRedirects(team.id);\n\n    await log({\n      message: \":cry: Team *`\" + team.id + \"`* deleted their subscription\",\n      type: \"info\",\n    });\n  } catch (error) {\n    if (\n      error instanceof Prisma.PrismaClientKnownRequestError &&\n      error.code === \"P2025\"\n    ) {\n      await log({\n        message: `Team with Stripe ID ${stripeId} and Subscription ID ${subscriptionId} not found`,\n        type: \"error\",\n      });\n      return res\n        .status(200)\n        .send(\"Team not found in database. Customer deleted their account.\");\n    }\n    await log({\n      message: `Error updating team ${stripeId} subscription ${subscriptionId}: ${error}`,\n      type: \"error\",\n    });\n    return res\n      .status(200)\n      .send(\"Error processing subscription deletion webhook.\");\n  }\n}\n"
  },
  {
    "path": "ee/stripe/webhooks/customer-subscription-updated.ts",
    "content": "import { NextApiResponse } from \"next\";\n\nimport {\n  BUSINESS_PLAN_LIMITS,\n  DATAROOMS_PLAN_LIMITS,\n  DATAROOMS_PLUS_PLAN_LIMITS,\n  DATAROOMS_PREMIUM_PLAN_LIMITS,\n  PRO_PLAN_LIMITS,\n} from \"@/ee/limits/constants\";\nimport Stripe from \"stripe\";\n\nimport { clearTeamDomainRedirects } from \"@/lib/api/domains/clear-team-redirects\";\nimport { planSupportsRedirects } from \"@/lib/api/domains/redis\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nimport { getPlanFromPriceId } from \"../utils\";\n\nexport async function customerSubsciptionUpdated(\n  event: Stripe.Event,\n  res: NextApiResponse,\n  isOldAccount: boolean = false,\n) {\n  const subscriptionUpdated = event.data.object as Stripe.Subscription;\n  const priceId = subscriptionUpdated.items.data[0].price.id;\n\n  const plan = getPlanFromPriceId(priceId, isOldAccount);\n\n  if (!plan) {\n    await log({\n      message: `Invalid price ID in customer.subscription.updated event: ${priceId}, isOldAccount: ${isOldAccount}. Skipping webhook processing to prevent unintended plan changes.`,\n      type: \"error\",\n    });\n    return res.status(200).json({ received: true });\n  }\n\n  const stripeId = subscriptionUpdated.customer.toString();\n\n  const team = await prisma.team.findUnique({\n    where: { stripeId },\n  });\n\n  if (!team) {\n    await log({\n      message:\n        \"Team with Stripe ID *`\" +\n        stripeId +\n        \"`* not found in Stripe webhook `customer.subscription.updated` callback\",\n      type: \"error\",\n    });\n    return res.status(200).json({ received: true });\n  }\n\n  const newPlan = plan.slug;\n  const subscriptionId = subscriptionUpdated.id;\n  const startsAt = new Date(subscriptionUpdated.current_period_start * 1000);\n  const endsAt = new Date(subscriptionUpdated.current_period_end * 1000);\n  const quantity = subscriptionUpdated.items.data[0].quantity;\n\n  let teamPlan = team.plan;\n  if (isOldAccount) {\n    // remove +old from plan\n    teamPlan = teamPlan.replace(\"+old\", \"\");\n  }\n  // If a team upgrades/downgrades their subscription, update their plan\n  if (teamPlan !== newPlan) {\n    // Choose the correct plan limits\n    let planLimits:\n      | typeof PRO_PLAN_LIMITS\n      | typeof BUSINESS_PLAN_LIMITS\n      | typeof DATAROOMS_PLAN_LIMITS\n      | typeof DATAROOMS_PLUS_PLAN_LIMITS\n      | typeof DATAROOMS_PREMIUM_PLAN_LIMITS = structuredClone(PRO_PLAN_LIMITS);\n    if (plan.slug === \"pro\") {\n      planLimits = structuredClone(PRO_PLAN_LIMITS);\n    } else if (plan.slug === \"business\") {\n      planLimits = structuredClone(BUSINESS_PLAN_LIMITS);\n    } else if (plan.slug === \"datarooms\") {\n      planLimits = structuredClone(DATAROOMS_PLAN_LIMITS);\n    } else if (plan.slug === \"datarooms-plus\") {\n      planLimits = structuredClone(DATAROOMS_PLUS_PLAN_LIMITS);\n    } else if (plan.slug === \"datarooms-premium\") {\n      planLimits = structuredClone(DATAROOMS_PREMIUM_PLAN_LIMITS);\n    }\n\n    // Update the user limit in planLimits based on the subscription quantity\n    planLimits.users =\n      typeof quantity === \"number\" && quantity > 1\n        ? quantity\n        : planLimits.users;\n\n    // Update the user with the subscription information and stripeId\n    await prisma.team.update({\n      where: { stripeId },\n      data: {\n        plan: `${plan.slug}${isOldAccount ? \"+old\" : \"\"}`,\n        subscriptionId,\n        startsAt,\n        endsAt,\n        limits: planLimits,\n      },\n    });\n\n    if (!planSupportsRedirects(newPlan)) {\n      await clearTeamDomainRedirects(team.id);\n    }\n  }\n\n  // If new account, and the plan is the same, but the quantity is different, update the quantity\n  if (\n    !isOldAccount &&\n    teamPlan === newPlan &&\n    (team.limits as any)?.users !== quantity\n  ) {\n    // Update the user limit in planLimits based on the subscription quantity\n    const newLimits = team.limits as any;\n    newLimits.users = quantity;\n    await prisma.team.update({\n      where: { stripeId },\n      data: {\n        plan: plan.slug,\n        subscriptionId,\n        startsAt,\n        endsAt,\n        limits: newLimits,\n      },\n    });\n  }\n\n  // Update the subscription start and end dates\n  await prisma.team.update({\n    where: { stripeId },\n    data: {\n      startsAt,\n      endsAt,\n    },\n  });\n}\n"
  },
  {
    "path": "ee/stripe/webhooks/invoice-upcoming.ts",
    "content": "import { NextApiResponse } from \"next\";\n\nimport { sendSubscriptionRenewalReminderEmail } from \"@/ee/features/billing/renewal-reminder/lib/send-subscription-renewal-reminder\";\nimport Stripe from \"stripe\";\n\nimport { log } from \"@/lib/utils\";\n\nexport async function invoiceUpcoming(\n  event: Stripe.Event,\n  res: NextApiResponse,\n  isOldAccount: boolean = false,\n) {\n  const invoice = event.data.object as Stripe.Invoice;\n\n  // Only process invoices for yearly renewals\n  const lineItems = invoice.lines.data;\n  if (!lineItems || lineItems.length === 0) {\n    await log({\n      message: \"No line items found in invoice.upcoming event\",\n      type: \"info\",\n    });\n    return res.status(200).json({ received: true });\n  }\n\n  // Check if this is a yearly subscription\n  const hasYearlyPlan = lineItems.some((item) => {\n    if (item.price && item.price.recurring) {\n      return (\n        item.price.recurring.interval === \"year\" ||\n        (item.price.recurring.interval === \"month\" &&\n          item.price.recurring.interval_count === 12)\n      );\n    }\n    return false;\n  });\n\n  if (!hasYearlyPlan) {\n    await log({\n      message: \"Invoice is not for yearly renewal, skipping reminder email\",\n      type: \"info\",\n    });\n    return res.status(200).json({ received: true });\n  }\n\n  const customerEmail = invoice.customer_email;\n\n  if (!customerEmail) {\n    await log({\n      message: \"No customer email found in invoice.upcoming event\",\n      type: \"error\",\n    });\n    return res.status(200).json({ received: true });\n  }\n\n  // Calculate renewal date (period_end is when the invoice will be charged)\n  const renewalTimestamp = invoice.period_end;\n  const renewalDate = new Date(renewalTimestamp * 1000);\n  const formattedRenewalDate = renewalDate.toLocaleDateString(\"en-US\", {\n    month: \"long\",\n    day: \"numeric\",\n  });\n\n  try {\n    // send email immediately\n    await sendSubscriptionRenewalReminderEmail({\n      customerEmail,\n      renewalDate: formattedRenewalDate,\n      isOldAccount,\n    });\n\n    await log({\n      message: `Renewal reminder email sent for ${customerEmail}. Renewal date: ${formattedRenewalDate}`,\n      type: \"info\",\n    });\n  } catch (error) {\n    await log({\n      message: `Failed to send renewal reminder email for ${customerEmail}: ${error}`,\n      type: \"error\",\n    });\n    return res.status(200).json({ received: true });\n  }\n}\n"
  },
  {
    "path": "lib/analytics/index.ts",
    "content": "import { emptyAnalytics, jitsuAnalytics } from \"@jitsu/js\";\nimport { posthog } from \"posthog-js\";\n\nimport { getPostHogConfig } from \"@/lib/posthog\";\nimport { AnalyticsEvents } from \"@/lib/types\";\n\nexport function useAnalytics() {\n  const isPostHogEnabled = getPostHogConfig();\n\n  /**\n   * Capture an analytic event.\n   *\n   * @param event The event name.\n   * @param properties Properties to attach to the event.\n   */\n  const capture = (event: string, properties?: Record<string, unknown>) => {\n    if (!isPostHogEnabled) {\n      return;\n    }\n\n    posthog.capture(event, properties);\n  };\n\n  const identify = (\n    distinctId?: string,\n    properties?: Record<string, unknown>,\n  ) => {\n    if (!isPostHogEnabled) {\n      return;\n    }\n\n    posthog.identify(distinctId, properties);\n  };\n\n  return {\n    capture,\n    identify,\n  };\n}\n\n// For server-side tracking\nconst analytics =\n  process.env.JITSU_HOST && process.env.JITSU_WRITE_KEY\n    ? jitsuAnalytics({\n        host: process.env.JITSU_HOST,\n        writeKey: process.env.JITSU_WRITE_KEY,\n      })\n    : emptyAnalytics;\n\nexport const identifyUser = (userId: string) => analytics.identify(userId);\nexport const trackAnalytics = (args: AnalyticsEvents) => analytics.track(args);\n"
  },
  {
    "path": "lib/api/auth/passkey.ts",
    "content": "import { type Session } from \"next-auth\";\n\nimport hanko from \"@/lib/hanko\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport async function startServerPasskeyRegistration({\n  session,\n}: {\n  session: Session;\n}) {\n  if (!session) throw new Error(\"Not logged in\");\n\n  const sessionUser = session.user as CustomUser;\n\n  const user = await prisma.user.findUnique({\n    where: { email: sessionUser.email as string },\n    select: { id: true, name: true },\n  });\n\n  const createOptions = await hanko.registration.initialize({\n    userId: user!.id,\n    username: user!.name || user!.id,\n  });\n\n  return createOptions;\n}\n\n// This is *your* server-side code; you need to implement this yourself.\n// NextAuth takes care of logging in the user after they have registered their passkey.\nexport async function finishServerPasskeyRegistration({\n  credential,\n  session,\n}: {\n  credential: any;\n  session: Session;\n}) {\n  if (!session) throw new Error(\"Not logged in\");\n\n  await hanko.registration.finalize(credential);\n\n  // const sessionUser = session.user as CustomUser;\n\n  // Now the user has registered their passkey and can use it to log in.\n\n  // const user = await prisma.user.update({\n  //   where: { email: sessionUser.email as string },\n  //   select: { id: true },\n  // });\n}\n\nexport async function listUserPasskeys({ session }: { session: Session }) {\n  if (!session) throw new Error(\"Not logged in\");\n\n  const sessionUser = session.user as CustomUser;\n\n  const user = await prisma.user.findUnique({\n    where: { email: sessionUser.email as string },\n    select: { id: true },\n  });\n\n  if (!user) throw new Error(\"User not found\");\n\n  const tenantId = process.env.NEXT_PUBLIC_HANKO_TENANT_ID;\n  const apiKey = process.env.HANKO_API_KEY;\n\n  if (!tenantId || !apiKey) {\n    throw new Error(\"Passkey service configuration missing\");\n  }\n  const response = await fetch(\n    `https://passkeys.hanko.io/${tenantId}/credentials?user_id=${user.id}`,\n    {\n      method: \"GET\",\n      headers: {\n        apiKey: apiKey,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  );\n\n  if (!response.ok) {\n    throw new Error(`Failed to list passkeys: ${response.statusText}`);\n  }\n\n  const passkeys = await response.json();\n\n  if (!Array.isArray(passkeys)) {\n    throw new Error(\"Invalid passkey data received\");\n  }\n\n  return passkeys;\n}\n\nexport async function removeUserPasskey({\n  credentialId,\n  session,\n}: {\n  credentialId: string;\n  session: Session;\n}) {\n  if (!session) throw new Error(\"Not logged in\");\n\n  // First verify the credential belongs to the user\n  const sessionUser = session.user as CustomUser;\n  const user = await prisma.user.findUnique({\n    where: { email: sessionUser.email as string },\n    select: { id: true },\n  });\n\n  if (!user) throw new Error(\"User not found\");\n\n  // Verify ownership by listing user's passkeys first\n  const userPasskeys = await listUserPasskeys({ session });\n  const ownsCredential = userPasskeys.some((pk: any) => pk.id === credentialId);\n\n  if (!ownsCredential) {\n    throw new Error(\"Unauthorized\");\n  }\n\n  const tenantId = process.env.NEXT_PUBLIC_HANKO_TENANT_ID;\n  const apiKey = process.env.HANKO_API_KEY;\n\n  if (!tenantId || !apiKey) {\n    throw new Error(\"Passkey service configuration missing\");\n  }\n\n  const isValidCredentialId = /^[a-zA-Z0-9_-]+$/.test(credentialId);\n  if (!isValidCredentialId) {\n    throw new Error(\"Invalid credential ID format\");\n  }\n\n  const response = await fetch(\n    `https://passkeys.hanko.io/${tenantId}/credentials/${credentialId}`,\n    {\n      method: \"DELETE\",\n      headers: {\n        apiKey: apiKey,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  );\n\n  if (!response.ok) {\n    throw new Error(`Failed to remove passkey: ${response.statusText}`);\n  }\n}\n"
  },
  {
    "path": "lib/api/auth/token.ts",
    "content": "import { createHash } from \"crypto\";\n\nexport const hashToken = (\n  token: string,\n  {\n    noSecret = false,\n  }: {\n    noSecret?: boolean;\n  } = {},\n) => {\n  return createHash(\"sha256\")\n    .update(`${token}${noSecret ? \"\" : process.env.NEXTAUTH_SECRET}`)\n    .digest(\"hex\");\n};\n"
  },
  {
    "path": "lib/api/documents/process-document.ts",
    "content": "import { get } from \"@vercel/edge-config\";\nimport { parsePageId } from \"notion-utils\";\n\nimport { DocumentData } from \"@/lib/documents/create-document\";\nimport { isTrustedTeam } from \"@/lib/edge-config/trusted-teams\";\nimport { copyFileToBucketServer } from \"@/lib/files/copy-file-to-bucket-server\";\nimport notion from \"@/lib/notion\";\nimport { getNotionPageIdFromSlug } from \"@/lib/notion/utils\";\nimport prisma from \"@/lib/prisma\";\nimport {\n  convertCadToPdfTask,\n  convertFilesToPdfTask,\n  convertKeynoteToPdfTask,\n} from \"@/lib/trigger/convert-files\";\nimport { processVideo } from \"@/lib/trigger/optimize-video-files\";\nimport { convertPdfToImageRoute } from \"@/lib/trigger/pdf-to-image-route\";\nimport { getExtension, log } from \"@/lib/utils\";\nimport { conversionQueue } from \"@/lib/utils/trigger-utils\";\nimport { sendDocumentCreatedWebhook } from \"@/lib/webhook/triggers/document-created\";\nimport { sendLinkCreatedWebhook } from \"@/lib/webhook/triggers/link-created\";\n\ntype ProcessDocumentParams = {\n  documentData: DocumentData;\n  teamId: string;\n  teamPlan: string;\n  userId?: string;\n  folderPathName?: string;\n  createLink?: boolean;\n  isExternalUpload?: boolean;\n};\n\nexport const processDocument = async ({\n  documentData,\n  teamId,\n  teamPlan,\n  userId,\n  folderPathName,\n  createLink = false,\n  isExternalUpload = false,\n}: ProcessDocumentParams) => {\n  const {\n    name,\n    key,\n    storageType,\n    contentType,\n    supportedFileType,\n    fileSize,\n    numPages,\n    enableExcelAdvancedMode,\n  } = documentData;\n\n  // Get passed type property or alternatively, the file extension and save it as the type\n  const type = supportedFileType || getExtension(name);\n\n  // Check whether the Notion page is publically accessible or not\n  if (type === \"notion\") {\n    try {\n      let pageId = parsePageId(key, { uuid: false });\n\n      // If parsePageId fails, try to get page ID from slug\n      if (!pageId) {\n        try {\n          const pageIdFromSlug = await getNotionPageIdFromSlug(key);\n          pageId = pageIdFromSlug || undefined;\n        } catch (slugError) {\n          throw new Error(\"Unable to extract page ID from Notion URL\");\n        }\n      }\n\n      // if the page isn't accessible then end the process here.\n      if (!pageId) {\n        throw new Error(\"Notion page not found\");\n      }\n      await notion.getPage(pageId);\n    } catch (error) {\n      throw new Error(\"This Notion page isn't publically available.\");\n    }\n  }\n\n  // For link type, validate URL format\n  if (type === \"link\") {\n    try {\n      new URL(key);\n\n      // Skip keyword check for trusted teams\n      const trusted = await isTrustedTeam(teamId);\n      if (!trusted) {\n        const keywords = await get(\"keywords\");\n        if (Array.isArray(keywords) && keywords.length > 0) {\n          const matchedKeyword = keywords.find(\n            (keyword) =>\n              typeof keyword === \"string\" &&\n              key.toLowerCase().includes(keyword.toLowerCase()),\n          );\n\n          if (matchedKeyword) {\n            log({\n              message: `Link document creation blocked: ${matchedKeyword} \\n\\n \\`Metadata: {teamId: ${teamId}, url: ${key}}\\``,\n              type: \"error\",\n              mention: true,\n            });\n            throw new Error(\"This URL is not allowed\");\n          }\n        }\n      }\n    } catch (error) {\n      throw new Error(\"Invalid URL format for link document.\");\n    }\n  }\n\n  const folder = await prisma.folder.findUnique({\n    where: {\n      teamId_path: {\n        teamId,\n        path: \"/\" + folderPathName,\n      },\n    },\n    select: {\n      id: true,\n    },\n  });\n\n  // determine if the document is download only\n  const isDownloadOnly =\n    type === \"zip\" ||\n    type === \"map\" ||\n    type === \"email\" ||\n    contentType === \"text/tab-separated-values\";\n\n  // Save data to the database\n  const document = await prisma.document.create({\n    data: {\n      name: name,\n      numPages: numPages,\n      file: key,\n      originalFile: key,\n      contentType: contentType,\n      type: type,\n      storageType,\n      ownerId: userId,\n      teamId: teamId,\n      advancedExcelEnabled: enableExcelAdvancedMode,\n      downloadOnly: isDownloadOnly,\n      ...(createLink && {\n        links: {\n          create: {\n            teamId,\n            ownerId: userId,\n          },\n        },\n      }),\n      versions: {\n        create: {\n          file: key,\n          originalFile: key,\n          contentType: contentType,\n          type: type,\n          storageType,\n          numPages: numPages,\n          isPrimary: true,\n          versionNumber: 1,\n          fileSize: fileSize,\n        },\n      },\n      folderId: folder?.id ?? null,\n      isExternalUpload,\n    },\n    include: {\n      links: true,\n      versions: true,\n    },\n  });\n\n  // Trigger appropriate conversion tasks based on document type\n  // Check if it's a Keynote file (slides type with Keynote content type)\n  if (\n    type === \"slides\" &&\n    (contentType === \"application/vnd.apple.keynote\" ||\n      contentType === \"application/x-iwork-keynote-sffkey\")\n  ) {\n    await convertKeynoteToPdfTask.trigger(\n      {\n        documentId: document.id,\n        documentVersionId: document.versions[0].id,\n        teamId,\n      },\n      {\n        idempotencyKey: `${teamId}-${document.versions[0].id}-keynote`,\n        tags: [\n          `team_${teamId}`,\n          `document_${document.id}`,\n          `version:${document.versions[0].id}`,\n        ],\n        queue: conversionQueue(teamPlan),\n        concurrencyKey: teamId,\n      },\n    );\n  } else if (type === \"docs\" || type === \"slides\") {\n    await convertFilesToPdfTask.trigger(\n      {\n        documentId: document.id,\n        documentVersionId: document.versions[0].id,\n        teamId,\n      },\n      {\n        idempotencyKey: `${teamId}-${document.versions[0].id}-docs`,\n        tags: [\n          `team_${teamId}`,\n          `document_${document.id}`,\n          `version:${document.versions[0].id}`,\n        ],\n        queue: conversionQueue(teamPlan),\n        concurrencyKey: teamId,\n      },\n    );\n  }\n\n  if (type === \"cad\") {\n    await convertCadToPdfTask.trigger(\n      {\n        documentId: document.id,\n        documentVersionId: document.versions[0].id,\n        teamId,\n      },\n      {\n        idempotencyKey: `${teamId}-${document.versions[0].id}-cad`,\n        tags: [\n          `team_${teamId}`,\n          `document_${document.id}`,\n          `version:${document.versions[0].id}`,\n        ],\n        queue: conversionQueue(teamPlan),\n        concurrencyKey: teamId,\n      },\n    );\n  }\n\n  if (\n    type === \"video\" &&\n    contentType !== \"video/mp4\" &&\n    contentType?.startsWith(\"video/\")\n  ) {\n    await processVideo.trigger(\n      {\n        videoUrl: key,\n        teamId,\n        docId: key.split(\"/\")[1], // Extract doc_xxxx from teamId/doc_xxxx/filename\n        documentVersionId: document.versions[0].id,\n        fileSize: fileSize || 0,\n      },\n      {\n        idempotencyKey: `${teamId}-${document.versions[0].id}`,\n        tags: [\n          `team_${teamId}`,\n          `document_${document.id}`,\n          `version:${document.versions[0].id}`,\n        ],\n        queue: conversionQueue(teamPlan),\n        concurrencyKey: teamId,\n      },\n    );\n  }\n\n  // skip triggering convert-pdf-to-image job for \"notion\" / \"excel\" documents\n  if (type === \"pdf\") {\n    await convertPdfToImageRoute.trigger(\n      {\n        documentId: document.id,\n        documentVersionId: document.versions[0].id,\n        teamId,\n      },\n      {\n        idempotencyKey: `${teamId}-${document.versions[0].id}`,\n        tags: [\n          `team_${teamId}`,\n          `document_${document.id}`,\n          `version:${document.versions[0].id}`,\n        ],\n        queue: conversionQueue(teamPlan),\n        concurrencyKey: teamId,\n      },\n    );\n  }\n\n  if (type === \"sheet\" && enableExcelAdvancedMode) {\n    await copyFileToBucketServer({\n      filePath: document.versions[0].file,\n      storageType: document.versions[0].storageType,\n      teamId,\n    });\n\n    await prisma.documentVersion.update({\n      where: { id: document.versions[0].id },\n      data: { numPages: 1 },\n    });\n\n    try {\n      await fetch(\n        `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&documentId=${document.id}`,\n      );\n    } catch (error) {\n      console.error(\"Failed to revalidate document:\", error);\n      // The document is still updated, so we can continue without throwing\n    }\n  }\n\n  // Send webhooks\n  await Promise.all([\n    !isExternalUpload &&\n      sendDocumentCreatedWebhook({\n        teamId,\n        data: {\n          document_id: document.id,\n        },\n      }),\n    createLink &&\n      sendLinkCreatedWebhook({\n        teamId,\n        data: {\n          document_id: document.id,\n          link_id: document.links[0].id,\n        },\n      }),\n  ]);\n\n  return document;\n};\n"
  },
  {
    "path": "lib/api/domains/clear-team-redirects.ts",
    "content": "import prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\n\nimport { getRedisKey } from \"./redis\";\n\n/**\n * Clears all redirect URLs for every domain belonging to a team,\n * removing them from both Postgres and Redis.\n */\nexport async function clearTeamDomainRedirects(\n  teamId: string,\n): Promise<void> {\n  const domains = await prisma.domain.findMany({\n    where: { teamId, redirectUrl: { not: null } },\n    select: { slug: true },\n  });\n\n  if (domains.length === 0) return;\n\n  await Promise.all([\n    prisma.domain.updateMany({\n      where: { teamId, redirectUrl: { not: null } },\n      data: { redirectUrl: null },\n    }),\n    ...domains.map((d) => redis.del(getRedisKey(d.slug))),\n  ]);\n}\n"
  },
  {
    "path": "lib/api/domains/redis.ts",
    "content": "import { redis } from \"@/lib/redis\";\n\nconst DOMAIN_REDIRECT_PREFIX = \"domain:redirect\";\n\nconst PLANS_WITH_REDIRECTS = new Set([\n  \"business\",\n  \"datarooms\",\n  \"datarooms-plus\",\n  \"datarooms-premium\",\n]);\n\nexport function planSupportsRedirects(plan: string): boolean {\n  const normalized = plan.replace(\"+old\", \"\");\n  return PLANS_WITH_REDIRECTS.has(normalized);\n}\n\nexport function getRedisKey(domain: string): string {\n  return `${DOMAIN_REDIRECT_PREFIX}:${domain.toLowerCase()}`;\n}\n\nexport async function getDomainRedirectUrl(\n  domain: string,\n): Promise<string | null> {\n  return redis.get<string>(getRedisKey(domain));\n}\n\nexport async function setDomainRedirectUrl(\n  domain: string,\n  redirectUrl: string | null,\n): Promise<void> {\n  const key = getRedisKey(domain);\n  if (redirectUrl) {\n    await redis.set(key, redirectUrl);\n  } else {\n    await redis.del(key);\n  }\n}\n\nexport async function deleteDomainRedirectUrl(domain: string): Promise<void> {\n  await redis.del(getRedisKey(domain));\n}\n"
  },
  {
    "path": "lib/api/domains/validate-redirect-url.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nimport { isTrustedTeam } from \"@/lib/edge-config/trusted-teams\";\nimport { log } from \"@/lib/utils\";\nimport { validateUrlSecurity } from \"@/lib/zod/url-validation\";\n\ntype ValidationResult =\n  | { valid: true; url: string }\n  | { valid: false; message: string };\n\nexport async function validateRedirectUrl(\n  redirectUrl: string,\n  teamId: string,\n): Promise<ValidationResult> {\n  const trimmed = redirectUrl.trim();\n\n  if (!trimmed) {\n    return { valid: true, url: \"\" };\n  }\n\n  let parsed: URL;\n  try {\n    parsed = new URL(trimmed);\n  } catch {\n    return { valid: false, message: \"Invalid redirect URL\" };\n  }\n\n  if (parsed.protocol !== \"https:\") {\n    return { valid: false, message: \"Redirect URL must use HTTPS\" };\n  }\n\n  if (!validateUrlSecurity(trimmed)) {\n    return {\n      valid: false,\n      message: \"Redirect URL targets a disallowed resource\",\n    };\n  }\n\n  const trusted = await isTrustedTeam(teamId);\n  if (!trusted) {\n    try {\n      const keywords = await get(\"keywords\");\n      if (Array.isArray(keywords) && keywords.length > 0) {\n        const matchedKeyword = keywords.find(\n          (keyword) =>\n            typeof keyword === \"string\" &&\n            trimmed.toLowerCase().includes(keyword.toLowerCase()),\n        );\n\n        if (matchedKeyword) {\n          log({\n            message: `Redirect URL blocked: ${matchedKeyword} \\n\\n \\`Metadata: {teamId: ${teamId}, url: ${trimmed}}\\``,\n            type: \"error\",\n            mention: true,\n          });\n          return { valid: false, message: \"This URL is not allowed\" };\n        }\n      }\n    } catch {\n      // Edge config unavailable; allow the URL through\n    }\n  }\n\n  // Return the sanitized URL (trimmed, with resolved origin)\n  return { valid: true, url: parsed.toString() };\n}\n"
  },
  {
    "path": "lib/api/domains.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\nimport { getApexDomain, removeDomainFromVercel } from \"../domains\";\n\n// calculate the domainCount\nexport async function getDomainCount(domain: string) {\n  const apexDomain = getApexDomain(`https://${domain}`);\n  const response = await prisma.domain.count({\n    where: {\n      OR: [\n        {\n          slug: apexDomain,\n        },\n        {\n          slug: {\n            endsWith: `.${apexDomain}`,\n          },\n        },\n      ],\n    },\n  });\n\n  return response;\n}\n\n/* Delete a domain */\nexport async function deleteDomain(\n  domain: string,\n  {\n    // Note: in certain cases, we don't need to remove the domain from the Prisma\n    skipPrismaDelete = false,\n  } = {},\n) {\n  const domainCount = await getDomainCount(domain);\n\n  return await Promise.allSettled([\n    // remove the domain from Vercel\n    removeDomainFromVercel(domain, domainCount),\n    // delete domain\n    !skipPrismaDelete &&\n      prisma.domain.delete({\n        where: {\n          slug: domain,\n        },\n      }),\n  ]);\n}\n"
  },
  {
    "path": "lib/api/links/link-data.ts",
    "content": "import {\n  Brand,\n  DataroomBrand,\n  ItemType,\n  LinkAudienceType,\n  LinkType,\n  PermissionGroupAccessControls,\n  Prisma,\n  ViewerGroupAccessControls,\n} from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\nimport { sortItemsByIndexAndName } from \"@/lib/utils/sort-items-by-index-name\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\ntype LinkFetchStatus = \"ok\" | \"not_found\" | \"archived\" | \"deleted\" | \"free\";\n\nexport type LinkFetchResult =\n  | {\n      status: \"ok\";\n      linkType: LinkType;\n      link: any;\n      brand: Partial<Brand> | Partial<DataroomBrand> | null;\n      linkId?: string;\n    }\n  | {\n      status: Exclude<LinkFetchStatus, \"ok\">;\n    };\n\n// Common select object for link queries\nconst linkSelect = {\n  id: true,\n  expiresAt: true,\n  emailProtected: true,\n  emailAuthenticated: true,\n  allowDownload: true,\n  enableFeedback: true,\n  enableScreenshotProtection: true,\n  password: true,\n  isArchived: true,\n  deletedAt: true,\n  enableIndexFile: true,\n  enableCustomMetatag: true,\n  metaTitle: true,\n  metaDescription: true,\n  metaImage: true,\n  metaFavicon: true,\n  welcomeMessage: true,\n  enableQuestion: true,\n  linkType: true,\n  feedback: {\n    select: {\n      id: true,\n      data: true,\n    },\n  },\n  enableAgreement: true,\n  agreement: true,\n  showBanner: true,\n  enableWatermark: true,\n  watermarkConfig: true,\n  groupId: true,\n  permissionGroupId: true,\n  audienceType: true,\n  dataroomId: true,\n  teamId: true,\n  team: {\n    select: {\n      plan: true,\n      globalBlockList: true,\n    },\n  },\n  customFields: {\n    select: {\n      id: true,\n      type: true,\n      identifier: true,\n      label: true,\n      placeholder: true,\n      required: true,\n      disabled: true,\n      orderIndex: true,\n    },\n    orderBy: {\n      orderIndex: \"asc\" as const,\n    },\n  },\n} satisfies Prisma.LinkSelect;\n\n// Type for the link record returned by the common select query\ntype LinkRecord = Prisma.LinkGetPayload<{ select: typeof linkSelect }>;\n\n// ============================================================================\n// Internal Helpers\n// ============================================================================\n\n// Helper function to get all parent folder IDs for given folder IDs\nasync function getAllParentFolderIds(\n  folderIds: string[],\n  dataroomId: string,\n): Promise<string[]> {\n  if (folderIds.length === 0) return [];\n\n  const allRequiredFolderIds = new Set(folderIds);\n\n  // Get all folders in the dataroom to build the hierarchy\n  const allFolders = await prisma.dataroomFolder.findMany({\n    where: { dataroomId },\n    select: { id: true, parentId: true },\n  });\n\n  // Use Map for O(1) parent lookup: folderId -> parentId\n  // This is more efficient than Set because we need key-value relationship for traversal\n  const folderMap = new Map(\n    allFolders.map((folder) => [folder.id, folder.parentId]),\n  );\n\n  // For each accessible folder, traverse up to find all parent folders\n  for (const folderId of folderIds) {\n    let currentId: string | null = folderId;\n    while (currentId) {\n      allRequiredFolderIds.add(currentId);\n      currentId = folderMap.get(currentId) || null;\n    }\n  }\n\n  return Array.from(allRequiredFolderIds);\n}\n\n// ============================================================================\n// Data Fetchers (used by both API routes and getStaticProps)\n// ============================================================================\n\nexport async function fetchDataroomLinkData({\n  linkId,\n  dataroomId,\n  teamId,\n  groupId,\n  permissionGroupId,\n}: {\n  linkId: string;\n  dataroomId: string | null;\n  teamId: string;\n  groupId?: string;\n  permissionGroupId?: string;\n}) {\n  let groupPermissions:\n    | ViewerGroupAccessControls[]\n    | PermissionGroupAccessControls[] = [];\n  let documentIds: string[] = [];\n  let folderIds: string[] = [];\n  let allRequiredFolderIds: string[] = [];\n\n  const effectiveGroupId = groupId || permissionGroupId;\n\n  if (effectiveGroupId) {\n    // Check if this is a ViewerGroup (legacy) or PermissionGroup\n    // First try to find ViewerGroup permissions (for backwards compatibility)\n    if (groupId) {\n      // This is a ViewerGroup (legacy behavior)\n      groupPermissions = await prisma.viewerGroupAccessControls.findMany({\n        where: {\n          groupId: groupId,\n          OR: [{ canView: true }, { canDownload: true }],\n        },\n      });\n    } else if (permissionGroupId) {\n      // This is a PermissionGroup (new behavior)\n      groupPermissions = await prisma.permissionGroupAccessControls.findMany({\n        where: {\n          groupId: permissionGroupId,\n          OR: [{ canView: true }, { canDownload: true }],\n        },\n      });\n    }\n\n    documentIds = groupPermissions\n      .filter(\n        (permission) => permission.itemType === ItemType.DATAROOM_DOCUMENT,\n      )\n      .map((permission) => permission.itemId);\n    folderIds = groupPermissions\n      .filter((permission) => permission.itemType === ItemType.DATAROOM_FOLDER)\n      .map((permission) => permission.itemId);\n\n    // Include parent folders if we have group permissions and they're actually being applied\n    // This ensures that if a group has access to a subfolder, all parent folders\n    // are also included to maintain proper hierarchy (even without explicit permissions)\n    allRequiredFolderIds = folderIds;\n    if (dataroomId && folderIds.length > 0) {\n      allRequiredFolderIds = await getAllParentFolderIds(folderIds, dataroomId);\n    }\n  }\n\n  const linkData = await prisma.link.findUnique({\n    where: { id: linkId, teamId },\n    select: {\n      dataroom: {\n        select: {\n          id: true,\n          name: true,\n          description: true,\n          teamId: true,\n          allowBulkDownload: true,\n          showLastUpdated: true,\n          introductionEnabled: true,\n          introductionContent: true,\n          createdAt: true,\n          documents: {\n            where:\n              groupPermissions.length > 0 || effectiveGroupId\n                ? { id: { in: documentIds } }\n                : undefined,\n            select: {\n              id: true,\n              folderId: true,\n              updatedAt: true,\n              orderIndex: true,\n              hierarchicalIndex: true,\n              document: {\n                select: {\n                  id: true,\n                  name: true,\n                  advancedExcelEnabled: true,\n                  downloadOnly: true,\n                  versions: {\n                    where: { isPrimary: true },\n                    select: {\n                      id: true,\n                      versionNumber: true,\n                      type: true,\n                      hasPages: true,\n                      file: true,\n                      isVertical: true,\n                      updatedAt: true,\n                    },\n                    take: 1,\n                  },\n                },\n              },\n            },\n            orderBy: [\n              { orderIndex: \"asc\" },\n              {\n                document: { name: \"asc\" },\n              },\n            ],\n          },\n          folders: {\n            where:\n              groupPermissions.length > 0 || effectiveGroupId\n                ? { id: { in: allRequiredFolderIds } }\n                : undefined,\n            select: {\n              id: true,\n              name: true,\n              path: true,\n              parentId: true,\n              dataroomId: true,\n              orderIndex: true,\n              hierarchicalIndex: true,\n              icon: true,\n              color: true,\n              createdAt: true,\n              updatedAt: true,\n            },\n            orderBy: [{ orderIndex: \"asc\" }, { name: \"asc\" }],\n          },\n        },\n      },\n      group: {\n        select: {\n          accessControls: true,\n        },\n      },\n      permissionGroup: {\n        select: {\n          accessControls: true,\n        },\n      },\n    },\n  });\n\n  if (!linkData?.dataroom) {\n    throw new Error(\"Dataroom not found\");\n  }\n\n  // Sort documents by index or name\n  linkData.dataroom.documents = sortItemsByIndexAndName(\n    linkData.dataroom.documents,\n  );\n\n  const dataroomBrand = await prisma.dataroomBrand.findFirst({\n    where: { dataroomId: linkData.dataroom.id },\n    select: {\n      logo: true,\n      banner: true,\n      brandColor: true,\n      accentColor: true,\n      applyAccentColorToDataroomView: true,\n      welcomeMessage: true,\n    },\n  });\n\n  const teamBrand = await prisma.brand.findFirst({\n    where: { teamId: linkData.dataroom.teamId },\n    select: {\n      logo: true,\n      banner: true,\n      brandColor: true,\n      accentColor: true,\n      applyAccentColorToDataroomView: true,\n      welcomeMessage: true,\n    },\n  });\n\n  const brand = {\n    logo: dataroomBrand?.logo || teamBrand?.logo,\n    banner: dataroomBrand?.banner || teamBrand?.banner || null,\n    brandColor: dataroomBrand?.brandColor || teamBrand?.brandColor,\n    accentColor: dataroomBrand?.accentColor || teamBrand?.accentColor,\n    applyAccentColorToDataroomView:\n      dataroomBrand?.applyAccentColorToDataroomView ??\n      teamBrand?.applyAccentColorToDataroomView ??\n      false,\n    welcomeMessage: dataroomBrand?.welcomeMessage || teamBrand?.welcomeMessage,\n  };\n\n  // Extract access controls from either ViewerGroup or PermissionGroup\n  const accessControls =\n    linkData.group?.accessControls ||\n    linkData.permissionGroup?.accessControls ||\n    [];\n\n  return { linkData, brand, accessControls };\n}\n\nexport async function fetchDataroomDocumentLinkData({\n  linkId,\n  teamId,\n  dataroomDocumentId,\n  groupId,\n  permissionGroupId,\n}: {\n  linkId: string;\n  teamId: string;\n  dataroomDocumentId: string;\n  groupId?: string;\n  permissionGroupId?: string;\n}) {\n  let groupPermissions:\n    | ViewerGroupAccessControls[]\n    | PermissionGroupAccessControls[] = [];\n\n  const effectiveGroupId = groupId || permissionGroupId;\n\n  if (effectiveGroupId) {\n    let hasAccess = false;\n\n    if (groupId) {\n      // This is a ViewerGroup (legacy behavior)\n      groupPermissions = await prisma.viewerGroupAccessControls.findMany({\n        where: {\n          groupId: groupId,\n          itemId: dataroomDocumentId,\n          itemType: ItemType.DATAROOM_DOCUMENT,\n          OR: [{ canView: true }, { canDownload: true }],\n        },\n      });\n      hasAccess = groupPermissions.length > 0;\n    } else if (permissionGroupId) {\n      // This is a PermissionGroup (new behavior)\n      groupPermissions = await prisma.permissionGroupAccessControls.findMany({\n        where: {\n          groupId: permissionGroupId,\n          itemId: dataroomDocumentId,\n          itemType: ItemType.DATAROOM_DOCUMENT,\n          OR: [{ canView: true }, { canDownload: true }],\n        },\n      });\n      hasAccess = groupPermissions.length > 0;\n    }\n\n    // if it's a group/permission link, we need to check if the document is accessible\n    if (!hasAccess) {\n      throw new Error(\"Document not found in group\");\n    }\n  }\n\n  const linkData = await prisma.link.findUnique({\n    where: { id: linkId, teamId, linkType: \"DATAROOM_LINK\", deletedAt: null },\n    select: {\n      dataroom: {\n        select: {\n          id: true,\n          name: true,\n          description: true,\n          teamId: true,\n          allowBulkDownload: true,\n          showLastUpdated: true,\n          documents: {\n            where: { id: dataroomDocumentId },\n            select: {\n              id: true,\n              updatedAt: true,\n              orderIndex: true,\n              hierarchicalIndex: true,\n              document: {\n                select: {\n                  id: true,\n                  name: true,\n                  advancedExcelEnabled: true,\n                  downloadOnly: true,\n                  versions: {\n                    where: { isPrimary: true },\n                    select: {\n                      id: true,\n                      versionNumber: true,\n                      type: true,\n                      hasPages: true,\n                      file: true,\n                      isVertical: true,\n                    },\n                    take: 1,\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    },\n  });\n\n  if (!linkData?.dataroom) {\n    throw new Error(\"Dataroom not found\");\n  }\n\n  const dataroomBrand = await prisma.dataroomBrand.findFirst({\n    where: { dataroomId: linkData.dataroom.id },\n    select: {\n      logo: true,\n      banner: true,\n      brandColor: true,\n      accentColor: true,\n      applyAccentColorToDataroomView: true,\n      welcomeMessage: true,\n    },\n  });\n\n  const teamBrand = await prisma.brand.findFirst({\n    where: { teamId: linkData.dataroom.teamId },\n    select: {\n      logo: true,\n      banner: true,\n      brandColor: true,\n      accentColor: true,\n      applyAccentColorToDataroomView: true,\n      welcomeMessage: true,\n    },\n  });\n\n  const brand = {\n    logo: dataroomBrand?.logo || teamBrand?.logo,\n    banner: dataroomBrand?.banner || teamBrand?.banner || null,\n    brandColor: dataroomBrand?.brandColor || teamBrand?.brandColor,\n    accentColor: dataroomBrand?.accentColor || teamBrand?.accentColor,\n    applyAccentColorToDataroomView:\n      dataroomBrand?.applyAccentColorToDataroomView ??\n      teamBrand?.applyAccentColorToDataroomView ??\n      false,\n    welcomeMessage: dataroomBrand?.welcomeMessage || teamBrand?.welcomeMessage,\n  };\n\n  return { linkData, brand };\n}\n\nexport async function fetchDocumentLinkData({\n  linkId,\n  teamId,\n}: {\n  linkId: string;\n  teamId: string;\n}) {\n  const linkData = await prisma.link.findUnique({\n    where: { id: linkId, teamId, deletedAt: null },\n    select: {\n      document: {\n        select: {\n          id: true,\n          name: true,\n          advancedExcelEnabled: true,\n          downloadOnly: true,\n          teamId: true,\n          ownerId: true,\n          team: {\n            select: { plan: true },\n          },\n          versions: {\n            where: { isPrimary: true },\n            select: {\n              id: true,\n              versionNumber: true,\n              type: true,\n              hasPages: true,\n              file: true,\n              isVertical: true,\n            },\n            take: 1,\n          },\n        },\n      },\n    },\n  });\n\n  if (!linkData?.document) {\n    throw new Error(\"Document not found\");\n  }\n\n  const brand = await prisma.brand.findFirst({\n    where: { teamId: linkData.document.teamId },\n    select: {\n      logo: true,\n      brandColor: true,\n      accentColor: true,\n      welcomeMessage: true,\n    },\n  });\n\n  return { linkData, brand };\n}\n\n// ============================================================================\n// Unified Link Data Fetcher for getStaticProps\n// Avoids internal HTTP fetch which can be blocked by Vercel edge (403 errors)\n// ============================================================================\n\n/**\n * Core function to process link data after fetching the link record.\n * Handles all link types: DOCUMENT_LINK, DATAROOM_LINK, WORKFLOW_LINK\n */\nasync function processLinkData(\n  link: LinkRecord,\n  options: {\n    dataroomDocumentId?: string;\n    isCustomDomain?: boolean;\n  } = {},\n): Promise<LinkFetchResult> {\n  const { dataroomDocumentId, isCustomDomain } = options;\n  const teamPlan = link.team?.plan || \"free\";\n  const linkType = link.linkType;\n\n  // For custom domains, free plan is not allowed\n  if (isCustomDomain && teamPlan.includes(\"free\")) {\n    return { status: \"free\" };\n  }\n\n  // Handle WORKFLOW_LINK\n  if (linkType === \"WORKFLOW_LINK\") {\n    let brand: Partial<Brand> | null = null;\n    if (link.teamId) {\n      const teamBrand = await prisma.brand.findUnique({\n        where: { teamId: link.teamId },\n        select: {\n          logo: true,\n          brandColor: true,\n          accentColor: true,\n        },\n      });\n      brand = teamBrand;\n    }\n\n    // For workflow links, return the link with minimal processing\n    // Remove team object (contains plan, globalBlockList) but keep teamId for feature flags\n    const sanitizedLink = {\n      ...link,\n      team: undefined,\n      deletedAt: undefined,\n    };\n\n    // Serialize to convert Date objects to strings (required for Next.js getStaticProps)\n    const serializedLink = JSON.parse(JSON.stringify(sanitizedLink));\n    const serializedBrand = brand ? JSON.parse(JSON.stringify(brand)) : null;\n\n    return {\n      status: \"ok\",\n      linkType,\n      brand: serializedBrand,\n      linkId: link.id,\n      link: serializedLink,\n    };\n  }\n\n  let brand: Partial<Brand> | Partial<DataroomBrand> | null = null;\n  let linkData: any;\n\n  // Handle DOCUMENT_LINK\n  if (linkType === \"DOCUMENT_LINK\") {\n    // Guard: teamId is required for document links\n    if (!link.teamId) {\n      return { status: \"not_found\" };\n    }\n\n    try {\n      const data = await fetchDocumentLinkData({\n        linkId: link.id,\n        teamId: link.teamId,\n      });\n      linkData = data.linkData;\n      brand = data.brand;\n    } catch {\n      return { status: \"not_found\" };\n    }\n  }\n  // Handle DATAROOM_LINK\n  else if (linkType === \"DATAROOM_LINK\") {\n    // Guard: teamId is required for dataroom links\n    if (!link.teamId) {\n      return { status: \"not_found\" };\n    }\n\n    if (dataroomDocumentId) {\n      // Fetching specific document within dataroom\n      try {\n        const data = await fetchDataroomDocumentLinkData({\n          linkId: link.id,\n          teamId: link.teamId,\n          dataroomDocumentId: dataroomDocumentId,\n          permissionGroupId: link.permissionGroupId || undefined,\n          ...(link.audienceType === LinkAudienceType.GROUP &&\n            link.groupId && {\n              groupId: link.groupId,\n            }),\n        });\n        linkData = data.linkData;\n        brand = data.brand;\n      } catch {\n        return { status: \"not_found\" };\n      }\n    } else {\n      // Fetching full dataroom\n      try {\n        const data = await fetchDataroomLinkData({\n          linkId: link.id,\n          dataroomId: link.dataroomId,\n          teamId: link.teamId,\n          permissionGroupId: link.permissionGroupId || undefined,\n          ...(link.audienceType === LinkAudienceType.GROUP &&\n            link.groupId && {\n              groupId: link.groupId,\n            }),\n        });\n        linkData = data.linkData;\n        brand = data.brand;\n        linkData.accessControls = data.accessControls;\n      } catch {\n        return { status: \"not_found\" };\n      }\n    }\n  }\n\n  // Only include agreement if enabled (no need to expose it otherwise)\n  const sanitizedAgreement =\n    link.enableAgreement && link.agreement\n      ? {\n          id: link.agreement.id,\n          name: link.agreement.name,\n          content: link.agreement.content,\n          contentType: link.agreement.contentType,\n          requireName: link.agreement.requireName,\n        }\n      : null;\n\n  // Sanitize document - keep fields needed by getStaticProps\n  // Note: team/teamId are used server-side for feature flags and are stripped before client props\n  const sanitizedDocument = linkData?.document\n    ? {\n        id: linkData.document.id,\n        name: linkData.document.name,\n        teamId: linkData.document.teamId,\n        team: linkData.document.team, // Used server-side for plan check, stripped before client\n        downloadOnly: linkData.document.downloadOnly,\n        advancedExcelEnabled: linkData.document.advancedExcelEnabled,\n        versions: linkData.document.versions,\n      }\n    : undefined;\n\n  // Sanitize link for return - remove sensitive/internal data\n  const sanitizedLink = {\n    ...link,\n    // Remove team object (contains plan, globalBlockList) but keep teamId for feature flags\n    team: undefined,\n    // Remove internal fields\n    deletedAt: undefined,\n    document: undefined,\n    dataroom: undefined,\n    password: link.password ? \"protected\" : null,\n    // Use sanitized agreement\n    agreement: sanitizedAgreement,\n    ...(teamPlan === \"free\" && {\n      customFields: [],\n      enableAgreement: false,\n      enableWatermark: false,\n      permissionGroupId: null,\n    }),\n  };\n\n  const returnLink = {\n    ...sanitizedLink,\n    ...linkData,\n    // Override with sanitized document\n    document: sanitizedDocument,\n    // Keep dataroomId for DATAROOM_LINK types (needed for session verification and API calls)\n    // For DOCUMENT_LINK types, set to undefined\n    dataroomId:\n      linkType === \"DATAROOM_LINK\"\n        ? link.dataroomId || linkData?.dataroom?.id\n        : undefined,\n    dataroomDocument: linkData?.dataroom?.documents?.[0] || undefined,\n  };\n\n  // Serialize to convert Date objects to strings (required for Next.js getStaticProps)\n  const serializedLink = JSON.parse(JSON.stringify(returnLink));\n  const serializedBrand = brand ? JSON.parse(JSON.stringify(brand)) : null;\n\n  return {\n    status: \"ok\",\n    linkType,\n    link: serializedLink,\n    brand: serializedBrand,\n  };\n}\n\n/**\n * Fetch link data by linkId (for /view/[linkId] routes)\n */\nexport async function fetchLinkDataById({\n  linkId,\n  dataroomDocumentId,\n}: {\n  linkId: string;\n  dataroomDocumentId?: string;\n}): Promise<LinkFetchResult> {\n  const link = await prisma.link.findUnique({\n    where: { id: linkId },\n    select: linkSelect,\n  });\n\n  if (!link) {\n    return { status: \"not_found\" };\n  }\n\n  if (link.deletedAt) {\n    return { status: \"deleted\" };\n  }\n\n  if (link.isArchived) {\n    return { status: \"archived\" };\n  }\n\n  return processLinkData(link, { dataroomDocumentId, isCustomDomain: false });\n}\n\n/**\n * Fetch link data by domain and slug (for /view/domains/[domain]/[slug] routes)\n * Includes free plan check since custom domains require paid plan\n */\nexport async function fetchLinkDataByDomainSlug({\n  domain,\n  slug,\n  dataroomDocumentId,\n}: {\n  domain: string;\n  slug: string;\n  dataroomDocumentId?: string;\n}): Promise<LinkFetchResult> {\n  const link = await prisma.link.findUnique({\n    where: {\n      domainSlug_slug: {\n        slug: slug,\n        domainSlug: domain,\n      },\n    },\n    select: linkSelect,\n  });\n\n  if (!link) {\n    return { status: \"not_found\" };\n  }\n\n  if (link.deletedAt) {\n    return { status: \"deleted\" };\n  }\n\n  if (link.isArchived) {\n    return { status: \"archived\" };\n  }\n\n  return processLinkData(link, { dataroomDocumentId, isCustomDomain: true });\n}\n\n// Legacy export aliases for backward compatibility\nexport const fetchCustomDomainLinkData = fetchLinkDataByDomainSlug;\nexport type CustomDomainLinkResult = LinkFetchResult;\n"
  },
  {
    "path": "lib/api/notification-helper.ts",
    "content": "import { log } from \"@/lib/utils\";\n\nexport default async function sendNotification({\n  viewId,\n  locationData,\n}: {\n  viewId: string;\n  locationData: {\n    continent: string | null;\n    country: string;\n    region: string;\n    city: string;\n  };\n}) {\n  return await fetch(`${process.env.NEXTAUTH_URL}/api/jobs/send-notification`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n    },\n    body: JSON.stringify({ viewId: viewId, locationData }),\n  })\n    .then(() => {})\n    .catch((error) => {\n      log({\n        message: `Failed to fetch notifications job in _/api/views_ route. \\n\\n Error: ${error} \\n\\n*Metadata*: \\`{viewId: ${viewId}}\\``,\n        type: \"error\",\n        mention: true,\n      });\n    });\n}\n\nexport async function sendViewerInvitation({\n  dataroomId,\n  linkId,\n  viewerIds,\n  senderUserId,\n}: {\n  dataroomId: string;\n  linkId: string;\n  viewerIds: string[];\n  senderUserId: string;\n}) {\n  for (var i = 0; i < viewerIds.length; ++i) {\n    await fetch(\n      `${process.env.NEXTAUTH_URL}/api/jobs/send-dataroom-view-invitation`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n        },\n        body: JSON.stringify({\n          dataroomId,\n          linkId,\n          viewerId: viewerIds[i],\n          senderUserId,\n        }),\n      },\n    )\n      .then(() => {})\n      .catch((error) => {\n        log({\n          message: `Failed to fetch dataroom viewer invite job. \\n\\n Error: ${error}`,\n          type: \"error\",\n          mention: true,\n        });\n      });\n  }\n  return;\n}\n"
  },
  {
    "path": "lib/api/teams/is-saml-enforced-for-email-domain.ts",
    "content": "import prisma from \"@/lib/prisma\";\nimport { isGenericEmail } from \"@/lib/utils/email-domain\";\n\n/**\n * Checks if SAML SSO is enforced for a given email address's domain.\n * When enforced, users with this email domain MUST use SSO to log in —\n * email magic links, Google OAuth, etc. are blocked.\n */\nexport async function isSamlEnforcedForEmailDomain(\n  email: string,\n): Promise<boolean> {\n  const emailDomain = email.split(\"@\")[1]?.toLowerCase();\n\n  if (!emailDomain) {\n    return false;\n  }\n\n  // Skip generic email providers — SSO enforcement doesn't apply\n  if (isGenericEmail(email)) {\n    return false;\n  }\n\n  const team = await prisma.team.findUnique({\n    where: {\n      ssoEmailDomain: emailDomain,\n    },\n    select: {\n      ssoEnforcedAt: true,\n    },\n  });\n\n  return !!team?.ssoEnforcedAt;\n}\n"
  },
  {
    "path": "lib/api/views/send-webhook-event.ts",
    "content": "import { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\nimport { sendWebhooks } from \"@/lib/webhook/send-webhooks\";\n\nexport async function sendLinkViewWebhook({\n  teamId,\n  clickData,\n}: {\n  teamId: string;\n  clickData: any;\n}) {\n  try {\n    const {\n      view_id: viewId,\n      link_id: linkId,\n      document_id: documentId,\n      dataroom_id: dataroomId,\n    } = clickData;\n\n    if (!viewId || !linkId || !teamId) {\n      throw new Error(\"Missing required parameters\");\n    }\n\n    // check if team is on paid plan\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      select: { plan: true },\n    });\n\n    if (\n      team?.plan === \"free\" ||\n      team?.plan === \"pro\" ||\n      team?.plan.includes(\"trial\")\n    ) {\n      // team is not on paid plan, so we don't need to send webhooks\n      return;\n    }\n\n    // check if team is paused\n    const teamIsPaused = await isTeamPausedById(teamId);\n    if (teamIsPaused) {\n      // team is paused, so we don't send webhooks\n      return;\n    }\n\n    // Get webhooks for team\n    const webhooks = await prisma.webhook.findMany({\n      where: {\n        teamId,\n        triggers: {\n          array_contains: [\"link.viewed\"],\n        },\n      },\n      select: {\n        pId: true,\n        url: true,\n        secret: true,\n      },\n    });\n\n    if (!webhooks || (webhooks && webhooks.length === 0)) {\n      // No webhooks for team, so we don't need to send webhooks\n      return;\n    }\n\n    // Get link information\n    const link = await prisma.link.findUnique({\n      where: { id: linkId, teamId },\n    });\n\n    if (!link) {\n      throw new Error(\"Link not found\");\n    }\n\n    // Prepare link data for webhook\n    const linkData = {\n      id: link.id,\n      url: link.domainId\n        ? `https://${link.domainSlug}/${link.slug}`\n        : `https://www.papermark.com/view/${link.id}`,\n      domain:\n        link.domainId && link.domainSlug ? link.domainSlug : \"papermark.com\",\n      key: link.domainId && link.slug ? link.slug : `view/${link.id}`,\n      name: link.name,\n      expiresAt: link.expiresAt?.toISOString() || null,\n      hasPassword: !!link.password,\n      allowList: link.allowList,\n      denyList: link.denyList,\n      enabledEmailProtection: link.emailProtected,\n      enabledEmailVerification: link.emailAuthenticated,\n      allowDownload: link.allowDownload ?? false,\n      isArchived: link.isArchived,\n      enabledNotification: link.enableNotification ?? false,\n      enabledFeedback: link.enableFeedback ?? false,\n      enabledQuestion: link.enableQuestion ?? false,\n      enabledScreenshotProtection: link.enableScreenshotProtection ?? false,\n      enabledAgreement: link.enableAgreement ?? false,\n      enabledWatermark: link.enableWatermark ?? false,\n      metaTitle: link.metaTitle,\n      metaDescription: link.metaDescription,\n      metaImage: link.metaImage,\n      metaFavicon: link.metaFavicon,\n      documentId: link.documentId,\n      dataroomId: link.dataroomId,\n      groupId: link.groupId,\n      permissionGroupId: link.permissionGroupId,\n      linkType: link.linkType,\n      teamId: teamId,\n      createdAt: link.createdAt.toISOString(),\n      updatedAt: link.updatedAt.toISOString(),\n    };\n\n    // Get view information\n    const view = await prisma.view.findUnique({\n      where: { id: viewId, linkId },\n      select: {\n        id: true,\n        viewedAt: true,\n        viewerEmail: true,\n        verified: true,\n      },\n    });\n\n    if (!view) {\n      throw new Error(\"View not found\");\n    }\n\n    // Prepare view data for webhook\n    const viewData = {\n      viewedAt: view.viewedAt.toISOString(),\n      viewId: view.id,\n      email: view.viewerEmail,\n      emailVerified: view.verified,\n      country: clickData.country,\n      city: clickData.city,\n      device: clickData.device,\n      browser: clickData.browser,\n      os: clickData.os,\n      ua: clickData.ua,\n      referer: clickData.referer,\n    };\n\n    // Get document and dataroom information for webhook in parallel\n    const [document, dataroom] = await Promise.all([\n      documentId\n        ? prisma.document.findUnique({\n            where: { id: documentId, teamId },\n            select: {\n              id: true,\n              name: true,\n              contentType: true,\n              createdAt: true,\n            },\n          })\n        : null,\n      dataroomId\n        ? prisma.dataroom.findUnique({\n            where: { id: dataroomId, teamId },\n            select: { id: true, name: true, createdAt: true },\n          })\n        : null,\n    ]);\n\n    // Prepare webhook payload\n    const webhookData = {\n      view: viewData,\n      link: linkData,\n      ...(document && {\n        document: {\n          id: document.id,\n          name: document.name,\n          contentType: document.contentType,\n          teamId: teamId,\n          createdAt: document.createdAt.toISOString(),\n        },\n      }),\n      ...(dataroom && {\n        dataroom: {\n          id: dataroom.id,\n          name: dataroom.name,\n          teamId: teamId,\n          createdAt: dataroom.createdAt.toISOString(),\n        },\n      }),\n    };\n\n    // Send webhooks\n    if (webhooks.length > 0) {\n      await sendWebhooks({\n        webhooks,\n        trigger: \"link.viewed\",\n        data: webhookData,\n      });\n    }\n    return;\n  } catch (error) {\n    log({\n      message: `Error sending webhooks for link view: ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    return;\n  }\n}\n"
  },
  {
    "path": "lib/auth/dataroom-auth.ts",
    "content": "import { NextApiRequest } from \"next\";\nimport { cookies } from \"next/headers\";\nimport { NextRequest } from \"next/server\";\n\nimport { ipAddress } from \"@vercel/functions\";\nimport { parse } from \"cookie\";\nimport crypto from \"crypto\";\nimport { z } from \"zod\";\n\nimport { redis } from \"@/lib/redis\";\n\nimport { LOCALHOST_IP } from \"../utils/geo\";\nimport { getIpAddress } from \"../utils/ip\";\n\nconst COOKIE_EXPIRATION_TIME = 23 * 60 * 60 * 1000; // 23 hours\n\n/**\n * Normalize IP addresses so that IPv6 loopback (::1),\n * IPv4-mapped IPv6 (::ffff:127.0.0.1) and plain 127.0.0.1\n * all compare equal.\n */\nfunction normalizeIp(ip: string): string {\n  const trimmed = ip.trim();\n  if (trimmed === \"::1\" || trimmed === \"::ffff:127.0.0.1\") {\n    return LOCALHOST_IP;\n  }\n  if (trimmed.startsWith(\"::ffff:\")) {\n    return trimmed.slice(7);\n  }\n  return trimmed;\n}\n\n/**\n * Generate a stable browser fingerprint from request headers.\n * Combines User-Agent, Accept-Language, and Sec-CH-UA client hints\n * which remain constant across IP changes but differ between\n * browsers/devices, making session sharing significantly harder.\n *\n * Sec-CH-UA headers are automatically sent by Chromium browsers and\n * cannot be overridden by simple cookie-copy tools or browser extensions.\n */\nexport function generateSessionFingerprint(headers: {\n  userAgent: string;\n  acceptLanguage?: string;\n  secChUa?: string;\n  secChUaPlatform?: string;\n  secChUaMobile?: string;\n}): string {\n  const parts = [\n    headers.userAgent,\n    headers.acceptLanguage ?? \"\",\n    headers.secChUa ?? \"\",\n    headers.secChUaPlatform ?? \"\",\n    headers.secChUaMobile ?? \"\",\n  ];\n  return crypto.createHash(\"sha256\").update(parts.join(\"|\")).digest(\"hex\");\n}\n\nexport function collectFingerprintHeaders(h: {\n  get(name: string): string | null;\n}): Parameters<typeof generateSessionFingerprint>[0] {\n  return {\n    userAgent: h.get(\"user-agent\") ?? \"unknown\",\n    acceptLanguage: h.get(\"accept-language\") ?? undefined,\n    secChUa: h.get(\"sec-ch-ua\") ?? undefined,\n    secChUaPlatform: h.get(\"sec-ch-ua-platform\") ?? undefined,\n    secChUaMobile: h.get(\"sec-ch-ua-mobile\") ?? undefined,\n  };\n}\n\nfunction getFingerprintFromNextRequest(request: NextRequest): string {\n  return generateSessionFingerprint(collectFingerprintHeaders(request.headers));\n}\n\nfunction getFingerprintFromPagesRequest(req: NextApiRequest): string {\n  const header = (name: string) => {\n    const v = req.headers[name];\n    return (Array.isArray(v) ? v[0] : v) ?? null;\n  };\n  return generateSessionFingerprint(\n    collectFingerprintHeaders({ get: header }),\n  );\n}\n\n// Define the Zod schema for session data\nexport const DataroomSessionSchema = z.object({\n  linkId: z.string(),\n  dataroomId: z.string(),\n  viewId: z.string(),\n  viewerId: z.string().optional(),\n  expiresAt: z.number(),\n  ipAddress: z.string(),\n  fingerprint: z.string().optional(),\n  verified: z.boolean(),\n});\n\n// Generate TypeScript type from Zod schema\nexport type DataroomSession = z.infer<typeof DataroomSessionSchema>;\n\nasync function createDataroomSession(\n  dataroomId: string,\n  linkId: string,\n  viewId: string,\n  ipAddress: string,\n  verified: boolean,\n  viewerId?: string,\n  fingerprint?: string,\n): Promise<{ token: string; expiresAt: number }> {\n  const sessionToken = crypto.randomBytes(32).toString(\"hex\");\n  const expiresAt = Date.now() + COOKIE_EXPIRATION_TIME;\n\n  const sessionData: DataroomSession = {\n    dataroomId,\n    linkId,\n    viewId,\n    viewerId,\n    expiresAt,\n    ipAddress: normalizeIp(ipAddress),\n    fingerprint,\n    verified,\n  };\n\n  DataroomSessionSchema.parse(sessionData);\n\n  await redis.set(\n    `dataroom_session:${sessionToken}`,\n    JSON.stringify(sessionData),\n    { pxat: expiresAt },\n  );\n\n  return {\n    token: sessionToken,\n    expiresAt,\n  };\n}\n\nasync function verifyDataroomSession(\n  request: NextRequest,\n  linkId: string,\n  dataroomId: string,\n): Promise<DataroomSession | null> {\n  if (!dataroomId) return null;\n\n  const sessionToken = cookies().get(`pm_drs_${linkId}`)?.value;\n  if (!sessionToken) return null;\n\n  const session = await redis.get(`dataroom_session:${sessionToken}`);\n  if (!session) return null;\n\n  try {\n    const sessionData = DataroomSessionSchema.parse(session);\n\n    if (sessionData.expiresAt < Date.now()) {\n      await redis.del(`dataroom_session:${sessionToken}`);\n      return null;\n    }\n\n    // Validate browser fingerprint instead of IP to handle VPN/network changes.\n    // Sessions created before this change won't have a fingerprint; for those\n    // we fall back to the legacy IP check for a smooth rollout.\n    if (sessionData.fingerprint) {\n      const currentFingerprint = getFingerprintFromNextRequest(request);\n      if (currentFingerprint !== sessionData.fingerprint) {\n        await redis.del(`dataroom_session:${sessionToken}`);\n        return null;\n      }\n    } else {\n      const ipAddressValue = normalizeIp(ipAddress(request) ?? LOCALHOST_IP);\n      if (ipAddressValue !== sessionData.ipAddress) {\n        await redis.del(`dataroom_session:${sessionToken}`);\n        return null;\n      }\n    }\n\n    if (\n      sessionData.linkId !== linkId ||\n      sessionData.dataroomId !== dataroomId\n    ) {\n      await redis.del(`dataroom_session:${sessionToken}`);\n      return null;\n    }\n\n    return sessionData;\n  } catch (error) {\n    await redis.del(`dataroom_session:${sessionToken}`);\n    return null;\n  }\n}\n\nexport async function verifyDataroomSessionInPagesRouter(\n  req: NextApiRequest,\n  linkId: string,\n  dataroomId: string,\n): Promise<DataroomSession | null> {\n  if (!dataroomId) return null;\n\n  const sessionData = await getDataroomSessionByLinkIdInPagesRouter(\n    req,\n    linkId,\n  );\n  if (!sessionData || sessionData.dataroomId !== dataroomId) return null;\n  return sessionData;\n}\n\n/**\n * Get dataroom session by linkId only (e.g. for downloads page where dataroomId may not be in context).\n */\nexport async function getDataroomSessionByLinkIdInPagesRouter(\n  req: NextApiRequest,\n  linkId: string,\n): Promise<DataroomSession | null> {\n  if (!linkId) return null;\n\n  const cookies = parse(req.headers.cookie || \"\");\n  const sessionToken = cookies[`pm_drs_${linkId}`];\n  if (!sessionToken) {\n    return null;\n  }\n\n  const session = await redis.get(`dataroom_session:${sessionToken}`);\n  if (!session) return null;\n\n  try {\n    const sessionData = DataroomSessionSchema.parse(session);\n\n    if (sessionData.expiresAt < Date.now()) {\n      await redis.del(`dataroom_session:${sessionToken}`);\n      return null;\n    }\n\n    if (sessionData.fingerprint) {\n      const currentFingerprint = getFingerprintFromPagesRequest(req);\n      if (currentFingerprint !== sessionData.fingerprint) {\n        await redis.del(`dataroom_session:${sessionToken}`);\n        return null;\n      }\n    } else {\n      const ipAddressValue = normalizeIp(\n        getIpAddress(req.headers) ?? LOCALHOST_IP,\n      );\n      if (ipAddressValue !== sessionData.ipAddress) {\n        await redis.del(`dataroom_session:${sessionToken}`);\n        return null;\n      }\n    }\n\n    if (sessionData.linkId !== linkId) {\n      await redis.del(`dataroom_session:${sessionToken}`);\n      return null;\n    }\n\n    return sessionData;\n  } catch (error) {\n    await redis.del(`dataroom_session:${sessionToken}`);\n    return null;\n  }\n}\n\n/**\n * Update the verified flag of an existing dataroom session (e.g. after OTP verification).\n * Caller must obtain sessionToken from the request cookie (pm_drs_{linkId}).\n */\nexport async function updateDataroomSessionVerified(\n  sessionToken: string,\n  verified: boolean,\n): Promise<boolean> {\n  if (!sessionToken) return false;\n\n  const raw = await redis.get(`dataroom_session:${sessionToken}`);\n  if (!raw) return false;\n\n  try {\n    const sessionData = DataroomSessionSchema.parse(\n      typeof raw === \"string\" ? JSON.parse(raw) : raw,\n    );\n    if (sessionData.expiresAt < Date.now()) {\n      await redis.del(`dataroom_session:${sessionToken}`);\n      return false;\n    }\n\n    const updated: DataroomSession = { ...sessionData, verified };\n    await redis.set(\n      `dataroom_session:${sessionToken}`,\n      JSON.stringify(updated),\n      {\n        pxat: sessionData.expiresAt,\n      },\n    );\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nexport { createDataroomSession, verifyDataroomSession };\n"
  },
  {
    "path": "lib/auth/link-session.ts",
    "content": "// lib/auth/link-session.ts\nimport { cookies } from \"next/headers\";\nimport { NextRequest } from \"next/server\";\n\nimport crypto from \"crypto\";\nimport { z } from \"zod\";\n\nimport {\n  collectFingerprintHeaders,\n  generateSessionFingerprint,\n} from \"@/lib/auth/dataroom-auth\";\nimport { redis } from \"@/lib/redis\";\n\nconst COOKIE_EXPIRATION_TIME = 23 * 60 * 60 * 1000; // 23 hours\n\nexport const LinkSessionSchema = z.object({\n  linkId: z.string(),\n  documentId: z.string().optional(),\n  dataroomId: z.string().optional(),\n  viewId: z.string(),\n  viewerId: z.string().optional(),\n  email: z.string(),\n  expiresAt: z.number(),\n  ipAddress: z.string(),\n  userAgent: z.string(),\n  fingerprint: z.string().optional(),\n  verified: z.boolean(),\n  linkType: z.enum([\"DOCUMENT_LINK\", \"DATAROOM_LINK\", \"WORKFLOW_LINK\"]),\n  accessCount: z.number().default(0),\n  maxAccesses: z.number().default(1000),\n  lastAccessedAt: z.number(),\n  createdAt: z.number(),\n});\n\nexport type LinkSession = z.infer<typeof LinkSessionSchema>;\n\nexport async function createLinkSession(\n  linkId: string,\n  linkType: \"DOCUMENT_LINK\" | \"DATAROOM_LINK\",\n  viewId: string,\n  email: string,\n  ipAddress: string,\n  userAgent: string,\n  verified: boolean,\n  viewerId?: string,\n  documentId?: string,\n  dataroomId?: string,\n  fingerprint?: string,\n): Promise<{ token: string; expiresAt: number }> {\n  const sessionToken = crypto.randomBytes(48).toString(\"base64url\");\n  const expiresAt = Date.now() + COOKIE_EXPIRATION_TIME;\n  const now = Date.now();\n\n  const sessionData: LinkSession = {\n    linkId,\n    linkType,\n    documentId,\n    dataroomId,\n    viewId,\n    viewerId,\n    email,\n    expiresAt,\n    ipAddress,\n    userAgent,\n    fingerprint,\n    verified,\n    accessCount: 1,\n    maxAccesses: 1000,\n    lastAccessedAt: now,\n    createdAt: now,\n  };\n\n  LinkSessionSchema.parse(sessionData);\n\n  await redis.set(`link_session:${sessionToken}`, JSON.stringify(sessionData), {\n    pxat: expiresAt,\n  });\n\n  // Track active sessions per viewer (for revocation)\n  if (viewerId) {\n    await redis.sadd(`viewer_sessions:${viewerId}`, sessionToken);\n    await redis.expire(\n      `viewer_sessions:${viewerId}`,\n      Math.floor(COOKIE_EXPIRATION_TIME / 1000),\n    );\n  }\n\n  return { token: sessionToken, expiresAt };\n}\n\nexport async function verifyLinkSession(\n  request: NextRequest,\n  linkId: string,\n): Promise<LinkSession | null> {\n  const sessionToken = cookies().get(`pm_ls_${linkId}`)?.value;\n\n  if (!sessionToken) return null;\n\n  const session = await redis.get(`link_session:${sessionToken}`);\n\n  if (!session) return null;\n\n  try {\n    const sessionData = LinkSessionSchema.parse(session);\n\n    // Check expiration\n    if (sessionData.expiresAt < Date.now()) {\n      await deleteLinkSession(sessionToken, sessionData.viewerId);\n      return null;\n    }\n\n    // Verify browser identity. New sessions store a fingerprint (UA + language\n    // + client hints); legacy sessions without a fingerprint fall back to a\n    // plain User-Agent comparison.\n    if (sessionData.fingerprint) {\n      const currentFingerprint = generateSessionFingerprint(\n        collectFingerprintHeaders(request.headers),\n      );\n      if (currentFingerprint !== sessionData.fingerprint) {\n        await deleteLinkSession(sessionToken, sessionData.viewerId);\n        return null;\n      }\n    } else {\n      const currentUserAgent = request.headers.get(\"user-agent\") ?? \"unknown\";\n      if (currentUserAgent !== sessionData.userAgent) {\n        await deleteLinkSession(sessionToken, sessionData.viewerId);\n        return null;\n      }\n    }\n\n    // Check link ID matches\n    if (sessionData.linkId !== linkId) {\n      await deleteLinkSession(sessionToken, sessionData.viewerId);\n      return null;\n    }\n\n    // Update access count and last accessed\n    sessionData.accessCount += 1;\n    sessionData.lastAccessedAt = Date.now();\n\n    // Check access limit\n    if (sessionData.accessCount > sessionData.maxAccesses) {\n      await deleteLinkSession(sessionToken, sessionData.viewerId);\n      return null;\n    }\n\n    // Rate limit check (max 100 requests per minute per session)\n    const rateLimitKey = `rate_limit:session:${sessionToken}`;\n    const requestCount = await redis.incr(rateLimitKey);\n    if (requestCount === 1) {\n      await redis.expire(rateLimitKey, 60);\n    }\n    if (requestCount > 100) {\n      return null; // Rate limited\n    }\n\n    // Update session in Redis\n    await redis.set(\n      `link_session:${sessionToken}`,\n      JSON.stringify(sessionData),\n      { pxat: sessionData.expiresAt },\n    );\n\n    return sessionData;\n  } catch (error) {\n    console.error(\"Session verification error:\", error);\n    await redis.del(`link_session:${sessionToken}`);\n    return null;\n  }\n}\n\nasync function deleteLinkSession(\n  sessionToken: string,\n  viewerId?: string,\n): Promise<void> {\n  await redis.del(`link_session:${sessionToken}`);\n  if (viewerId) {\n    await redis.srem(`viewer_sessions:${viewerId}`, sessionToken);\n  }\n}\n\nexport async function revokeLinkSession(linkId: string): Promise<void> {\n  const sessionToken = cookies().get(`pm_ls_${linkId}`)?.value;\n  if (sessionToken) {\n    const session = await redis.get(`link_session:${sessionToken}`);\n    if (session) {\n      const sessionData = LinkSessionSchema.parse(session);\n      await deleteLinkSession(sessionToken, sessionData.viewerId);\n    }\n  }\n}\n"
  },
  {
    "path": "lib/auth/preview-auth.ts",
    "content": "import crypto from \"crypto\";\nimport { z } from \"zod\";\n\nimport { redis } from \"@/lib/redis\";\n\nexport const PREVIEW_EXPIRATION_TIME = 20 * 60 * 1000; // 20 minutes\n\nconst ZPreviewSessionSchema = z.object({\n  userId: z.string(),\n  linkId: z.string(),\n  expiresAt: z.number(),\n});\n\ntype PreviewSession = z.infer<typeof ZPreviewSessionSchema>;\n\nasync function createPreviewSession(\n  linkId: string,\n  userId: string,\n): Promise<{ token: string; expiresAt: number }> {\n  const sessionToken = crypto.randomBytes(32).toString(\"hex\");\n  const expiresAt = Date.now() + PREVIEW_EXPIRATION_TIME;\n\n  const sessionData: PreviewSession = {\n    linkId,\n    userId,\n    expiresAt,\n  };\n\n  // Validate session data before storing\n  ZPreviewSessionSchema.parse(sessionData);\n\n  // Store session in Redis\n  await redis.set(\n    `preview_session:${sessionToken}`,\n    JSON.stringify(sessionData),\n    { pxat: expiresAt },\n  );\n\n  return {\n    token: sessionToken,\n    expiresAt,\n  };\n}\n\nasync function verifyPreviewSession(\n  previewToken: string,\n  userId: string,\n  linkId: string,\n): Promise<PreviewSession | null> {\n  const sessionToken = previewToken;\n  if (!sessionToken) return null;\n\n  const session = await redis.get(`preview_session:${sessionToken}`);\n  if (!session) return null;\n\n  try {\n    const sessionData = ZPreviewSessionSchema.parse(session);\n\n    // Check if the session is for the correct user\n    if (sessionData.userId !== userId) {\n      await redis.del(`preview_session:${sessionToken}`);\n      return null;\n    }\n\n    // Check if session is expired\n    if (sessionData.expiresAt < Date.now()) {\n      await redis.del(`preview_session:${sessionToken}`);\n      return null;\n    }\n\n    // Check if the session is for the correct link and dataroom\n    if (sessionData.linkId !== linkId) {\n      await redis.del(`preview_session:${sessionToken}`);\n      return null;\n    }\n\n    return sessionData;\n  } catch (error) {\n    console.error(\"Preview session verification error:\", error);\n    await redis.del(`preview_session:${sessionToken}`);\n    return null;\n  }\n}\n\nexport { createPreviewSession, verifyPreviewSession };\nexport type { PreviewSession };\n"
  },
  {
    "path": "lib/constants/folder-constants.ts",
    "content": "import {\n  ArchiveIcon,\n  AwardIcon,\n  BellIcon,\n  BookIcon,\n  BookmarkIcon,\n  BoxIcon,\n  BriefcaseIcon,\n  CloudIcon,\n  FileIcon,\n  FlagIcon,\n  FlameIcon,\n  FolderIcon,\n  FolderOpenIcon,\n  GlobeIcon,\n  HashIcon,\n  HeartIcon,\n  HomeIcon,\n  ImageIcon,\n  LayersIcon,\n  LightbulbIcon,\n  LockIcon,\n  MailIcon,\n  MusicIcon,\n  PaletteIcon,\n  PenToolIcon,\n  SettingsIcon,\n  ShieldIcon,\n  SparklesIcon,\n  StarIcon,\n  SunIcon,\n  TagIcon,\n  UsersIcon,\n  VideoIcon,\n  WrenchIcon,\n  ZapIcon,\n} from \"lucide-react\";\n\n// Folder icon definitions\nexport const FOLDER_ICONS = [\n  { id: \"folder\", icon: FolderIcon, label: \"Folder\" },\n  { id: \"folder-open\", icon: FolderOpenIcon, label: \"Folder Open\" },\n  { id: \"briefcase\", icon: BriefcaseIcon, label: \"Briefcase\" },\n  { id: \"archive\", icon: ArchiveIcon, label: \"Archive\" },\n  { id: \"box\", icon: BoxIcon, label: \"Box\" },\n  { id: \"file\", icon: FileIcon, label: \"File\" },\n  { id: \"book\", icon: BookIcon, label: \"Book\" },\n  { id: \"bookmark\", icon: BookmarkIcon, label: \"Bookmark\" },\n  { id: \"star\", icon: StarIcon, label: \"Star\" },\n  { id: \"heart\", icon: HeartIcon, label: \"Heart\" },\n  { id: \"lock\", icon: LockIcon, label: \"Lock\" },\n  { id: \"shield\", icon: ShieldIcon, label: \"Shield\" },\n  { id: \"users\", icon: UsersIcon, label: \"Users\" },\n  { id: \"settings\", icon: SettingsIcon, label: \"Settings\" },\n  { id: \"tag\", icon: TagIcon, label: \"Tag\" },\n  { id: \"layers\", icon: LayersIcon, label: \"Layers\" },\n  { id: \"globe\", icon: GlobeIcon, label: \"Globe\" },\n  { id: \"home\", icon: HomeIcon, label: \"Home\" },\n  { id: \"mail\", icon: MailIcon, label: \"Mail\" },\n  { id: \"image\", icon: ImageIcon, label: \"Image\" },\n  { id: \"video\", icon: VideoIcon, label: \"Video\" },\n  { id: \"music\", icon: MusicIcon, label: \"Music\" },\n  { id: \"palette\", icon: PaletteIcon, label: \"Palette\" },\n  { id: \"pen-tool\", icon: PenToolIcon, label: \"Design\" },\n  { id: \"lightbulb\", icon: LightbulbIcon, label: \"Ideas\" },\n  { id: \"zap\", icon: ZapIcon, label: \"Quick\" },\n  { id: \"wrench\", icon: WrenchIcon, label: \"Tools\" },\n  { id: \"sparkles\", icon: SparklesIcon, label: \"Sparkles\" },\n  { id: \"cloud\", icon: CloudIcon, label: \"Cloud\" },\n  { id: \"flag\", icon: FlagIcon, label: \"Flag\" },\n  { id: \"award\", icon: AwardIcon, label: \"Award\" },\n  { id: \"flame\", icon: FlameIcon, label: \"Fire\" },\n  { id: \"bell\", icon: BellIcon, label: \"Bell\" },\n  { id: \"sun\", icon: SunIcon, label: \"Sun\" },\n  { id: \"hash\", icon: HashIcon, label: \"Hashtag\" },\n] as const;\n\nexport type FolderIconId = (typeof FOLDER_ICONS)[number][\"id\"];\n\n// Folder color palette (using similar colors to tags but with folder-specific styling)\nexport const FOLDER_COLORS = [\n  {\n    id: \"gray\",\n    label: \"Gray\",\n    text: \"text-gray-600\",\n    bg: \"bg-gray-100\",\n    border: \"border-gray-300\",\n    iconClass: \"text-gray-600 dark:text-gray-400\",\n  },\n  {\n    id: \"red\",\n    label: \"Red\",\n    text: \"text-red-600\",\n    bg: \"bg-red-100\",\n    border: \"border-red-300\",\n    iconClass: \"text-red-500 dark:text-red-400\",\n  },\n  {\n    id: \"orange\",\n    label: \"Orange\",\n    text: \"text-orange-600\",\n    bg: \"bg-orange-100\",\n    border: \"border-orange-300\",\n    iconClass: \"text-orange-500 dark:text-orange-400\",\n  },\n  {\n    id: \"yellow\",\n    label: \"Yellow\",\n    text: \"text-yellow-600\",\n    bg: \"bg-yellow-100\",\n    border: \"border-yellow-300\",\n    iconClass: \"text-yellow-500 dark:text-yellow-400\",\n  },\n  {\n    id: \"green\",\n    label: \"Green\",\n    text: \"text-emerald-600\",\n    bg: \"bg-emerald-100\",\n    border: \"border-emerald-300\",\n    iconClass: \"text-emerald-500 dark:text-emerald-400\",\n  },\n  {\n    id: \"blue\",\n    label: \"Blue\",\n    text: \"text-blue-600\",\n    bg: \"bg-blue-100\",\n    border: \"border-blue-300\",\n    iconClass: \"text-blue-500 dark:text-blue-400\",\n  },\n  {\n    id: \"black\",\n    label: \"Black\",\n    text: \"text-neutral-900\",\n    bg: \"bg-neutral-100\",\n    border: \"border-neutral-300\",\n    iconClass: \"text-neutral-700 dark:text-neutral-400\",\n  },\n] as const;\n\nexport type FolderColorId = (typeof FOLDER_COLORS)[number][\"id\"];\n\n// Allowed values for server-side validation\nexport const ALLOWED_FOLDER_ICONS = FOLDER_ICONS.map((icon) => icon.id);\nexport const ALLOWED_FOLDER_COLORS = FOLDER_COLORS.map((color) => color.id);\n\n// Default values\nexport const DEFAULT_FOLDER_ICON: FolderIconId = \"folder\";\nexport const DEFAULT_FOLDER_COLOR: FolderColorId = \"gray\";\n\n// Helper to get icon component by ID\nexport function getFolderIcon(iconId: string | null | undefined) {\n  const foundIcon = FOLDER_ICONS.find((icon) => icon.id === iconId);\n  return foundIcon?.icon ?? FolderIcon;\n}\n\n// Helper to get color classes by ID\nexport function getFolderColorClasses(colorId: string | null | undefined) {\n  const foundColor = FOLDER_COLORS.find((color) => color.id === colorId);\n  return foundColor ?? FOLDER_COLORS[0]; // Default to gray\n}\n"
  },
  {
    "path": "lib/constants.ts",
    "content": "export const FADE_IN_ANIMATION_SETTINGS = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n  transition: { duration: 0.2 },\n};\n\nexport const STAGGER_CHILD_VARIANTS = {\n  hidden: { opacity: 0, y: 20 },\n  show: {\n    opacity: 1,\n    y: 0,\n    transition: { duration: 0.4, type: \"spring\" as const },\n  },\n};\n\nexport const PAPERMARK_HEADERS = {\n  headers: {\n    \"x-powered-by\":\n      \"Papermark - Secure Data Room Infrastructure for the modern web\",\n  },\n};\n\nexport const REACTIONS = [\n  {\n    emoji: \"❤️\",\n    label: \"heart\",\n  },\n  {\n    emoji: \"💸\",\n    label: \"money\",\n  },\n  {\n    emoji: \"👍\",\n    label: \"up\",\n  },\n  {\n    emoji: \"👎\",\n    label: \"down\",\n  },\n];\n\n// time in milliseconds\nexport const ONE_SECOND = 1000;\nexport const ONE_MINUTE = ONE_SECOND * 60;\nexport const TWO_MINUTES = ONE_MINUTE * 2;\nexport const ONE_HOUR = ONE_MINUTE * 60;\nexport const ONE_DAY = ONE_HOUR * 24;\nexport const ONE_WEEK = ONE_DAY * 7;\n\n// growing list of blocked pathnames that lead to 404s\nexport const BLOCKED_PATHNAMES = [\n  \"/phpmyadmin\",\n  \"/server-status\",\n  \"/wordpress\",\n  \"/_all_dbs\",\n  \"/wp-json\",\n];\n\n// list of paths that should be excluded from team checks\nexport const EXCLUDED_PATHS = [\"/\", \"/register\", \"/privacy\", \"/view\", \"/notification-preferences\"];\n\n// free limits\nexport const LIMITS = {\n  views: 20,\n};\n\nexport const SUPPORTED_DOCUMENT_MIME_TYPES = [\n  \"application/pdf\", // .pdf\n  \"application/vnd.ms-excel\", // .xls\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\", // .xlsx\n  \"application/vnd.ms-excel.sheet.macroEnabled.12\", // .xlsm\n  \"text/csv\", // .csv\n  \"text/tab-separated-values\", // .tsv\n  \"application/vnd.oasis.opendocument.spreadsheet\", // .ods\n  \"application/vnd.ms-powerpoint\", // .ppt\n  \"application/vnd.openxmlformats-officedocument.presentationml.presentation\", // .pptx\n  \"application/vnd.oasis.opendocument.presentation\", // .odp\n  \"application/vnd.apple.keynote\", // .key\n  \"application/x-iwork-keynote-sffkey\", // .key (older format)\n  \"application/msword\", // .doc\n  \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\", // .docx\n  \"application/vnd.oasis.opendocument.text\", // .odt\n  \"application/rtf\", // .rtf\n  \"text/rtf\", // .rtf\n  \"text/plain\", // .txt\n  \"text/markdown\", // .md\n  \"image/vnd.dwg\", // .dwg\n  \"image/vnd.dxf\", // .dxf\n  \"image/png\", // .png\n  \"image/jpeg\", // .jpeg\n  \"image/jpg\", // .jpg\n  \"application/zip\", // .zip\n  \"application/x-zip-compressed\", // .zip\n  \"video/mp4\", // .mp4\n  \"video/quicktime\", // .mov\n  \"video/x-msvideo\", // .avi\n  \"video/webm\", // .webm\n  \"video/ogg\", // .ogg\n  \"audio/mp4\", // .m4a\n  \"audio/x-m4a\", // .m4a (older MIME type)\n  \"audio/m4a\", // .m4a (alternative MIME type)\n  \"audio/mpeg\", // .mp3\n  \"application/vnd.google-earth.kml+xml\", // .kml\n  \"application/vnd.google-earth.kmz\", // .kmz\n  \"application/vnd.ms-outlook\", // .msg\n];\n\n// Upload configurations for different plan types and contexts\nexport const FREE_PLAN_ACCEPTED_FILE_TYPES = {\n  \"application/pdf\": [], // \".pdf\"\n  \"application/vnd.ms-excel\": [], // \".xls\"\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\": [], // \".xlsx\"\n  \"text/csv\": [], // \".csv\"\n  \"application/vnd.oasis.opendocument.spreadsheet\": [], // \".ods\"\n  \"image/png\": [], // \".png\"\n  \"image/jpeg\": [], // \".jpeg\"\n  \"image/jpg\": [], // \".jpg\"\n};\n\nexport const FULL_PLAN_ACCEPTED_FILE_TYPES = {\n  \"application/pdf\": [], // \".pdf\"\n  \"application/vnd.ms-excel\": [], // \".xls\"\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\": [], // \".xlsx\"\n  \"application/vnd.ms-excel.sheet.macroEnabled.12\": [\".xlsm\"], // \".xlsm\"\n  \"text/csv\": [], // \".csv\"\n  \"text/tab-separated-values\": [\".tsv\"], // \".tsv\"\n  \"application/vnd.oasis.opendocument.spreadsheet\": [], // \".ods\"\n  \"application/vnd.ms-powerpoint\": [], // \".ppt\"\n  \"application/vnd.openxmlformats-officedocument.presentationml.presentation\":\n    [], // \".pptx\"\n  \"application/vnd.oasis.opendocument.presentation\": [], // \".odp\"\n  \"application/vnd.apple.keynote\": [\".key\"], // \".key\"\n  \"application/x-iwork-keynote-sffkey\": [\".key\"], // \".key\"\n  \"application/msword\": [], // \".doc\"\n  \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\": [], // \".docx\"\n  \"application/vnd.oasis.opendocument.text\": [], // \".odt\"\n  \"application/rtf\": [], // \".rtf\"\n  \"text/rtf\": [], // \".rtf\"\n  \"text/plain\": [], // \".txt\"\n  \"image/vnd.dwg\": [\".dwg\"], // \".dwg\"\n  \"image/vnd.dxf\": [\".dxf\"], // \".dxf\"\n  \"image/png\": [], // \".png\"\n  \"image/jpeg\": [], // \".jpeg\"\n  \"image/jpg\": [], // \".jpg\"\n  \"application/zip\": [], // \".zip\"\n  \"application/x-zip-compressed\": [], // \".zip\"\n  \"video/mp4\": [\".mp4\"], // \".mp4\"\n  \"video/quicktime\": [\".mov\"], // \".mov\"\n  \"video/x-msvideo\": [\".avi\"], // \".avi\"\n  \"video/webm\": [\".webm\"], // \".webm\"\n  \"video/ogg\": [\".ogg\"], // \".ogg\"\n  \"audio/mp4\": [\".m4a\"], // \".m4a\"\n  \"audio/x-m4a\": [\".m4a\"], // \".m4a\"\n  \"audio/m4a\": [\".m4a\"], // \".m4a\"\n  \"audio/mpeg\": [\".mp3\"], // \".mp3\"\n  \"application/vnd.google-earth.kml+xml\": [\".kml\"], // \".kml\"\n  \"application/vnd.google-earth.kmz\": [\".kmz\"], // \".kmz\"\n  \"application/vnd.ms-outlook\": [\".msg\"], // \".msg\"\n};\n\nexport const VIEWER_ACCEPTED_FILE_TYPES = {\n  \"application/pdf\": [], // \".pdf\"\n  \"application/vnd.ms-excel\": [], // \".xls\"\n  \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\": [], // \".xlsx\"\n  \"application/vnd.ms-excel.sheet.macroEnabled.12\": [\".xlsm\"], // \".xlsm\"\n  \"text/csv\": [], // \".csv\"\n  \"text/tab-separated-values\": [\".tsv\"], // \".tsv\"\n  \"application/vnd.oasis.opendocument.spreadsheet\": [], // \".ods\"\n  \"application/vnd.ms-powerpoint\": [], // \".ppt\"\n  \"application/vnd.openxmlformats-officedocument.presentationml.presentation\":\n    [], // \".pptx\"\n  \"application/vnd.oasis.opendocument.presentation\": [], // \".odp\"\n  \"application/vnd.apple.keynote\": [\".key\"], // \".key\"\n  \"application/x-iwork-keynote-sffkey\": [\".key\"], // \".key\"\n  \"application/msword\": [], // \".doc\"\n  \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\": [], // \".docx\"\n  \"application/vnd.oasis.opendocument.text\": [], // \".odt\"\n  \"application/rtf\": [], // \".rtf\"\n  \"text/rtf\": [], // \".rtf\"\n  \"text/plain\": [], // \".txt\"\n  \"image/png\": [], // \".png\"\n  \"image/jpeg\": [], // \".jpeg\"\n  \"image/jpg\": [], // \".jpg\"\n  \"application/zip\": [], // \".zip\"\n  \"application/x-zip-compressed\": [], // \".zip\"\n  \"application/vnd.ms-outlook\": [\".msg\"], // \".msg\"\n};\n\nexport const SUPPORTED_DOCUMENT_SIMPLE_TYPES = [\n  \"pdf\",\n  \"notion\",\n  \"link\",\n  \"sheet\",\n  \"slides\",\n  \"docs\",\n  \"cad\",\n  \"image\",\n  \"zip\",\n  \"video\",\n  \"map\",\n  \"email\",\n] as const;\n\nexport const VIDEO_EVENT_TYPES = [\n  // Playback events\n  \"loaded\", // Initial load\n  \"played\", // Play pressed\n  \"seeked\", // User seeked to position\n\n  // Speed events\n  \"rate_changed\", // Playback speed changed\n\n  // Volume events\n  \"volume_up\", // Volume increased\n  \"volume_down\", // Volume decreased\n  \"muted\", // Muted\n  \"unmuted\", // Unmuted\n\n  // View state events\n  \"focus\", // Window/tab gained focus\n  \"blur\", // Window/tab lost focus\n  \"enterfullscreen\", // Entered fullscreen\n  \"exitfullscreen\", // Exited fullscreen\n] as const;\n\nexport const COUNTRIES: { [key: string]: string } = {\n  AF: \"Afghanistan\",\n  AL: \"Albania\",\n  DZ: \"Algeria\",\n  AS: \"American Samoa\",\n  AD: \"Andorra\",\n  AO: \"Angola\",\n  AI: \"Anguilla\",\n  AQ: \"Antarctica\",\n  AG: \"Antigua and Barbuda\",\n  AR: \"Argentina\",\n  AM: \"Armenia\",\n  AW: \"Aruba\",\n  AU: \"Australia\",\n  AT: \"Austria\",\n  AZ: \"Azerbaijan\",\n  BS: \"Bahamas\",\n  BH: \"Bahrain\",\n  BD: \"Bangladesh\",\n  BB: \"Barbados\",\n  BY: \"Belarus\",\n  BE: \"Belgium\",\n  BZ: \"Belize\",\n  BJ: \"Benin\",\n  BM: \"Bermuda\",\n  BT: \"Bhutan\",\n  BO: \"Bolivia\",\n  BA: \"Bosnia and Herzegovina\",\n  BW: \"Botswana\",\n  BV: \"Bouvet Island\",\n  BR: \"Brazil\",\n  IO: \"British Indian Ocean Territory\",\n  BN: \"Brunei Darussalam\",\n  BG: \"Bulgaria\",\n  BF: \"Burkina Faso\",\n  BI: \"Burundi\",\n  KH: \"Cambodia\",\n  CM: \"Cameroon\",\n  CA: \"Canada\",\n  CV: \"Cape Verde\",\n  KY: \"Cayman Islands\",\n  CF: \"Central African Republic\",\n  TD: \"Chad\",\n  CL: \"Chile\",\n  CN: \"China\",\n  CX: \"Christmas Island\",\n  CC: \"Cocos (Keeling) Islands\",\n  CO: \"Colombia\",\n  KM: \"Comoros\",\n  CG: \"Congo (Republic)\",\n  CD: \"Congo (Democratic Republic)\",\n  CK: \"Cook Islands\",\n  CR: \"Costa Rica\",\n  CI: \"Ivory Coast\",\n  HR: \"Croatia\",\n  CU: \"Cuba\",\n  CY: \"Cyprus\",\n  CZ: \"Czech Republic\",\n  DK: \"Denmark\",\n  DJ: \"Djibouti\",\n  DM: \"Dominica\",\n  DO: \"Dominican Republic\",\n  EC: \"Ecuador\",\n  EG: \"Egypt\",\n  SV: \"El Salvador\",\n  GQ: \"Equatorial Guinea\",\n  ER: \"Eritrea\",\n  EE: \"Estonia\",\n  ET: \"Ethiopia\",\n  FK: \"Falkland Islands\",\n  FO: \"Faroe Islands\",\n  FJ: \"Fiji\",\n  FI: \"Finland\",\n  FR: \"France\",\n  GF: \"French Guiana\",\n  PF: \"French Polynesia\",\n  TF: \"French Southern Territories\",\n  GA: \"Gabon\",\n  GM: \"Gambia\",\n  GE: \"Georgia\",\n  DE: \"Germany\",\n  GH: \"Ghana\",\n  GI: \"Gibraltar\",\n  GR: \"Greece\",\n  GL: \"Greenland\",\n  GD: \"Grenada\",\n  GP: \"Guadeloupe\",\n  GU: \"Guam\",\n  GT: \"Guatemala\",\n  GN: \"Guinea\",\n  GW: \"Guinea-Bissau\",\n  GY: \"Guyana\",\n  HT: \"Haiti\",\n  HM: \"Heard Island and McDonald Islands\",\n  VA: \"Vatican City\",\n  HN: \"Honduras\",\n  HK: \"Hong Kong\",\n  HU: \"Hungary\",\n  IS: \"Iceland\",\n  IN: \"India\",\n  ID: \"Indonesia\",\n  IR: \"Iran\",\n  IQ: \"Iraq\",\n  IE: \"Ireland\",\n  IL: \"Israel\",\n  IT: \"Italy\",\n  JM: \"Jamaica\",\n  JP: \"Japan\",\n  JO: \"Jordan\",\n  KZ: \"Kazakhstan\",\n  KE: \"Kenya\",\n  KI: \"Kiribati\",\n  KP: \"North Korea\",\n  KR: \"South Korea\",\n  KW: \"Kuwait\",\n  KG: \"Kyrgyzstan\",\n  LA: \"Laos\",\n  LV: \"Latvia\",\n  LB: \"Lebanon\",\n  LS: \"Lesotho\",\n  LR: \"Liberia\",\n  LY: \"Libya\",\n  LI: \"Liechtenstein\",\n  LT: \"Lithuania\",\n  LU: \"Luxembourg\",\n  MO: \"Macao\",\n  MG: \"Madagascar\",\n  MW: \"Malawi\",\n  MY: \"Malaysia\",\n  MV: \"Maldives\",\n  ML: \"Mali\",\n  MT: \"Malta\",\n  MH: \"Marshall Islands\",\n  MQ: \"Martinique\",\n  MR: \"Mauritania\",\n  MU: \"Mauritius\",\n  YT: \"Mayotte\",\n  MX: \"Mexico\",\n  FM: \"Micronesia\",\n  MD: \"Moldova\",\n  MC: \"Monaco\",\n  MN: \"Mongolia\",\n  MS: \"Montserrat\",\n  MA: \"Morocco\",\n  MZ: \"Mozambique\",\n  MM: \"Myanmar\",\n  NA: \"Namibia\",\n  NR: \"Nauru\",\n  NP: \"Nepal\",\n  NL: \"Netherlands\",\n  NC: \"New Caledonia\",\n  NZ: \"New Zealand\",\n  NI: \"Nicaragua\",\n  NE: \"Niger\",\n  NG: \"Nigeria\",\n  NU: \"Niue\",\n  NF: \"Norfolk Island\",\n  MK: \"Macedonia\",\n  MP: \"Northern Mariana Islands\",\n  NO: \"Norway\",\n  OM: \"Oman\",\n  PK: \"Pakistan\",\n  PW: \"Palau\",\n  PS: \"Palestine\",\n  PA: \"Panama\",\n  PG: \"Papua New Guinea\",\n  PY: \"Paraguay\",\n  PE: \"Peru\",\n  PH: \"Philippines\",\n  PN: \"Pitcairn\",\n  PL: \"Poland\",\n  PT: \"Portugal\",\n  PR: \"Puerto Rico\",\n  QA: \"Qatar\",\n  RE: \"Reunion\",\n  RO: \"Romania\",\n  RU: \"Russia\",\n  RW: \"Rwanda\",\n  SH: \"Saint Helena\",\n  KN: \"Saint Kitts and Nevis\",\n  LC: \"Saint Lucia\",\n  PM: \"Saint Pierre and Miquelon\",\n  VC: \"Saint Vincent and the Grenadines\",\n  WS: \"Samoa\",\n  SM: \"San Marino\",\n  ST: \"Sao Tome and Principe\",\n  SA: \"Saudi Arabia\",\n  SN: \"Senegal\",\n  SC: \"Seychelles\",\n  SL: \"Sierra Leone\",\n  SG: \"Singapore\",\n  SK: \"Slovakia\",\n  SI: \"Slovenia\",\n  SB: \"Solomon Islands\",\n  SO: \"Somalia\",\n  ZA: \"South Africa\",\n  GS: \"South Georgia and the South Sandwich Islands\",\n  ES: \"Spain\",\n  LK: \"Sri Lanka\",\n  SD: \"Sudan\",\n  SR: \"Suriname\",\n  SJ: \"Svalbard and Jan Mayen\",\n  SZ: \"Eswatini\",\n  SE: \"Sweden\",\n  CH: \"Switzerland\",\n  SY: \"Syrian Arab Republic\",\n  TW: \"Taiwan\",\n  TJ: \"Tajikistan\",\n  TZ: \"Tanzania\",\n  TH: \"Thailand\",\n  TL: \"Timor-Leste\",\n  TG: \"Togo\",\n  TK: \"Tokelau\",\n  TO: \"Tonga\",\n  TT: \"Trinidad and Tobago\",\n  TN: \"Tunisia\",\n  TR: \"Turkey\",\n  TM: \"Turkmenistan\",\n  TC: \"Turks and Caicos Islands\",\n  TV: \"Tuvalu\",\n  UG: \"Uganda\",\n  UA: \"Ukraine\",\n  AE: \"United Arab Emirates\",\n  GB: \"United Kingdom\",\n  US: \"United States\",\n  UM: \"United States Minor Outlying Islands\",\n  UY: \"Uruguay\",\n  UZ: \"Uzbekistan\",\n  VU: \"Vanuatu\",\n  VE: \"Venezuela\",\n  VN: \"Vietnam\",\n  VG: \"Virgin Islands, British\",\n  VI: \"Virgin Islands, U.S.\",\n  WF: \"Wallis and Futuna\",\n  EH: \"Western Sahara\",\n  YE: \"Yemen\",\n  ZM: \"Zambia\",\n  ZW: \"Zimbabwe\",\n  AX: \"Åland Islands\",\n  BQ: \"Bonaire, Sint Eustatius and Saba\",\n  CW: \"Curaçao\",\n  GG: \"Guernsey\",\n  IM: \"Isle of Man\",\n  JE: \"Jersey\",\n  ME: \"Montenegro\",\n  BL: \"Saint Barthélemy\",\n  MF: \"Saint Martin (French part)\",\n  RS: \"Serbia\",\n  SX: \"Sint Maarten (Dutch part)\",\n  SS: \"South Sudan\",\n  XK: \"Kosovo\",\n};\n\nexport const COUNTRY_CODES = Object.keys(COUNTRIES) as [string, ...string[]];\n\nexport const EU_COUNTRY_CODES = [\n  \"AT\",\n  \"BE\",\n  \"BG\",\n  \"CY\",\n  \"CZ\",\n  \"DE\",\n  \"DK\",\n  \"EE\",\n  \"ES\",\n  \"FI\",\n  \"FR\",\n  \"GB\",\n  \"GR\",\n  \"HR\",\n  \"HU\",\n  \"IE\",\n  \"IS\",\n  \"IT\",\n  \"LI\",\n  \"LT\",\n  \"LU\",\n  \"LV\",\n  \"MT\",\n  \"NL\",\n  \"NO\",\n  \"PL\",\n  \"PT\",\n  \"RO\",\n  \"SE\",\n  \"SI\",\n  \"SK\",\n];\n\nexport const SYSTEM_FILES = [\".DS_Store\", \"Thumbs.db\", \"node_modules\"];\n"
  },
  {
    "path": "lib/cron/index.ts",
    "content": "import { Receiver } from \"@upstash/qstash\";\nimport { Client } from \"@upstash/qstash\";\nimport Bottleneck from \"bottleneck\";\n\n// we're using Bottleneck to avoid running into Resend's rate limit of 10 req/s\nexport const limiter = new Bottleneck({\n  maxConcurrent: 1, // maximum concurrent requests\n  minTime: 100, // minimum time between requests in ms\n});\n\n// we're using Upstash's Receiver to verify the request signature\nexport const receiver = new Receiver({\n  currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY || \"\",\n  nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY || \"\",\n});\n\nexport const qstash = new Client({\n  token: process.env.QSTASH_TOKEN || \"\",\n});\n"
  },
  {
    "path": "lib/cron/verify-qstash.ts",
    "content": "import { receiver } from \".\";\nimport { log } from \"../utils\";\n\nexport const verifyQstashSignature = async ({\n  req,\n  rawBody,\n}: {\n  req: Request;\n  rawBody: string; // Make sure to pass the raw body not the parsed JSON\n}) => {\n  // skip verification in local development\n  if (process.env.VERCEL !== \"1\") {\n    return;\n  }\n\n  const signature = req.headers.get(\"Upstash-Signature\");\n\n  if (!signature) {\n    throw new Error(\"Upstash-Signature header not found.\");\n  }\n\n  const isValid = await receiver.verify({\n    signature,\n    body: rawBody,\n  });\n\n  if (!isValid) {\n    const url = req.url;\n    const messageId = req.headers.get(\"Upstash-Message-Id\");\n\n    log({\n      message: `Invalid QStash request signature: *${url}* - *${messageId}*`,\n      type: \"error\",\n      mention: true,\n    });\n\n    throw new Error(\"Invalid QStash request signature.\");\n  }\n};\n"
  },
  {
    "path": "lib/dataroom/build-folder-hierarchy.ts",
    "content": "import { safeSlugify } from \"@/lib/utils\";\n\nexport interface FolderInput {\n  id: string;\n  name: string;\n  parentId: string | null;\n}\n\n/**\n * Builds a map of folder ID -> computed path based on the parentId hierarchy.\n *\n * This ensures the download folder structure matches what the UI shows\n * (which uses parentId to build the tree), rather than relying on the\n * materialized `path` field which can become stale after renames/moves.\n *\n * @param folders - Array of folders with id, name, and parentId\n * @returns Map of folderId -> computed slugified path (e.g., \"/02-company-background/docs\")\n */\nexport function buildFolderPathsFromHierarchy(\n  folders: FolderInput[],\n): Map<string, string> {\n  const folderById = new Map(folders.map((f) => [f.id, f]));\n  const pathCache = new Map<string, string>();\n\n  function computePath(folderId: string, visited: Set<string>): string {\n    if (pathCache.has(folderId)) return pathCache.get(folderId)!;\n\n    // Prevent infinite loops from circular parentId references\n    if (visited.has(folderId)) {\n      const folder = folderById.get(folderId);\n      const fallbackPath = `/${safeSlugify(folder?.name ?? folderId)}`;\n      pathCache.set(folderId, fallbackPath);\n      return fallbackPath;\n    }\n    visited.add(folderId);\n\n    const folder = folderById.get(folderId);\n    if (!folder) return \"\";\n\n    let parentPath = \"\";\n    if (folder.parentId && folderById.has(folder.parentId)) {\n      parentPath = computePath(folder.parentId, visited);\n    }\n\n    const path = `${parentPath}/${safeSlugify(folder.name)}`;\n    pathCache.set(folderId, path);\n    return path;\n  }\n\n  for (const folder of folders) {\n    computePath(folder.id, new Set());\n  }\n\n  return pathCache;\n}\n\n/**\n * Collects all descendant folder IDs for a given root folder using\n * the parentId hierarchy (BFS traversal).\n *\n * Use this instead of `path: { startsWith }` queries, which rely on\n * the materialized `path` field that can become stale after renames/moves.\n *\n * @param rootId - The ID of the root folder\n * @param folders - All folders to search through (must include at least the subtree)\n * @returns Set of descendant folder IDs (does NOT include rootId itself)\n */\nexport function collectDescendantIds(\n  rootId: string,\n  folders: { id: string; parentId: string | null }[],\n): Set<string> {\n  // Build a children lookup map\n  const childrenMap = new Map<string, string[]>();\n  for (const folder of folders) {\n    if (folder.parentId) {\n      const siblings = childrenMap.get(folder.parentId) ?? [];\n      siblings.push(folder.id);\n      childrenMap.set(folder.parentId, siblings);\n    }\n  }\n\n  // BFS from rootId\n  const descendants = new Set<string>();\n  const queue = childrenMap.get(rootId) ?? [];\n  while (queue.length > 0) {\n    const current = queue.shift()!;\n    if (descendants.has(current)) continue; // guard against cycles\n    descendants.add(current);\n    const children = childrenMap.get(current) ?? [];\n    queue.push(...children);\n  }\n\n  return descendants;\n}\n\n/**\n * Builds a folder name map from computed paths.\n * Maps computedPath -> { name, id } for looking up display names.\n */\nexport function buildFolderNameMap(\n  folders: FolderInput[],\n  pathMap: Map<string, string>,\n): Map<string, { name: string; id: string }> {\n  const nameMap = new Map<string, { name: string; id: string }>();\n  for (const folder of folders) {\n    const computedPath = pathMap.get(folder.id);\n    if (computedPath) {\n      nameMap.set(computedPath, { name: folder.name, id: folder.id });\n    }\n  }\n  return nameMap;\n}\n"
  },
  {
    "path": "lib/dataroom/index-generator.ts",
    "content": "import { DataroomFolder, Document, DocumentVersion } from \"@prisma/client\";\nimport ExcelJS from \"exceljs\";\n\nimport { LinkWithDataroom } from \"../types\";\nimport {\n  DataroomIndex,\n  DataroomIndexEntry,\n  IndexFileFormat,\n} from \"../types/index-file\";\n\ninterface GenerateIndexOptions {\n  format?: IndexFileFormat;\n  baseUrl?: string;\n  showHierarchicalIndex?: boolean;\n}\n\ninterface DataroomDocumentWithVersion {\n  id: string;\n  folderId: string | null;\n  orderIndex: number | null;\n  updatedAt: Date;\n  createdAt: Date;\n  hierarchicalIndex: string | null;\n  document: {\n    id: string;\n    name: string;\n    versions: {\n      id: string;\n      versionNumber: number;\n      type: string;\n      hasPages: boolean;\n      file: string;\n      fileSize: number;\n      isVertical: boolean;\n      numPages: number;\n      updatedAt: Date;\n    }[];\n  };\n}\n\nconst formatBytes = (bytes: number): number => {\n  if (bytes === 0) return 0;\n  // Convert bytes to MB by dividing by 1024^2 (1MB = 1024 * 1024 bytes)\n  const mb = bytes / (1024 * 1024);\n  // Round to 2 decimal places\n  return parseFloat(mb.toFixed(2));\n};\n\nexport async function generateDataroomIndex(\n  link: LinkWithDataroom,\n  options: GenerateIndexOptions = {},\n): Promise<{ data: Buffer; filename: string; mimeType: string }> {\n  const { format = \"excel\", baseUrl, showHierarchicalIndex = false } = options;\n\n  // Generate the index data structure\n  const indexData: DataroomIndex = {\n    dataroomId: link.dataroom.id,\n    dataroomName: link.dataroom.name,\n    linkId: link.id,\n    generatedAt: new Date(),\n    entries: [],\n    totalFiles: 0,\n    totalFolders: 0,\n    totalSize: 0,\n  };\n\n  // Helper function to process folders recursively\n  function processFolder(\n    folder: DataroomFolder,\n    parentPath: string = \"\",\n  ): DataroomIndexEntry[] {\n    const entries: DataroomIndexEntry[] = [];\n    const currentPath = parentPath\n      ? `${parentPath}/${folder.name}`\n      : `/${folder.name}`;\n\n    // Add folder entry\n    entries.push({\n      hierarchicalIndex: showHierarchicalIndex\n        ? folder.hierarchicalIndex\n        : undefined,\n      name: folder.name,\n      type: \"Folder\",\n      path: currentPath.split(\"/\").slice(0, -1).join(\"/\") || \"/\",\n      lastUpdated: folder.updatedAt || new Date(),\n      createdAt: folder.createdAt || new Date(),\n    });\n    indexData.totalFolders++;\n\n    // Process documents in the folder\n    const documents = (\n      link.dataroom?.documents as DataroomDocumentWithVersion[]\n    ).filter((doc) => doc.folderId === folder.id);\n\n    for (const doc of documents) {\n      const latestVersion =\n        doc.document.versions[doc.document.versions.length - 1];\n      const entry: DataroomIndexEntry = {\n        hierarchicalIndex: showHierarchicalIndex\n          ? doc.hierarchicalIndex\n          : undefined,\n        name: doc.document.name,\n        type: \"File\",\n        path: `${currentPath}/`,\n        lastUpdated: latestVersion?.updatedAt || new Date(),\n        createdAt: doc.createdAt,\n        pages: latestVersion?.numPages ?? 0,\n        size: formatBytes(latestVersion?.fileSize ?? 0),\n        onlineUrl: `${baseUrl}/d/${doc.id}`,\n        mimeType: latestVersion?.type,\n        version: latestVersion?.versionNumber,\n      };\n      entries.push(entry);\n      indexData.totalFiles++;\n      indexData.totalSize += latestVersion?.fileSize ?? 0;\n    }\n\n    // Process subfolders recursively\n    const childFolders = link.dataroom.folders.filter(\n      (f: DataroomFolder) => f.parentId === folder.id,\n    );\n    for (const childFolder of childFolders) {\n      entries.push(...processFolder(childFolder, currentPath));\n    }\n\n    return entries;\n  }\n\n  // Process root level items\n  const rootFolders = link.dataroom.folders.filter(\n    (f: DataroomFolder) => !f.parentId,\n  );\n  const rootDocuments = (\n    link.dataroom.documents as DataroomDocumentWithVersion[]\n  ).filter((d) => !d.folderId);\n\n  // Add root dataroom entry\n  indexData.entries.push({\n    hierarchicalIndex: showHierarchicalIndex ? \"0\" : undefined,\n    name: link.dataroom.name,\n    type: \"Root Folder\",\n    path: \"\",\n    lastUpdated: link.dataroom.lastUpdatedAt,\n    createdAt: link.dataroom.createdAt,\n    onlineUrl: `${baseUrl}`,\n  });\n\n  // Process root folders\n  for (const folder of rootFolders) {\n    indexData.entries.push(...processFolder(folder));\n  }\n\n  // Process root documents\n  for (const doc of rootDocuments) {\n    const latestVersion =\n      doc.document.versions[doc.document.versions.length - 1];\n    indexData.entries.push({\n      hierarchicalIndex: showHierarchicalIndex\n        ? doc.hierarchicalIndex\n        : undefined,\n      name: doc.document.name,\n      type: \"File\",\n      path: \"/\",\n      lastUpdated:\n        doc.updatedAt > latestVersion?.updatedAt\n          ? doc.updatedAt\n          : latestVersion?.updatedAt || new Date(),\n      createdAt: doc.createdAt,\n      pages: latestVersion?.numPages ?? 0,\n      size: formatBytes(latestVersion?.fileSize ?? 0),\n      onlineUrl: `${baseUrl}/d/${doc.id}`,\n      mimeType: latestVersion?.type || \"unknown\",\n      version: latestVersion?.versionNumber,\n    });\n    indexData.totalFiles++;\n    indexData.totalSize += latestVersion?.fileSize ?? 0;\n  }\n\n  // Generate the filename\n  const date = indexData.generatedAt\n    .toLocaleDateString(\"en-US\", {\n      year: \"numeric\",\n      month: \"short\",\n      day: \"numeric\",\n    })\n    .replace(\",\", \"\"); // Remove the comma that might appear between day and year\n  const safeFilename = `${link.dataroom.name.replace(/[^a-zA-Z0-9-_]/g, \"_\")}_Index_${date.replace(/[^a-zA-Z0-9]/g, \"_\")}`;\n\n  // Generate the output file based on the requested format\n  switch (format) {\n    case \"excel\": {\n      const workbook = new ExcelJS.Workbook();\n      const worksheet = workbook.addWorksheet(\"Dataroom Index\");\n\n      // Define columns with their configuration\n      const columns = [\n        ...(showHierarchicalIndex\n          ? [{ header: \"Index\", key: \"hierarchicalIndex\", width: 10 }]\n          : []),\n        { header: \"Name\", key: \"name\", width: 30 },\n        { header: \"Type\", key: \"type\", width: 10 },\n        { header: \"Path\", key: \"path\", width: 40 },\n        { header: \"Version\", key: \"version\", width: 8 },\n        { header: \"Pages\", key: \"pages\", width: 8 },\n        { header: \"Size\", key: \"size\", width: 8 },\n        { header: \"Online Link\", key: \"onlineUrl\", width: 50 },\n        { header: \"MIME Type\", key: \"mimeType\", width: 20 },\n        { header: \"Added At\", key: \"createdAt\", width: 20 },\n        { header: \"Last Updated At\", key: \"lastUpdated\", width: 20 },\n      ];\n\n      // Set column widths and properties\n      worksheet.columns = columns;\n\n      // Find the index of the onlineUrl column (1-based for ExcelJS)\n      const onlineUrlColumnIndex =\n        columns.findIndex((col) => col.key === \"onlineUrl\") + 1;\n\n      // Validate that the column was found\n      if (onlineUrlColumnIndex === 0) {\n        throw new Error(\"onlineUrl column not found in column definitions\");\n      }\n\n      // Add title rows\n      worksheet.spliceRows(\n        1,\n        0,\n        [`Data Room: ${indexData.dataroomName}`],\n        [`Index File generated: ${indexData.generatedAt.toLocaleString()}`],\n        [], // Empty row for spacing\n      );\n\n      // Style the title rows\n      const titleStyle = {\n        fill: {\n          type: \"pattern\" as const,\n          pattern: \"solid\" as const,\n          fgColor: { argb: \"4F81BD\" },\n        },\n        font: {\n          color: { argb: \"FFFFFF\" },\n          bold: true,\n          size: 14,\n        },\n        alignment: { horizontal: \"left\" as const },\n      };\n\n      worksheet.getRow(1).eachCell((cell) => {\n        cell.style = titleStyle;\n      });\n      worksheet.getRow(2).eachCell((cell) => {\n        cell.style = titleStyle;\n      });\n\n      // Merge cells for title rows\n      worksheet.mergeCells(\"A1:H1\");\n      worksheet.mergeCells(\"A2:H2\");\n\n      // Style the header row\n      const headerRow = worksheet.getRow(4);\n      headerRow.font = { size: 11 };\n      headerRow.fill = {\n        type: \"pattern\",\n        pattern: \"solid\",\n        fgColor: { argb: \"DCE6F1\" },\n      };\n      headerRow.alignment = { horizontal: \"center\" };\n      headerRow.eachCell((cell) => {\n        cell.border = {\n          top: { style: \"thin\" },\n          bottom: { style: \"thin\" },\n          left: { style: \"thin\" },\n          right: { style: \"thin\" },\n        };\n      });\n\n      // Add data rows\n      indexData.entries.forEach((entry, index) => {\n        const row = worksheet.addRow([\n          ...(showHierarchicalIndex ? [entry.hierarchicalIndex] : []),\n          entry.name,\n          entry.type,\n          entry.path,\n          entry.version,\n          entry.pages,\n          entry.size,\n          entry.onlineUrl,\n          entry.mimeType,\n          entry.createdAt?.toLocaleDateString(),\n          entry.lastUpdated.toLocaleDateString(),\n        ]);\n\n        // Style based on entry type\n        if (entry.type === \"Folder\") {\n          row.fill = {\n            type: \"pattern\",\n            pattern: \"solid\",\n            fgColor: { argb: \"F2F2F2\" },\n          };\n        }\n\n        // Add hyperlink to Online URL\n        if (entry.onlineUrl) {\n          const cell = row.getCell(onlineUrlColumnIndex); // dynamically found online link column\n          cell.value = {\n            text: entry.onlineUrl,\n            hyperlink: entry.onlineUrl,\n            tooltip: `Open ${entry.name} in browser`,\n          };\n          cell.font = {\n            color: { argb: \"0563C1\" },\n            underline: true,\n          };\n        }\n      });\n\n      // Add summary worksheet\n      const summarySheet = workbook.addWorksheet(\"Summary\");\n      summarySheet.columns = [\n        { header: \"Property\", key: \"property\", width: 20 },\n        { header: \"Value\", key: \"value\", width: 30 },\n      ];\n\n      const summaryData = [\n        [\"Dataroom Name\", indexData.dataroomName],\n        [\"Generated At\", indexData.generatedAt.toLocaleDateString()],\n        [\"Total Files\", indexData.totalFiles],\n        [\"Total Folders\", indexData.totalFolders],\n        [\"Total Size (MB)\", formatBytes(indexData.totalSize)],\n      ];\n\n      summaryData.forEach(([property, value]) => {\n        const row = summarySheet.addRow([property, value]);\n        row.getCell(1).fill = {\n          type: \"pattern\",\n          pattern: \"solid\",\n          fgColor: { argb: \"DCE6F1\" },\n        };\n        row.getCell(1).font = { bold: true };\n      });\n\n      // Generate buffer\n      const buffer = await workbook.xlsx.writeBuffer();\n\n      return {\n        data: Buffer.from(buffer),\n        filename: `${safeFilename}.xlsx`,\n        mimeType:\n          \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n      };\n    }\n\n    case \"csv\": {\n      const csvRows = [\n        [\n          ...(showHierarchicalIndex ? [\"Index\"] : []),\n          \"Name\",\n          \"Type\",\n          \"Path\",\n          \"Version\",\n          \"Pages\",\n          \"Size\",\n          \"Online Link\",\n          \"MIME Type\",\n          \"Added At\",\n          \"Last Updated At\",\n        ],\n        ...indexData.entries.map((entry) => [\n          ...(showHierarchicalIndex ? [entry.hierarchicalIndex] : []),\n          entry.name,\n          entry.type,\n          entry.path,\n          entry.version,\n          entry.pages,\n          entry.size,\n          entry.onlineUrl,\n          entry.mimeType,\n          entry.createdAt?.toISOString(),\n          entry.lastUpdated.toISOString(),\n        ]),\n      ];\n      const csvContent = csvRows.map((row) => row.join(\",\")).join(\"\\n\");\n\n      return {\n        data: Buffer.from(csvContent),\n        filename: `${safeFilename}.csv`,\n        mimeType: \"text/csv\",\n      };\n    }\n\n    case \"json\":\n    default: {\n      return {\n        data: Buffer.from(JSON.stringify(indexData, null, 2)),\n        filename: `${safeFilename}.json`,\n        mimeType: \"application/json\",\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "lib/documents/create-document.ts",
    "content": "import { DocumentStorageType } from \"@prisma/client\";\nimport z from \"zod\";\n\nexport type DocumentData = {\n  name: string;\n  key: string;\n  storageType: DocumentStorageType;\n  contentType: string | null; // actual file mime type\n  supportedFileType: string; // papermark types: \"pdf\", \"sheet\", \"docs\", \"slides\", \"map\", \"zip\"\n  fileSize: number | undefined; // file size in bytes\n  numPages?: number;\n  enableExcelAdvancedMode?: boolean;\n};\n\nexport const createDocument = async ({\n  documentData,\n  teamId,\n  numPages,\n  folderPathName,\n  createLink = false,\n  token,\n}: {\n  documentData: DocumentData;\n  teamId: string;\n  numPages?: number;\n  folderPathName?: string;\n  createLink?: boolean;\n  token?: string;\n}) => {\n  // create a document in the database with the blob url\n  const response = await fetch(\n    `${process.env.NEXT_PUBLIC_BASE_URL}/api/teams/${teamId}/documents`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n        ...(token ? { Authorization: `Bearer ${token}` } : {}),\n      },\n      body: JSON.stringify({\n        name: documentData.name,\n        url: documentData.key,\n        storageType: documentData.storageType,\n        numPages: numPages,\n        folderPathName: folderPathName,\n        type: documentData.supportedFileType,\n        contentType: documentData.contentType,\n        createLink: createLink,\n        fileSize: documentData.fileSize,\n      }),\n    },\n  );\n\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(error);\n  }\n\n  return response;\n};\n\nexport const createAgreementDocument = async ({\n  documentData,\n  teamId,\n  numPages,\n  folderPathName,\n}: {\n  documentData: DocumentData;\n  teamId: string;\n  numPages?: number;\n  folderPathName?: string;\n}) => {\n  // create a document in the database with the blob url\n  const response = await fetch(`/api/teams/${teamId}/documents/agreement`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      name: documentData.name,\n      url: documentData.key,\n      storageType: documentData.storageType,\n      numPages: numPages,\n      folderPathName: folderPathName,\n      type: documentData.supportedFileType,\n      contentType: documentData.contentType,\n      fileSize: documentData.fileSize,\n    }),\n  });\n\n  if (!response.ok) {\n    throw new Error(`HTTP error! status: ${response.status}`);\n  }\n\n  return response;\n};\n\n// create a new version in the database\nexport const createNewDocumentVersion = async ({\n  documentData,\n  documentId,\n  teamId,\n  numPages,\n  token,\n}: {\n  documentData: DocumentData;\n  documentId: string;\n  teamId: string;\n  numPages?: number;\n  token?: string;\n}) => {\n  try {\n    const documentIdParsed = z.string().cuid().parse(documentId);\n\n    // Use absolute URL when a token is provided (server-side / webhook context),\n    // otherwise use a relative URL (client-side context).\n    const baseUrl = token ? process.env.NEXT_PUBLIC_BASE_URL : \"\";\n\n    const response = await fetch(\n      `${baseUrl}/api/teams/${teamId}/documents/${documentIdParsed}/versions`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          ...(token ? { Authorization: `Bearer ${token}` } : {}),\n        },\n        body: JSON.stringify({\n          url: documentData.key,\n          storageType: documentData.storageType,\n          numPages: numPages,\n          type: documentData.supportedFileType,\n          contentType: documentData.contentType,\n          fileSize: documentData.fileSize,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      throw new Error(`HTTP error! status: ${response.status}`);\n    }\n\n    return response;\n  } catch (error) {\n    console.error(\"Error creating new document version:\", error);\n    throw new Error(\"Invalid document ID or team ID\");\n  }\n};\n"
  },
  {
    "path": "lib/documents/get-file-helper.ts",
    "content": "import { getFile } from \"@/lib/files/get-file\";\nimport prisma from \"@/lib/prisma\";\n\nexport const getFileForDocumentPage = async (\n  pageNumber: number,\n  documentId: string,\n  versionNumber?: number,\n): Promise<string> => {\n  const documentVersions = await prisma.documentVersion.findMany({\n    where: {\n      documentId: documentId,\n      ...(versionNumber\n        ? { versionNumber: versionNumber }\n        : { isPrimary: true }),\n    },\n    select: {\n      id: true,\n    },\n    orderBy: {\n      versionNumber: \"desc\",\n    },\n    take: 1,\n  });\n\n  if (documentVersions.length === 0) {\n    throw new Error(\n      `Latest document version from document id ${documentId} with document id ${documentId} not found`,\n    );\n  }\n\n  const documentVersion = documentVersions[0];\n\n  const documentPage = await prisma.documentPage.findUnique({\n    where: {\n      pageNumber_versionId: {\n        pageNumber: pageNumber,\n        versionId: documentVersion.id,\n      },\n    },\n    select: {\n      file: true,\n      storageType: true,\n    },\n  });\n\n  if (!documentPage) {\n    throw new Error(\n      `Document page ${pageNumber} with version id ${documentId} not found`,\n    );\n  }\n\n  return getFile({\n    type: documentPage.storageType,\n    data: documentPage.file,\n  });\n};\n"
  },
  {
    "path": "lib/documents/move-dataroom-documents.ts",
    "content": "import { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport const moveDataroomDocumentToFolder = async ({\n  documentIds,\n  folderId,\n  folderPathName,\n  dataroomId,\n  teamId,\n  folderIds,\n}: {\n  documentIds: string[];\n  folderId: string;\n  folderPathName: string[] | undefined;\n  dataroomId: string;\n  teamId?: string;\n  folderIds: string[];\n}) => {\n  if (!teamId) {\n    toast.error(\"Team is required to move documents\");\n    return;\n  }\n\n  console.log(\"moving documents to folder\", documentIds, folderId);\n  const key = `/api/teams/${teamId}/datarooms/${dataroomId}${folderPathName ? `/folders/documents/${folderPathName.join(\"/\")}` : \"/documents\"}`;\n  // Optimistically update the UI by removing the documents from current folder\n  mutate(\n    key,\n    (documents: any[] | undefined) => {\n      if (!documents) return documents;\n\n      // Filter out the documents that are being moved\n      const updatedDocuments = documents.filter(\n        (doc) => !documentIds.includes(doc.id),\n      );\n\n      // Return the updated list of documents\n      return updatedDocuments;\n    },\n    false,\n  );\n  const folderKey = `/api/teams/${teamId}/datarooms/${dataroomId}${folderPathName ? `/folders/${folderPathName.join(\"/\")}` : \"/folders?root=true\"}`;\n\n  // Optimistically update the UI by removing the folder from current folders\n  mutate(\n    folderKey,\n    (folder: any) => {\n      if (!folder) return folder;\n\n      // Filter out the folder that are being moved\n      const updatedFolder = folder.filter(\n        (doc: any) => !folderIds.includes(doc.id),\n      );\n      // Return the updated list of folder\n      return updatedFolder;\n    },\n    false,\n  );\n\n  try {\n    // Make the API call to move the document\n    const response = await fetch(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/documents/move`,\n      {\n        method: \"PATCH\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ documentIds, folderId }),\n      },\n    );\n\n    if (!response.ok) {\n      throw new Error(\"Failed to move document\");\n    }\n\n    const { updatedCount, newPath } = await response.json();\n\n    // Update local data using SWR's mutate\n    mutate(key);\n    if (folderIds) {\n      mutate(folderKey);\n    }\n    // update folder document counts in current path\n    mutate(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/folders${folderPathName ? `/${folderPathName.join(\"/\")}` : \"?root=true\"}`,\n    );\n    // update folder document counts in home\n    !newPath &&\n      mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/folders?root=true`);\n    // update documents in new folder (`newPath` or home)\n    mutate(\n      `/api/teams/${teamId}/datarooms/${dataroomId}${newPath ? `/folders/documents/${newPath}` : \"/documents\"}`,\n    );\n    mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/folders`);\n    toast.success(\n      `${updatedCount} document${updatedCount > 1 ? \"s\" : \"\"} moved successfully`,\n    );\n  } catch (error) {\n    toast.error(\"Failed to move documents\");\n    // Revert the UI back to the previous state\n    mutate(key);\n    if (folderIds) {\n      mutate(folderKey);\n    }\n  }\n};\n"
  },
  {
    "path": "lib/documents/move-dataroom-folders.ts",
    "content": "import { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport const moveDataroomFolderToFolder = async ({\n  folderIds,\n  selectedFolder,\n  folderPathName,\n  dataroomId,\n  teamId,\n  selectedFolderPath,\n}: {\n  folderIds: string[];\n  selectedFolder: string;\n  folderPathName: string[] | undefined;\n  teamId?: string;\n  dataroomId: string;\n  selectedFolderPath: string;\n}) => {\n  if (!teamId) {\n    toast.error(\"Team is required to move documents\");\n    return;\n  }\n  const key = `/api/teams/${teamId}/datarooms/${dataroomId}${folderPathName ? `/folders/${folderPathName.join(\"/\")}` : \"/folders?root=true\"}`;\n  // Optimistically update the UI by removing the folder from current folders\n  mutate(\n    key,\n    (folder: any) => {\n      if (!folder) return folder;\n\n      // Filter out the folder that are being moved\n      const updatedFolder = folder.filter(\n        (doc: any) => !folderIds.includes(doc.id),\n      );\n      // Return the updated list of folder\n      return updatedFolder;\n    },\n    false,\n  );\n\n  try {\n    // Make the API call to move the document\n    const response = await fetch(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/folders/move`,\n      {\n        method: \"PATCH\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({\n          selectedFolder,\n          folderIds,\n          selectedFolderPath,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      const { message } = await response.json();\n      throw new Error(message);\n      return;\n    }\n\n    const { updatedCount, newPath } = await response.json();\n\n    // Update local data using SWR's mutate\n    mutate(key);\n    mutate(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/folders${newPath ? `${newPath}` : \"?root=true\"}`,\n    );\n    mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/folders`);\n    // update documents in new folder (`newPath` or home)\n    mutate(\n      `/api/teams/${teamId}/datarooms/${dataroomId}${newPath ? `/folders/documents/${newPath}` : \"/documents\"}`,\n    );\n    toast.success(\n      `${updatedCount} folder${updatedCount > 1 ? \"s\" : \"\"} moved successfully`,\n    );\n  } catch (error) {\n    toast.error(\n      error instanceof Error ? error.message : \"Failed to move documents\",\n    );\n    // Revert the UI back to the previous state\n    mutate(key);\n  }\n};\n"
  },
  {
    "path": "lib/documents/move-documents.ts",
    "content": "import { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport const moveDocumentToFolder = async ({\n  documentIds,\n  folderId,\n  folderPathName,\n  teamId,\n  folderIds,\n}: {\n  documentIds: string[];\n  folderId: string;\n  folderPathName?: string[];\n  teamId?: string;\n  folderIds?: string[];\n}) => {\n  if (!teamId) {\n    toast.error(\"Team is required to move documents\");\n    return;\n  }\n\n  const key = `/api/teams/${teamId}${folderPathName ? `/folders/documents/${folderPathName.join(\"/\")}` : \"/documents\"}`;\n  // Optimistically update the UI by removing the documents from current folder\n  mutate(\n    key,\n    (data: any) => {\n      if (Array.isArray(data?.documents)) {\n        const updatedDocuments = data.documents.filter(\n          (doc: any) => !documentIds.includes(doc.id),\n        );\n        return { ...data, documents: updatedDocuments };\n      }\n      if (Array.isArray(data)) {\n        const updatedDocuments = data.filter(\n          (doc: any) => !documentIds.includes(doc.id),\n        );\n        return updatedDocuments;\n      }\n      return data;\n    },\n    { revalidate: false },\n  );\n  // Instant Update the UI\n  const folderKey = `/api/teams/${teamId}${folderPathName ? `/folders/${folderPathName.join(\"/\")}` : \"/folders?root=true\"}`;\n  if (folderIds) {\n    mutate(\n      folderKey,\n      (folder: any) => {\n        if (Array.isArray(folder)) {\n          interface Folder {\n            id: string;\n          }\n          const updatedFolder: Folder[] = folder.filter(\n            (f: Folder) => !folderIds.includes(f.id),\n          );\n          return updatedFolder;\n        }\n        return folder; \n      },\n      { revalidate: false },\n    );\n  }\n  try {\n    // Make the API call to move the document\n    const response = await fetch(`/api/teams/${teamId}/documents/move`, {\n      method: \"PATCH\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ documentIds, folderId }),\n    });\n\n    if (!response.ok) {\n      throw new Error(\"Failed to move document\");\n    }\n\n    const { updatedCount, newPath } = await response.json();\n\n    // Update local data using SWR's mutate\n    mutate(key);\n    if (folderIds) {\n      mutate(folderKey);\n    }\n    // update folder document counts in current path\n    mutate(\n      `/api/teams/${teamId}/folders${folderPathName ? `/${folderPathName.join(\"/\")}` : \"?root=true\"}`,\n    );\n    // update documents in new folder (or home)\n    mutate(\n      `/api/teams/${teamId}${newPath ? `/folders/documents/${newPath}` : \"/documents\"}`,\n    );\n    toast.success(\n      `${updatedCount} document${updatedCount > 1 ? \"s\" : \"\"} moved successfully`,\n    );\n  } catch (error) {\n    toast.error(\"Failed to move documents\");\n    // Revert the UI back to the previous state\n    mutate(key);\n    if (folderIds) {\n      mutate(folderKey);\n    }\n  }\n};\n"
  },
  {
    "path": "lib/documents/move-folder.ts",
    "content": "import { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nexport const moveFolderToFolder = async ({\n  folderIds,\n  folderPathName,\n  teamId,\n  selectedFolder,\n  selectedFolderPath,\n}: {\n  folderIds: string[];\n  folderPathName?: string[];\n  teamId?: string;\n  selectedFolder?: string;\n  selectedFolderPath: string;\n}) => {\n  if (!teamId) {\n    toast.error(\"Team is required to move documents\");\n    return;\n  }\n  const key = `/api/teams/${teamId}${folderPathName ? `/folders/${folderPathName.join(\"/\")}` : \"/folders?root=true\"}`;\n  mutate(\n    key,\n    (folder: any) => {\n      if (!folder) return folder;\n      // Filter out the folder that are being moved\n      interface Folder {\n        id: string;\n      }\n\n      const updatedFolder: Folder[] = folder.filter(\n        (f: Folder) => !folderIds.includes(f.id),\n      );\n      // Return the updated list of folder\n      return updatedFolder;\n    },\n    false,\n  );\n\n  try {\n    // Make the API call to move the document\n    const response = await fetch(`/api/teams/${teamId}/folders/move`, {\n      method: \"PATCH\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({\n        selectedFolder,\n        folderIds,\n        selectedFolderPath,\n      }),\n    });\n\n    if (!response.ok) {\n      const { message } = await response.json();\n      throw new Error(message);\n      return;\n    }\n\n    const { updatedCount, newPath } = await response.json();\n    mutate(key);\n    mutate(\n      `/api/teams/${teamId}/folders${newPath ? `${newPath}` : \"?root=true\"}`,\n    );\n    mutate(\n      `/api/teams/${teamId}${newPath ? `/folders/documents/${newPath}` : \"/documents\"}`,\n    );\n    toast.success(\n      `${updatedCount} folder${updatedCount > 1 ? \"s\" : \"\"} moved successfully`,\n    );\n  } catch (error) {\n    toast.error(\n      error instanceof Error ? error.message : \"Failed to move documents\",\n    );\n    // Revert the UI back to the previous state\n    mutate(key);\n  }\n};\n"
  },
  {
    "path": "lib/domains.ts",
    "content": "import {\n  DomainConfigResponse,\n  DomainResponse,\n  DomainVerificationResponse,\n} from \"@/lib/types\";\n\nexport const addDomainToVercel = async (domain: string) => {\n  return await fetch(\n    `https://api.vercel.com/v10/projects/${process.env.PROJECT_ID_VERCEL}/domains?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        name: domain.toLowerCase(),\n      }),\n    },\n  ).then((res) => res.json());\n};\n\nexport const removeDomainFromVercelProject = async (domain: string) => {\n  return await fetch(\n    `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n      },\n      method: \"DELETE\",\n    },\n  ).then((res) => res.json());\n};\n\nexport const removeDomainFromVercelTeam = async (domain: string) => {\n  return await fetch(\n    `https://api.vercel.com/v6/domains/${domain}?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n      },\n      method: \"DELETE\",\n    },\n  ).then((res) => res.json());\n};\n\nexport const removeDomainFromVercel = async (\n  domain: string,\n  domainCount: number,\n) => {\n  if (domainCount > 1) {\n    // the apex domain is being used by other domains\n    // so we should only remove it from our Vercel project\n    removeDomainFromVercelProject(domain);\n  } else {\n    // this is the only domain using this apex domain\n    // so we can remove it entirely from our Vercel team\n    removeDomainFromVercelProject(domain);\n    removeDomainFromVercelTeam(domain);\n  }\n};\n\nexport const getDomainResponse = async (\n  domain: string,\n): Promise<DomainResponse & { error: { code: string; message: string } }> => {\n  return await fetch(\n    `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain.toLowerCase()}?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"GET\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  ).then((res) => {\n    return res.json();\n  });\n};\n\nexport const getConfigResponse = async (\n  domain: string,\n): Promise<DomainConfigResponse> => {\n  return await fetch(\n    `https://api.vercel.com/v6/domains/${domain.toLowerCase()}/config?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"GET\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  ).then((res) => res.json());\n};\n\nexport const verifyDomain = async (\n  domain: string,\n): Promise<DomainVerificationResponse> => {\n  return await fetch(\n    `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain.toLowerCase()}/verify?teamId=${process.env.TEAM_ID_VERCEL}`,\n    {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,\n        \"Content-Type\": \"application/json\",\n      },\n    },\n  ).then((res) => res.json());\n};\n\nexport const getSubdomain = (name: string, apexName: string) => {\n  if (name === apexName) return null;\n  return name.slice(0, name.length - apexName.length - 1);\n};\n\nexport const getApexDomain = (url: string) => {\n  let domain;\n  try {\n    domain = new URL(url).hostname;\n  } catch (e) {\n    return \"\";\n  }\n  const parts = domain.split(\".\");\n  if (parts.length > 2) {\n    // if it's a subdomain (e.g. papermark.vercel.app), return the last 2 parts\n    return parts.slice(-2).join(\".\");\n  }\n  // if it's a normal domain (e.g. papermark.com), we return the domain\n  return domain;\n};\n\n// courtesy of ChatGPT: https://sharegpt.com/c/pUYXtRs\nexport const validDomainRegex = new RegExp(\n  /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$/,\n);\n"
  },
  {
    "path": "lib/dub.ts",
    "content": "import { Dub } from \"dub\";\n\nexport const dub = new Dub({\n  token: process.env.DUB_API_KEY,\n});\n\nexport async function getDubDiscountForExternalUserId(externalId: string) {\n  try {\n    const customers = await dub.customers.list({\n      externalId,\n      includeExpandedFields: true,\n    });\n    const first = customers[0];\n    const couponId =\n      process.env.NODE_ENV !== \"production\" && first?.discount?.couponTestId\n        ? first.discount.couponTestId\n        : first?.discount?.couponId;\n\n    return couponId ? { discounts: [{ coupon: couponId }] } : null;\n  } catch (err) {\n    console.warn(\"Skipping Dub discount due to API error\", err);\n    return null; // degrade gracefully; don't block checkout\n  }\n}\n"
  },
  {
    "path": "lib/edge-config/blacklist.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nexport const isBlacklistedEmail = async (email: string) => {\n  if (!process.env.EDGE_CONFIG) {\n    return false;\n  }\n\n  let blacklistedEmails: string[] = [];\n  try {\n    const result = await get(\"emails\");\n    // Make sure we only use string arrays\n    blacklistedEmails = Array.isArray(result)\n      ? result.filter((item): item is string => typeof item === \"string\")\n      : [];\n  } catch (e) {\n    // Already initialized as empty array\n  }\n\n  if (blacklistedEmails.length === 0) return false;\n  return new RegExp(blacklistedEmails.join(\"|\"), \"i\").test(email);\n};\n"
  },
  {
    "path": "lib/edge-config/custom-email.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nexport const getCustomEmail = async (teamId?: string) => {\n  if (!process.env.EDGE_CONFIG || !teamId) {\n    return null;\n  }\n\n  let customEmails: Record<string, string | null> = {};\n  try {\n    const result = await get(\"customEmail\");\n    // Make sure we get a valid object\n    customEmails =\n      typeof result === \"object\" && result !== null\n        ? (result as Record<string, string | null>)\n        : {};\n  } catch (e) {\n    // Error getting custom emails, return null\n    return null;\n  }\n\n  // Return the custom email for the team if it exists\n  return customEmails[teamId] || null;\n};\n"
  },
  {
    "path": "lib/edge-config/trusted-teams.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nexport const isTrustedTeam = async (teamId: string): Promise<boolean> => {\n  if (!process.env.EDGE_CONFIG) {\n    return false;\n  }\n\n  let trustedTeams: string[] = [];\n  try {\n    const result = await get(\"trustedTeams\");\n    trustedTeams = Array.isArray(result)\n      ? result.filter((item): item is string => typeof item === \"string\")\n      : [];\n  } catch (e) {\n    // Already initialized as empty array\n  }\n\n  if (trustedTeams.length === 0) return false;\n  return trustedTeams.includes(teamId);\n};\n"
  },
  {
    "path": "lib/emails/process-dataroom-digest.ts",
    "content": "import prisma from \"@/lib/prisma\";\nimport {\n  DigestBatch,\n  popDigestQueue,\n} from \"@/lib/redis/dataroom-notification-queue\";\nimport { log } from \"@/lib/utils\";\nimport { generateUnsubscribeUrl } from \"@/lib/utils/unsubscribe\";\n\nimport { sendDataroomDigestNotification } from \"./send-dataroom-digest-notification\";\n\nexport async function processDataroomDigest(frequency: \"daily\" | \"weekly\") {\n  const batches = await popDigestQueue(frequency);\n\n  if (batches.length === 0) {\n    return { processed: 0 };\n  }\n\n  let processed = 0;\n\n  for (const batch of batches) {\n    try {\n      await processBatch(batch, frequency);\n      processed++;\n    } catch (error) {\n      await log({\n        message: `Failed to process ${frequency} digest for viewer ${batch.viewerId} in dataroom ${batch.dataroomId}. Error: ${(error as Error).message}`,\n        type: \"error\",\n        mention: true,\n      });\n    }\n  }\n\n  return { processed };\n}\n\nasync function processBatch(batch: DigestBatch, frequency: \"daily\" | \"weekly\") {\n  if (batch.items.length === 0) return;\n\n  const [viewer, dataroom, senderUser] = await Promise.all([\n    prisma.viewer.findUnique({\n      where: { id: batch.viewerId, teamId: batch.teamId },\n      select: {\n        email: true,\n        views: {\n          where: {\n            dataroomId: batch.dataroomId,\n            viewType: \"DATAROOM_VIEW\",\n            verified: true,\n          },\n          orderBy: { viewedAt: \"desc\" },\n          take: 1,\n          include: {\n            link: {\n              select: {\n                id: true,\n                slug: true,\n                domainSlug: true,\n                domainId: true,\n              },\n            },\n          },\n        },\n      },\n    }),\n    prisma.dataroom.findUnique({\n      where: { id: batch.dataroomId, teamId: batch.teamId },\n      select: { name: true },\n    }),\n    batch.items[0]?.senderUserId\n      ? prisma.user.findUnique({\n          where: { id: batch.items[0].senderUserId },\n          select: { email: true },\n        })\n      : null,\n  ]);\n\n  if (!viewer?.email) return;\n\n  const uniqueDocIds = [\n    ...new Set(batch.items.map((item) => item.dataroomDocumentId)),\n  ];\n\n  const dataroomDocuments = await prisma.dataroomDocument.findMany({\n    where: { id: { in: uniqueDocIds } },\n    select: {\n      id: true,\n      document: { select: { name: true } },\n    },\n  });\n\n  const docNameMap = new Map(\n    dataroomDocuments.map((dd) => [dd.id, dd.document?.name ?? \"Untitled\"]),\n  );\n\n  const documents = uniqueDocIds.map((id) => ({\n    documentName: docNameMap.get(id) ?? \"Untitled\",\n  }));\n\n  const link = viewer.views[0]?.link;\n  let linkUrl: string | undefined;\n  if (link?.domainId && link.domainSlug && link.slug) {\n    linkUrl = `https://${link.domainSlug}/${link.slug}`;\n  } else if (link) {\n    linkUrl = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;\n  }\n\n  if (!linkUrl) return;\n\n  const preferencesUrl = generateUnsubscribeUrl({\n    viewerId: batch.viewerId,\n    dataroomId: batch.dataroomId,\n    teamId: batch.teamId,\n  });\n\n  try {\n    await sendDataroomDigestNotification({\n      dataroomName: dataroom?.name ?? \"Unknown Dataroom\",\n      documents,\n      senderEmail: senderUser?.email ?? \"noreply@papermark.com\",\n      to: viewer.email,\n      url: linkUrl,\n      preferencesUrl,\n      frequency,\n    });\n  } catch (error) {\n    throw new Error(\n      `Failed to send ${frequency} digest for dataroom \"${dataroom?.name}\" ` +\n        `(viewerId: ${batch.viewerId}, dataroomId: ${batch.dataroomId}): ` +\n        `${(error as Error).message}`,\n    );\n  }\n}\n"
  },
  {
    "path": "lib/emails/send-custom-domain-setup.ts",
    "content": "import CustomDomainSetupEmail from \"@/components/emails/custom-domain-setup\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendCustomDomainSetupEmail = async (\n  email: string,\n  name?: string,\n  currentPlan?: string,\n  hasAccess?: boolean,\n) => {\n  const emailTemplate = CustomDomainSetupEmail({ \n    name: name || \"there\", \n    currentPlan: currentPlan || \"Free\",\n    hasAccess: hasAccess || false,\n  });\n  \n  try {\n    await sendEmail({\n      to: email,\n      subject: \"Your Papermark custom domain set up\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n}; "
  },
  {
    "path": "lib/emails/send-dataroom-digest-notification.ts",
    "content": "import DataroomDigestNotification from \"@/components/emails/dataroom-digest-notification\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendDataroomDigestNotification = async ({\n  dataroomName,\n  documents,\n  senderEmail,\n  to,\n  url,\n  preferencesUrl,\n  frequency,\n}: {\n  dataroomName: string;\n  documents: { documentName: string }[];\n  senderEmail: string;\n  to: string;\n  url: string;\n  preferencesUrl: string;\n  frequency: \"daily\" | \"weekly\";\n}) => {\n  const count = documents.length;\n  const periodLabel = frequency === \"daily\" ? \"today\" : \"this week\";\n\n  try {\n    await sendEmail({\n      to,\n      subject: `${count} new document${count !== 1 ? \"s\" : \"\"} in ${dataroomName} ${periodLabel}`,\n      react: DataroomDigestNotification({\n        senderEmail,\n        dataroomName,\n        documents,\n        url,\n        preferencesUrl,\n        frequency,\n      }),\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n      unsubscribeUrl: preferencesUrl,\n    });\n  } catch (e) {\n    console.error(e);\n    throw e;\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-dataroom-info.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport DataRoomsInformationEmail from \"@/components/emails/data-rooms-information\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nconst USECASE_SUBJECTS = {\n  \"mergers-and-acquisitions\": \"Virtual Data Rooms for Mergers and Acquisitions\",\n  \"startup-fundraising\": \"Virtual Data Rooms for Startup Fundraising\",\n  \"fund-management\": \"Virtual Data Rooms for Fund Management & Fundraising\",\n  sales: \"Virtual Data Rooms for Sales\",\n  \"project-management\": \"Virtual Data Rooms for Project Management\",\n  operations: \"Virtual Data Rooms for Operations\",\n  other: \"Virtual Data Rooms\",\n};\n\nexport const sendDataroomInfoEmail = async (\n  params: CreateUserEmailProps,\n  useCase: string,\n) => {\n  const { email } = params.user;\n\n  const emailTemplate = DataRoomsInformationEmail();\n\n  const subject = USECASE_SUBJECTS[useCase as keyof typeof USECASE_SUBJECTS];\n\n  try {\n    await sendEmail({\n      to: email as string,\n      from: \"Marc Seitz <marc@papermark.com>\",\n      subject,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-dataroom-notification.ts",
    "content": "import DataroomNotification from \"@/components/emails/dataroom-notification\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendDataroomNotification = async ({\n  dataroomName,\n  documentName,\n  senderEmail,\n  to,\n  url,\n  unsubscribeUrl,\n}: {\n  dataroomName: string;\n  documentName: string | undefined;\n  senderEmail: string;\n  to: string;\n  url: string;\n  unsubscribeUrl: string;\n}) => {\n  try {\n    await sendEmail({\n      to: to,\n      subject: `New document available in ${dataroomName}`,\n      react: DataroomNotification({\n        senderEmail,\n        dataroomName,\n        documentName,\n        url,\n        unsubscribeUrl,\n      }),\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n      unsubscribeUrl,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-dataroom-trial-24h.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport DataroomTrial24hReminderEmail from \"@/components/emails/dataroom-trial-24h\";\n\nexport const sendDataroomTrial24hReminderEmail = async (params: {\n  email: string;\n  name: string;\n}) => {\n  const { email, name } = params;\n\n  const emailTemplate = DataroomTrial24hReminderEmail({ name });\n  try {\n    await sendEmail({\n      to: email,\n      from: \"Marc Seitz <marc@papermark.com>\",\n      subject: \"Your Data Room plan trial expires in 24 hours\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-dataroom-trial-end.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport DataroomTrialEnd from \"@/components/emails/dataroom-trial-end\";\n\nexport const sendDataroomTrialEndEmail = async (params: {\n  email: string;\n  name: string;\n}) => {\n  const { email, name } = params;\n\n  let emailTemplate;\n  let subject;\n\n  emailTemplate = DataroomTrialEnd({ name });\n  subject = \"Your Data Room plan trial has ended\";\n\n  try {\n    await sendEmail({\n      to: email as string,\n      from: \"Marc Seitz <marc@papermark.com>\",\n      subject,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-dataroom-trial.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport DataroomTrialWelcome from \"@/components/emails/dataroom-trial-welcome\";\n\nexport const sendDataroomTrialWelcome = async ({\n  fullName,\n  to,\n}: {\n  fullName: string;\n  to: string;\n}) => {\n  // Schedule the email to be sent 6 minutes from now\n  const sixMinuteFromNow = new Date(Date.now() + 1000 * 60 * 6).toISOString();\n\n  // get the first name from the full name\n  const name = fullName.split(\" \")[0];\n\n  try {\n    await sendEmail({\n      to: to,\n      from: \"Marc Seitz <marc@papermark.com>\",\n      subject: `For ${name}`,\n      react: DataroomTrialWelcome({ name }),\n      test: process.env.NODE_ENV === \"development\",\n      scheduledAt: sixMinuteFromNow,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-dataroom-upload-notification.ts",
    "content": "import DataroomUploadNotification from \"@/components/emails/dataroom-upload-notification\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendDataroomUploadNotification = async ({\n  ownerEmail,\n  dataroomId,\n  dataroomName,\n  uploaderEmail,\n  documentNames,\n  linkName,\n  teamMembers,\n}: {\n  ownerEmail: string;\n  dataroomId: string;\n  dataroomName: string;\n  uploaderEmail: string | null;\n  documentNames: string[];\n  linkName: string;\n  teamMembers?: string[];\n}) => {\n  const documentCount = documentNames.length;\n  const documentLabel = documentCount === 1 ? \"document\" : \"documents\";\n\n  let subjectLine = `${documentCount} new ${documentLabel} uploaded to ${dataroomName}`;\n  if (uploaderEmail) {\n    subjectLine = `${uploaderEmail} uploaded ${documentCount} ${documentLabel} to ${dataroomName}`;\n  }\n\n  const emailTemplate = DataroomUploadNotification({\n    dataroomId,\n    dataroomName,\n    uploaderEmail,\n    documentNames,\n    linkName,\n  });\n\n  try {\n    const data = await sendEmail({\n      to: ownerEmail,\n      cc: teamMembers,\n      subject: subjectLine,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n\n    return { success: true, data };\n  } catch (error) {\n    return { success: false, error };\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-deleted-domain.ts",
    "content": "import DeletedDomainEmail from \"@/components/emails/deleted-domain\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendDeletedDomainEmail = async (email: string, domain: string) => {\n  const emailTemplate = DeletedDomainEmail({ domain });\n  try {\n    await sendEmail({\n      to: email,\n      subject: `Your domain ${domain} has been deleted`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-download-ready-email.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport DownloadReady from \"@/components/emails/download-ready\";\n\nexport const sendDownloadReadyEmail = async ({\n  to,\n  dataroomName,\n  downloadUrl,\n  expiresAt,\n  isViewer,\n}: {\n  to: string;\n  dataroomName: string;\n  downloadUrl: string;\n  expiresAt?: string;\n  isViewer?: boolean;\n}) => {\n  const emailTemplate = DownloadReady({\n    dataroomName,\n    downloadUrl,\n    email: to,\n    expiresAt,\n    isViewer: isViewer ?? false,\n  });\n\n  try {\n    await sendEmail({\n      to,\n      subject: `Your ${dataroomName} download is ready`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(\"Error sending download ready email:\", e);\n    throw e;\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-email-otp-verification.ts",
    "content": "import { getCustomEmail } from \"@/lib/edge-config/custom-email\";\nimport prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\nimport { sendEmail } from \"@/lib/resend\";\n\nimport OtpEmailVerification from \"@/components/emails/otp-verification\";\n\nexport const sendOtpVerificationEmail = async (\n  email: string,\n  code: string,\n  isDataroom: boolean = false,\n  teamId: string,\n) => {\n  let logo: string | null = null;\n  let from: string | undefined;\n\n  const customEmail = await getCustomEmail(teamId);\n\n  if (customEmail && teamId) {\n    from = customEmail;\n    logo = await redis.get(`brand:logo:${teamId}`);\n  }\n\n  const emailTemplate = OtpEmailVerification({\n    email,\n    code,\n    isDataroom,\n    logo: logo ?? undefined,\n  });\n\n  try {\n    await sendEmail({\n      from,\n      to: email,\n      subject: `${code} is your verification code`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      verify: true,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-export-ready-email.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport ExportReady from \"@/components/emails/export-ready\";\n\nexport const sendExportReadyEmail = async ({\n  to,\n  resourceName,\n  downloadUrl,\n}: {\n  to: string;\n  resourceName: string;\n  downloadUrl: string;\n}) => {\n  const emailTemplate = ExportReady({ resourceName, downloadUrl, email: to });\n\n  try {\n    await sendEmail({\n      to,\n      subject: `Your ${resourceName} export is ready`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(\"Error sending export ready email:\", e);\n    throw e;\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-hundred-views-congrats.ts",
    "content": "import HundredViewsCongratsEmail from \"@/components/emails/hundred-views-congrats\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nexport const sendHundredViewsCongratsEmail = async (params: CreateUserEmailProps) => {\n  const { name, email } = params.user;\n  const emailTemplate = HundredViewsCongratsEmail({ name });\n  try {\n    await sendEmail({\n      to: email as string,\n      subject: `100 views on Papermark. Awesome, ${name}`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};"
  },
  {
    "path": "lib/emails/send-invalid-domain.ts",
    "content": "import InvalidDomainEmail from \"@/components/emails/invalid-domain\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendInvalidDomainEmail = async (\n  email: string,\n  domain: string,\n  invalidDays: number,\n) => {\n  const emailTemplate = InvalidDomainEmail({ domain, invalidDays });\n  try {\n    await sendEmail({\n      to: email,\n      subject: `Your domain ${domain} needs to be configured`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-mail-verification.ts",
    "content": "import ConfirmEmailChange from \"@/components/emails/verification-email-change\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendEmailChangeVerificationRequestEmail = async (params: {\n  email: string;\n  url: string;\n  newEmail: string;\n}) => {\n  const { url, email, newEmail } = params;\n\n  const emailTemplate = ConfirmEmailChange({\n    confirmUrl: url,\n    email,\n    newEmail,\n  });\n\n  try {\n    await sendEmail({\n      to: email,\n      system: true,\n      subject: \"Confirm your email address change for Papermark!\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-onboarding.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport Onboarding5Email from \"@/components/emails/data-rooms-information\";\nimport Onboarding1Email from \"@/components/emails/onboarding-1\";\nimport Onboarding2Email from \"@/components/emails/onboarding-2\";\nimport Onboarding3Email from \"@/components/emails/onboarding-3\";\nimport Onboarding4Email from \"@/components/emails/onboarding-4\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\ntype EmailType =\n  | \"onboarding1\"\n  | \"onboarding2\"\n  | \"onboarding3\"\n  | \"onboarding4\"\n  | \"onboarding5\";\n\nexport const sendOnboardingEmail = async (\n  params: CreateUserEmailProps,\n  emailType: EmailType,\n) => {\n  const { email } = params.user;\n\n  let emailTemplate;\n  let subject;\n\n  switch (emailType) {\n    case \"onboarding1\":\n      emailTemplate = Onboarding1Email();\n      subject = \"Day 1 with Papermark - Turn your documents into links\";\n      break;\n    case \"onboarding2\":\n      emailTemplate = Onboarding2Email();\n      subject = \"Day 2 - Set link permissions\";\n      break;\n    case \"onboarding3\":\n      emailTemplate = Onboarding3Email();\n      subject = \"Day 3 - Track analytics on each page\";\n      break;\n    case \"onboarding4\":\n      emailTemplate = Onboarding4Email();\n      subject = \"Day 4 - Custom domain and branding\";\n      break;\n    case \"onboarding5\":\n      emailTemplate = Onboarding5Email();\n      subject = \"Day 5 - Virtual Data Rooms\";\n      break;\n    default:\n      emailTemplate = Onboarding1Email();\n      subject = \"Day 1 with Papermark - Turn your documents into links\";\n      break;\n  }\n\n  try {\n    await sendEmail({\n      to: email as string,\n      subject,\n      replyTo: \"Papermark <support@papermark.com>\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-slack-integration.ts",
    "content": "import SlackIntegrationEmail from \"@/components/emails/slack-integration\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nexport const sendSlackIntegrationEmail = async (params: CreateUserEmailProps) => {\n  const { name, email } = params.user;\n  \n  // Schedule the email to be sent 1 day from now (24 hours)\n  const oneDayFromNow = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString();\n  \n  const emailTemplate = SlackIntegrationEmail({ name });\n  try {\n    await sendEmail({\n      to: email as string,\n      subject: \"See who viewed your documents in Slack \",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      scheduledAt: oneDayFromNow,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};"
  },
  {
    "path": "lib/emails/send-teammate-invite.ts",
    "content": "import TeamInvitation from \"@/components/emails/team-invitation\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendTeammateInviteEmail = async ({\n  senderName,\n  senderEmail,\n  teamName,\n  to,\n  url,\n}: {\n  senderName: string;\n  senderEmail: string;\n  teamName: string;\n  to: string;\n  url: string;\n}) => {\n  try {\n    await sendEmail({\n      to: to,\n      subject: `You are invited to join team`,\n      react: TeamInvitation({\n        senderName,\n        senderEmail,\n        teamName,\n        url,\n      }),\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-thousand-views-congrats.ts",
    "content": "import ThousandViewsCongratsEmail from \"@/components/emails/thousand-views-congrats\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nexport const sendThousandViewsCongratsEmail = async (params: CreateUserEmailProps) => {\n  const { name, email } = params.user;\n  const emailTemplate = ThousandViewsCongratsEmail({ name });\n  try {\n    await sendEmail({\n      to: email as string,\n      subject: `1000 views on Papermark. Awesome, ${name}`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};"
  },
  {
    "path": "lib/emails/send-upgrade-month-checkin.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport UpgradeOneMonthCheckinEmail from \"@/components/emails/upgrade-one-month-checkin\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nexport const sendUpgradeOneMonthCheckinEmail = async (\n  params: CreateUserEmailProps,\n) => {\n  const { name, email } = params.user;\n\n  // Get the first name from the full name\n  const firstName = name ? name.split(\" \")[0] : null;\n\n  const emailTemplate = UpgradeOneMonthCheckinEmail({\n    name: firstName,\n  });\n  try {\n    await sendEmail({\n      to: email as string,\n      subject: \"Check-in from Papermark\",\n      from: \"Marc Seitz <marc@papermark.com>\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-upgrade-personal-welcome.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport UpgradePersonalEmail from \"@/components/emails/upgrade-personal-welcome\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nconst PLAN_TYPE_MAP = {\n  pro: \"Pro\",\n  business: \"Business\",\n  datarooms: \"Data Rooms\",\n  \"datarooms-plus\": \"Data Rooms Plus\",\n  \"datarooms-premium\": \"Data Rooms Premium\",\n};\n\nexport const sendUpgradePersonalEmail = async (\n  params: CreateUserEmailProps & { planSlug?: string },\n) => {\n  const { name, email } = params.user;\n  const { planSlug = \"pro\" } = params;\n\n  // Schedule the email to be sent 6 minutes from now\n  const sixMinuteFromNow = new Date(Date.now() + 1000 * 60 * 6).toISOString();\n\n  // Get the first name from the full name\n  const firstName = name ? name.split(\" \")[0] : null;\n  const planName = PLAN_TYPE_MAP[planSlug as keyof typeof PLAN_TYPE_MAP];\n\n  const emailTemplate = UpgradePersonalEmail({ name: firstName, planName });\n  try {\n    await sendEmail({\n      to: email as string,\n      from: \"Iuliia Shnai <iuliia@papermark.com>\",\n      subject: \"Your Papermark account is ready\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      scheduledAt: sixMinuteFromNow,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-upgrade-plan.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\nimport { CreateUserEmailProps } from \"@/lib/types\";\n\nimport UpgradePlanEmail from \"@/components/emails/upgrade-plan\";\n\nconst PLAN_TYPE_MAP = {\n  pro: \"Pro\",\n  business: \"Business\",\n  datarooms: \"Data Rooms\",\n  \"datarooms-plus\": \"Data Rooms Plus\",\n  \"datarooms-premium\": \"Data Rooms Premium\",\n};\n\nexport const sendUpgradePlanEmail = async (\n  params: CreateUserEmailProps & { planType: string },\n) => {\n  const { name, email } = params.user;\n  const { planType } = params;\n  const emailTemplate = UpgradePlanEmail({ name, planType });\n\n  const planTypeText = PLAN_TYPE_MAP[planType as keyof typeof PLAN_TYPE_MAP];\n\n  try {\n    await sendEmail({\n      to: email as string,\n      subject: `Thank you for upgrading to Papermark ${planTypeText}!`,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-upgrade-six-months-checkin.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport SixMonthMilestoneEmail from \"@/components/emails/upgrade-six-month-checkin\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nexport const sendSixMonthMilestoneEmail = async (\n  params: CreateUserEmailProps & { planName?: string },\n) => {\n  const { name, email } = params.user;\n  const { planName = \"Pro\" } = params;\n\n  // Schedule the email to be sent 6.5 months from now (195 days)\n  const sixAndHalfMonthsFromNow = new Date(\n    Date.now() + 1000 * 60 * 60 * 24 * 195,\n  ).toISOString();\n\n  // Get the first name from the full name\n  const firstName = name ? name.split(\" \")[0] : null;\n\n  const emailTemplate = SixMonthMilestoneEmail({ name: firstName, planName });\n  try {\n    await sendEmail({\n      to: email as string,\n      subject: \"6 months with Papermark\",\n      from: \"Marc Seitz <marc@papermark.com>\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      scheduledAt: sixAndHalfMonthsFromNow,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-verification-request.ts",
    "content": "import { waitUntil } from \"@vercel/functions\";\nimport { customAlphabet } from \"nanoid\";\n\nimport { redis } from \"@/lib/redis\";\nimport { sendEmail } from \"@/lib/resend\";\n\nimport VerificationCodeEmail from \"@/components/emails/verification-link\";\n\n// Generate a 10-character uppercase alphanumeric verification code (like Linear's style)\nconst generateVerificationCode = customAlphabet(\n  \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\",\n  10,\n);\n\n// Redis key prefixes for login codes\nconst LOGIN_CODE_PREFIX = \"login_code:\";\nconst LOGIN_CODE_EMAIL_PREFIX = \"login_code:email:\";\n// Token expiration time in seconds (15 minutes)\nconst TOKEN_EXPIRATION_SECONDS = 15 * 60;\n\nexport interface LoginCodeData {\n  email: string;\n  code: string;\n  callbackUrl: string;\n  createdAt: number;\n}\n\nexport const sendVerificationRequestEmail = async (params: {\n  email: string;\n  url: string;\n}) => {\n  const { url, email } = params;\n\n  // Generate verification code\n  const code = generateVerificationCode();\n\n  // Store the login data in Redis with 15-minute TTL\n  const loginCodeData: LoginCodeData = {\n    email,\n    code,\n    callbackUrl: url,\n    createdAt: Date.now(),\n  };\n\n  // Store with email:code as key for lookup (must complete before redirecting)\n  await redis.set(\n    `${LOGIN_CODE_EMAIL_PREFIX}${email.toLowerCase()}:${code}`,\n    JSON.stringify(loginCodeData),\n    { ex: TOKEN_EXPIRATION_SECONDS },\n  );\n\n  const emailTemplate = VerificationCodeEmail({\n    email,\n    code,\n  });\n\n  // Use waitUntil to send email in background after response is sent\n  // This keeps the serverless function alive until the email is sent\n  waitUntil(\n    sendEmail({\n      to: email as string,\n      system: true,\n      subject: \"Login for Papermark\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n    }).catch((e) => {\n      console.error(\"Failed to send verification email:\", e);\n    }),\n  );\n};\n\n/**\n * Atomically fetch and delete login code data from Redis\n * Uses GETDEL to prevent TOCTOU race conditions where the same code could be used twice\n * Returns null if not found or expired\n */\nexport const fetchAndDeleteLoginCodeData = async (\n  email: string,\n  code: string,\n): Promise<LoginCodeData | null> => {\n  try {\n    const key = `${LOGIN_CODE_EMAIL_PREFIX}${email.toLowerCase()}:${code.toUpperCase()}`;\n\n    // Use GETDEL for atomic get-and-delete operation\n    // This prevents race conditions where two requests could use the same code\n    const data = await redis.getdel(key);\n    if (!data) return null;\n\n    // Handle both string and already-parsed object (Redis client behavior)\n    if (typeof data === \"string\") {\n      return JSON.parse(data) as LoginCodeData;\n    }\n    return data as LoginCodeData;\n  } catch (error) {\n    console.error(\"Error fetching and deleting login code data:\", error);\n    return null;\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-viewed-dataroom-paused.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport ViewedDataroomPausedEmail from \"@/components/emails/viewed-dataroom-paused\";\n\nexport const sendViewedDataroomPausedEmail = async ({\n  ownerEmail,\n  dataroomName,\n  linkName,\n  teamMembers,\n}: {\n  ownerEmail: string | null;\n  dataroomName: string;\n  linkName: string;\n  teamMembers?: string[];\n}) => {\n  const emailTemplate = ViewedDataroomPausedEmail({\n    dataroomName,\n    linkName,\n  });\n  try {\n    if (!ownerEmail) {\n      throw new Error(\"Dataroom Admin not found\");\n    }\n\n    const subjectLine = `Your dataroom has been viewed: ${dataroomName}`;\n\n    const data = await sendEmail({\n      to: ownerEmail,\n      cc: teamMembers,\n      subject: subjectLine,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n\n    return { success: true, data };\n  } catch (error) {\n    return { success: false, error };\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-viewed-dataroom.ts",
    "content": "import ViewedDataroomEmail from \"@/components/emails/viewed-dataroom\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendViewedDataroomEmail = async ({\n  ownerEmail,\n  dataroomId,\n  dataroomName,\n  viewerEmail,\n  linkName,\n  teamMembers,\n  locationString,\n}: {\n  ownerEmail: string | null;\n  dataroomId: string;\n  dataroomName: string;\n  viewerEmail: string | null;\n  linkName: string;\n  teamMembers?: string[];\n  locationString?: string;\n}) => {\n  const emailTemplate = ViewedDataroomEmail({\n    dataroomId,\n    dataroomName,\n    viewerEmail,\n    linkName,\n    locationString,\n  });\n  try {\n    if (!ownerEmail) {\n      throw new Error(\"Dataroom Admin not found\");\n    }\n\n    let subjectLine: string = `Your dataroom has been viewed: ${dataroomName}`;\n    if (viewerEmail) {\n      subjectLine = `${viewerEmail} viewed the dataroom: ${dataroomName}`;\n    }\n\n    const data = await sendEmail({\n      to: ownerEmail,\n      cc: teamMembers,\n      subject: subjectLine,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n\n    return { success: true, data };\n  } catch (error) {\n    return { success: false, error };\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-viewed-document-paused.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport ViewedDocumentPausedEmail from \"@/components/emails/viewed-document-paused\";\n\nexport const sendViewedDocumentPausedEmail = async ({\n  ownerEmail,\n  documentName,\n  linkName,\n  teamMembers,\n}: {\n  ownerEmail: string | null;\n  documentName: string;\n  linkName: string;\n  teamMembers?: string[];\n}) => {\n  const emailTemplate = ViewedDocumentPausedEmail({\n    documentName,\n    linkName,\n  });\n  try {\n    if (!ownerEmail) {\n      throw new Error(\"Document Owner not found\");\n    }\n\n    const subjectLine = `Your document has been viewed: ${documentName}`;\n\n    const data = await sendEmail({\n      to: ownerEmail,\n      cc: teamMembers,\n      subject: subjectLine,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n\n    return { success: true, data };\n  } catch (error) {\n    return { success: false, error };\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-viewed-document.ts",
    "content": "import ViewedDocumentEmail from \"@/components/emails/viewed-document\";\n\nimport { sendEmail } from \"@/lib/resend\";\n\nexport const sendViewedDocumentEmail = async ({\n  ownerEmail,\n  documentId,\n  documentName,\n  linkName,\n  viewerEmail,\n  teamMembers,\n  locationString,\n}: {\n  ownerEmail: string | null;\n  documentId: string;\n  documentName: string;\n  linkName: string;\n  viewerEmail: string | null;\n  teamMembers?: string[];\n  locationString?: string;\n}) => {\n  const emailTemplate = ViewedDocumentEmail({\n    documentId,\n    documentName,\n    linkName,\n    viewerEmail,\n    locationString,\n  });\n  try {\n    if (!ownerEmail) {\n      throw new Error(\"Document Owner not found\");\n    }\n\n    let subjectLine: string = `Your document has been viewed: ${documentName}`;\n    if (viewerEmail) {\n      subjectLine = `${viewerEmail} viewed the document: ${documentName}`;\n    }\n\n    const data = await sendEmail({\n      to: ownerEmail,\n      cc: teamMembers,\n      subject: subjectLine,\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      system: true,\n    });\n\n    return { success: true, data };\n  } catch (error) {\n    return { success: false, error };\n  }\n};\n"
  },
  {
    "path": "lib/emails/send-welcome.ts",
    "content": "import { sendEmail } from \"@/lib/resend\";\n\nimport WelcomeEmail from \"@/components/emails/welcome\";\n\nimport { CreateUserEmailProps } from \"../types\";\n\nexport const sendWelcomeEmail = async (params: CreateUserEmailProps) => {\n  const { name, email } = params.user;\n  const emailTemplate = WelcomeEmail({ name });\n  try {\n    await sendEmail({\n      to: email as string,\n      marketing: true,\n      subject: \"Welcome to Papermark!\",\n      react: emailTemplate,\n      test: process.env.NODE_ENV === \"development\",\n      unsubscribeUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/account/general`,\n    });\n  } catch (e) {\n    console.error(e);\n  }\n};\n"
  },
  {
    "path": "lib/errorHandler.ts",
    "content": "import { NextApiResponse } from \"next\";\n\nexport function errorhandler(err: unknown, res: NextApiResponse) {\n  if (err instanceof TeamError || err instanceof DocumentError) {\n    return res.status(err.statusCode).end(err.message);\n  } else {\n    return res.status(500).json({\n      message: \"Internal Server Error\",\n      error: (err as Error).message,\n    });\n  }\n}\n\nexport class TeamError extends Error {\n  statusCode = 400;\n  constructor(public message: string) {\n    super(message);\n  }\n}\n\nexport class DocumentError extends Error {\n  statusCode = 400;\n  constructor(public message: string) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "lib/featureFlags/index.ts",
    "content": "import { get } from \"@vercel/edge-config\";\n\nexport type BetaFeatures =\n  | \"tokens\"\n  | \"incomingWebhooks\"\n  | \"roomChangeNotifications\"\n  | \"webhooks\"\n  | \"conversations\"\n  | \"dataroomUpload\"\n  | \"inDocumentLinks\"\n  | \"usStorage\"\n  | \"dataroomIndex\"\n  | \"slack\"\n  | \"annotations\"\n  | \"dataroomInvitations\"\n  | \"workflows\"\n  | \"ai\"\n  | \"sso\"\n  | \"textSelection\";\n\ntype BetaFeaturesRecord = Record<BetaFeatures, string[]>;\n\nexport const getFeatureFlags = async ({ teamId }: { teamId?: string }) => {\n  const teamFeatures: Record<BetaFeatures, boolean> = {\n    tokens: false,\n    incomingWebhooks: false,\n    roomChangeNotifications: false,\n    webhooks: false,\n    conversations: false,\n    dataroomUpload: false,\n    inDocumentLinks: false,\n    usStorage: false,\n    dataroomIndex: false,\n    slack: false,\n    annotations: false,\n    dataroomInvitations: false,\n    workflows: false,\n    ai: false,\n    sso: false,\n    textSelection: false,\n  };\n\n  // Return all features as false if edge config is not available\n  if (!process.env.EDGE_CONFIG) {\n    return Object.fromEntries(\n      Object.entries(teamFeatures).map(([key, _v]) => [key, false]),\n    );\n  } else if (!teamId) {\n    return teamFeatures;\n  }\n\n  let betaFeatures: BetaFeaturesRecord | undefined = undefined;\n\n  try {\n    betaFeatures = await get(\"betaFeatures\");\n  } catch (e) {\n    console.error(`Error getting beta features: ${e}`);\n  }\n\n  if (betaFeatures) {\n    for (const [featureFlag, teamIds] of Object.entries(betaFeatures)) {\n      if (teamIds.includes(teamId)) {\n        teamFeatures[featureFlag as BetaFeatures] = true;\n      }\n    }\n  }\n\n  return teamFeatures;\n};\n"
  },
  {
    "path": "lib/files/aws-client.ts",
    "content": "import {\n  type StorageConfig,\n  getStorageConfig,\n  getTeamStorageConfigById,\n} from \"@/ee/features/storage/config\";\nimport { LambdaClient } from \"@aws-sdk/client-lambda\";\nimport { S3Client } from \"@aws-sdk/client-s3\";\n\nexport const getS3Client = (storageRegion?: string) => {\n  const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;\n\n  if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== \"s3\") {\n    throw new Error(\"Invalid upload transport\");\n  }\n\n  const config = getStorageConfig(storageRegion);\n\n  return new S3Client({\n    endpoint: config.endpoint || undefined,\n    region: config.region,\n    credentials: {\n      accessKeyId: config.accessKeyId,\n      secretAccessKey: config.secretAccessKey,\n    },\n  });\n};\n\nexport const getS3ClientForTeam = async (teamId: string) => {\n  const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;\n\n  if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== \"s3\") {\n    throw new Error(\"Invalid upload transport\");\n  }\n\n  const config = await getTeamStorageConfigById(teamId);\n\n  return new S3Client({\n    endpoint: config.endpoint || undefined,\n    region: config.region,\n    credentials: {\n      accessKeyId: config.accessKeyId,\n      secretAccessKey: config.secretAccessKey,\n    },\n  });\n};\n\nexport const getLambdaClient = (storageRegion?: string) => {\n  const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;\n\n  if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== \"s3\") {\n    throw new Error(\"Invalid upload transport\");\n  }\n\n  const config = getStorageConfig(storageRegion);\n\n  return new LambdaClient({\n    region: config.region,\n    credentials: {\n      accessKeyId: config.accessKeyId,\n      secretAccessKey: config.secretAccessKey,\n    },\n  });\n};\n\nexport const getLambdaClientForTeam = async (teamId: string) => {\n  const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;\n\n  if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== \"s3\") {\n    throw new Error(\"Invalid upload transport\");\n  }\n\n  const config = await getTeamStorageConfigById(teamId);\n\n  return new LambdaClient({\n    region: config.region,\n    credentials: {\n      accessKeyId: config.accessKeyId,\n      secretAccessKey: config.secretAccessKey,\n    },\n  });\n};\n\n/**\n * Gets both S3 client and storage config for a team in a single call.\n * This is more efficient than calling getS3ClientForTeam and getTeamStorageConfigById separately.\n *\n * @param teamId - The team ID\n * @returns Promise<{ client: S3Client, config: StorageConfig }> - Both client and config\n */\nexport const getTeamS3ClientAndConfig = async (teamId: string) => {\n  const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;\n\n  if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== \"s3\") {\n    throw new Error(\"Invalid upload transport\");\n  }\n\n  const config = await getTeamStorageConfigById(teamId);\n\n  const client = new S3Client({\n    endpoint: config.endpoint || undefined,\n    region: config.region,\n    credentials: {\n      accessKeyId: config.accessKeyId,\n      secretAccessKey: config.secretAccessKey,\n    },\n  });\n\n  return { client, config };\n};\n"
  },
  {
    "path": "lib/files/bulk-download-presign.ts",
    "content": "import { GetObjectCommand, S3Client } from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\n\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\n\nexport interface S3KeyInfo {\n  bucket: string;\n  key: string;\n  region: string;\n}\n\nconst THREE_DAYS_IN_SECONDS = 3 * 24 * 60 * 60;\n\n/**\n * Parse an S3 presigned URL to extract bucket, key, and region.\n * Supports both path-style and virtual-hosted-style URLs.\n *\n * Path-style:    https://s3.{region}.amazonaws.com/{bucket}/{key}\n * Virtual-hosted: https://{bucket}.s3.{region}.amazonaws.com/{key}\n */\nexport function parseS3PresignedUrl(presignedUrl: string): S3KeyInfo {\n  const url = new URL(presignedUrl);\n  const hostname = url.hostname;\n\n  // Path-style: s3.{region}.amazonaws.com\n  const pathStyleMatch = hostname.match(/^s3\\.([^.]+)\\.amazonaws\\.com$/);\n  if (pathStyleMatch) {\n    const region = pathStyleMatch[1];\n    // Path is /{bucket}/{key...}\n    const pathParts = url.pathname.slice(1).split(\"/\");\n    const bucket = decodeURIComponent(pathParts[0]);\n    const key = pathParts.slice(1).map(decodeURIComponent).join(\"/\");\n    return { bucket, key, region };\n  }\n\n  // Virtual-hosted-style: {bucket}.s3.{region}.amazonaws.com\n  const virtualMatch = hostname.match(/^(.+)\\.s3\\.([^.]+)\\.amazonaws\\.com$/);\n  if (virtualMatch) {\n    const bucket = virtualMatch[1];\n    const region = virtualMatch[2];\n    const key = decodeURIComponent(url.pathname.slice(1));\n    return { bucket, key, region };\n  }\n\n  // Virtual-hosted without region: {bucket}.s3.amazonaws.com (us-east-1)\n  const virtualNoRegionMatch = hostname.match(/^(.+)\\.s3\\.amazonaws\\.com$/);\n  if (virtualNoRegionMatch) {\n    const bucket = virtualNoRegionMatch[1];\n    const key = decodeURIComponent(url.pathname.slice(1));\n    return { bucket: bucket, key, region: \"us-east-1\" };\n  }\n\n  throw new Error(`Unable to parse S3 URL: ${presignedUrl}`);\n}\n\n/**\n * Generate a fresh presigned URL for an S3 object using team credentials.\n * Uses long-term IAM credentials which allow presigned URLs up to 7 days.\n */\nexport async function generateFreshPresignedUrl(\n  teamId: string,\n  s3Key: S3KeyInfo,\n  expiresInSeconds: number = THREE_DAYS_IN_SECONDS,\n): Promise<string> {\n  const config = await getTeamStorageConfigById(teamId);\n\n  const client = new S3Client({\n    region: s3Key.region,\n    credentials: {\n      accessKeyId: config.accessKeyId,\n      secretAccessKey: config.secretAccessKey,\n    },\n  });\n\n  const command = new GetObjectCommand({\n    Bucket: s3Key.bucket,\n    Key: s3Key.key,\n    ResponseContentDisposition: `attachment; filename=\"${encodeURIComponent(s3Key.key.split(\"/\").pop() || \"download.zip\")}\"`,\n    ResponseCacheControl: \"no-cache, no-store, must-revalidate\",\n  });\n\n  return getSignedUrl(client, command, { expiresIn: expiresInSeconds });\n}\n"
  },
  {
    "path": "lib/files/bulk-download.ts",
    "content": "import { GetObjectCommand } from \"@aws-sdk/client-s3\";\nimport type { S3Client } from \"@aws-sdk/client-s3\";\nimport { PassThrough } from \"stream\";\nimport { Readable } from \"stream\";\nimport { ReadableStream } from \"stream/web\";\n\nexport class S3DownloadService {\n  constructor(private s3: S3Client) {}\n\n  public createLazyDownloadStreamFrom(bucket: string, key: string): Readable {\n    let streamCreated = false;\n    const stream = new PassThrough();\n    stream.on(\"newListener\", async (event) => {\n      if (!streamCreated && event === \"data\") {\n        await this.initDownloadStream(bucket, key, stream);\n        streamCreated = true;\n      }\n    });\n    return stream;\n  }\n\n  public createLazyDownloadStreamFromUrl(url: string): Readable {\n    let streamCreated = false;\n    const stream = new PassThrough();\n    stream.on(\"newListener\", async (event) => {\n      if (!streamCreated && event === \"data\") {\n        await this.initUrlDownloadStream(url, stream);\n        streamCreated = true;\n      }\n    });\n    return stream;\n  }\n\n  private async initDownloadStream(\n    bucket: string,\n    key: string,\n    stream: PassThrough,\n  ) {\n    try {\n      const { Body: body } = await this.s3.send(\n        new GetObjectCommand({ Bucket: bucket, Key: key }),\n      );\n      if (!body) {\n        stream.emit(\n          \"error\",\n          new Error(\n            `got an undefined body from s3 when getting object ${bucket}/${key}`,\n          ),\n        );\n      } else if (!(\"on\" in body)) {\n        stream.emit(\n          \"error\",\n          new Error(\n            `got a ReadableStream<any> or Blob from s3 when getting object ${bucket}/${key} instead of Readable`,\n          ),\n        );\n      } else {\n        body.on(\"error\", (err) => stream.emit(\"error\", err)).pipe(stream);\n      }\n    } catch (e) {\n      stream.emit(\"error\", e);\n    }\n  }\n\n  private async initUrlDownloadStream(url: string, stream: PassThrough) {\n    try {\n      const response = await fetch(url);\n      if (!response.body) {\n        stream.emit(\n          \"error\",\n          new Error(`Failed to fetch the file from the URL: ${url}`),\n        );\n        return;\n      }\n\n      const nodeReadable = Readable.fromWeb(response.body as ReadableStream);\n      nodeReadable\n        .on(\"error\", (err: any) => stream.emit(\"error\", err))\n        .pipe(stream);\n    } catch (e) {\n      stream.emit(\"error\", e);\n    }\n  }\n}\n"
  },
  {
    "path": "lib/files/copy-file-server.ts",
    "content": "import { CopyObjectCommand, ListObjectsV2Command } from \"@aws-sdk/client-s3\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { copy } from \"@vercel/blob\";\nimport { match } from \"ts-pattern\";\n\nimport { newId } from \"@/lib/id-helper\";\n\nimport { getTeamS3ClientAndConfig } from \"./aws-client\";\n\nexport const copyFileServer = async ({\n  teamId,\n  filePath,\n  fileName,\n  storageType,\n}: {\n  teamId: string;\n  filePath: string;\n  fileName: string;\n  storageType: DocumentStorageType;\n}) => {\n  const { type, data } = await match(storageType)\n    .with(\"S3_PATH\", async () => copyFileInS3Server({ teamId, filePath }))\n    .with(\"VERCEL_BLOB\", async () =>\n      copyFileInVercelServer({ fileName, fileUrl: filePath }),\n    )\n    .otherwise(() => {\n      return {\n        type: null,\n        data: null,\n      };\n    });\n\n  return { type, data };\n};\n\nconst copyFileInVercelServer = async ({\n  fileName,\n  fileUrl,\n}: {\n  fileName: string;\n  fileUrl: string;\n}) => {\n  const newFileName = fileName + \"-copy\";\n\n  const blob = await copy(fileUrl, newFileName, {\n    access: \"public\",\n    addRandomSuffix: true,\n  });\n\n  return {\n    type: DocumentStorageType.VERCEL_BLOB,\n    data: { fromLocation: fileUrl, toLocation: blob.url },\n  };\n};\n\nconst copyFileInS3Server = async ({\n  teamId,\n  filePath,\n}: {\n  teamId: string;\n  filePath: string;\n}) => {\n  // Get the current document ID from the file path\n  const fromDocId = filePath.match(/doc_\\w+/)?.[0];\n  // Generate a new document ID for copied document prefix\n  const toDocId = newId(\"doc\");\n\n  if (!fromDocId) {\n    throw new Error(\"Invalid file ID\");\n  }\n\n  const fromLocation = `${teamId}/${fromDocId}/`;\n  const toLocation = `${teamId}/${toDocId}/`;\n\n  const { config } = await getTeamS3ClientAndConfig(teamId);\n\n  const response = await copyFolder(\n    {\n      fromBucket: config.bucket,\n      fromLocation: fromLocation,\n      toBucket: config.bucket,\n      toLocation: toLocation,\n    },\n    teamId,\n  );\n\n  console.log(\"response\", response);\n\n  return {\n    type: DocumentStorageType.S3_PATH,\n    data: { fromLocation, toLocation },\n  };\n};\n\n// copies all items in a folder on s3\nasync function copyFolder(\n  {\n    fromBucket,\n    fromLocation,\n    toBucket = fromBucket,\n    toLocation,\n  }: {\n    fromBucket: string;\n    fromLocation: string;\n    toBucket: string;\n    toLocation: string;\n  },\n  teamId: string,\n) {\n  const { client } = await getTeamS3ClientAndConfig(teamId);\n  let count = 0;\n  const recursiveCopy = async function (token?: string) {\n    const listCommand = new ListObjectsV2Command({\n      Bucket: fromBucket,\n      Prefix: fromLocation,\n      ContinuationToken: token,\n    });\n    let list = await client.send(listCommand); // get the list\n    if (list.KeyCount && list.Contents) {\n      // if items to copy\n      const fromObjectKeys = list.Contents.map((content) => content.Key); // get the existing object keys\n      for (let fromObjectKey of fromObjectKeys) {\n        // loop through items and copy each one\n        const toObjectKey = fromObjectKey?.replace(fromLocation, toLocation); // replace with the destination in the key\n        // copy the file\n        const copyCommand = new CopyObjectCommand({\n          Bucket: toBucket,\n          CopySource: `${fromBucket}/${fromObjectKey}`,\n          Key: toObjectKey,\n        });\n        await client.send(copyCommand);\n        count += 1;\n      }\n    }\n    if (list.NextContinuationToken) {\n      recursiveCopy(list.NextContinuationToken);\n    }\n    return `${count} files copied.`;\n  };\n  return recursiveCopy();\n}\n"
  },
  {
    "path": "lib/files/copy-file-to-bucket-server.ts",
    "content": "import { CopyObjectCommand, ListObjectsV2Command } from \"@aws-sdk/client-s3\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { match } from \"ts-pattern\";\n\nimport { getTeamS3ClientAndConfig } from \"./aws-client\";\n\nexport const copyFileToBucketServer = async ({\n  filePath,\n  storageType,\n  teamId,\n}: {\n  filePath: string;\n  storageType: DocumentStorageType;\n  teamId: string;\n}) => {\n  const { type, data } = await match(storageType)\n    .with(\"S3_PATH\", async () =>\n      copyFileToBucketInS3Server({ filePath, teamId }),\n    )\n    .otherwise(() => {\n      return {\n        type: null,\n        data: null,\n      };\n    });\n\n  return { type, data };\n};\n\nconst copyFileToBucketInS3Server = async ({\n  filePath,\n  teamId,\n}: {\n  filePath: string;\n  teamId: string;\n}) => {\n  const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n  try {\n    const copyCommand = new CopyObjectCommand({\n      CopySource: `${config.bucket}/${filePath}`,\n      Bucket: config.advancedBucket,\n      Key: filePath,\n    });\n\n    await client.send(copyCommand);\n\n    return {\n      type: DocumentStorageType.S3_PATH,\n      data: filePath,\n    };\n  } catch (error) {\n    console.error(error);\n    return {\n      type: null,\n      data: null,\n    };\n  }\n};\n"
  },
  {
    "path": "lib/files/delete-file-server.ts",
    "content": "import { DeleteObjectsCommand, ListObjectsV2Command } from \"@aws-sdk/client-s3\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { del } from \"@vercel/blob\";\nimport { match } from \"ts-pattern\";\n\nimport { getTeamS3ClientAndConfig } from \"./aws-client\";\n\nexport type DeleteFileOptions = {\n  type: DocumentStorageType;\n  data: string; // url for vercel, folderpath for s3\n  teamId: string; // needed to resolve storage region\n};\n\nexport const deleteFile = async ({ type, data, teamId }: DeleteFileOptions) => {\n  return await match(type)\n    .with(DocumentStorageType.S3_PATH, async () =>\n      deleteAllFilesFromS3Server(data, teamId),\n    )\n    .with(DocumentStorageType.VERCEL_BLOB, async () =>\n      deleteFileFromVercelServer(data),\n    )\n    .otherwise(() => {\n      return;\n    });\n};\n\nconst deleteFileFromVercelServer = async (url: string) => {\n  await del(url);\n};\n\nconst deleteAllFilesFromS3Server = async (data: string, teamId: string) => {\n  // get docId from url with starts with \"doc_\" with regex\n  const dataMatch = data.match(/^(.*doc_[^\\/]+)\\//);\n  const folderPath = dataMatch ? dataMatch[1] : data;\n\n  const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n  try {\n    // List all objects in the folder\n    const listParams = {\n      Bucket: config.bucket,\n      Prefix: `${folderPath}/`, // Ensure this ends with a slash if it's a folder\n    };\n    const listedObjects = await client.send(\n      new ListObjectsV2Command(listParams),\n    );\n\n    if (!listedObjects.Contents) return;\n    if (listedObjects.Contents.length === 0) return;\n\n    // Prepare delete parameters\n    const deleteParams = {\n      Bucket: config.bucket,\n      Delete: {\n        Objects: listedObjects.Contents.map((file) => ({ Key: file.Key })),\n      },\n    };\n\n    // Delete the objects\n    await client.send(new DeleteObjectsCommand(deleteParams));\n\n    if (listedObjects.IsTruncated) {\n      // If there are more files than returned in a single request, recurse\n      await deleteAllFilesFromS3Server(folderPath, teamId);\n    }\n  } catch (error) {\n    console.error(\"Error deleting files:\", error);\n  }\n};\n"
  },
  {
    "path": "lib/files/delete-team-files-server.ts",
    "content": "import { DeleteObjectsCommand, ListObjectsV2Command } from \"@aws-sdk/client-s3\";\nimport { del } from \"@vercel/blob\";\n\nimport { getTeamS3ClientAndConfig } from \"./aws-client\";\n\nexport type DeleteFilesOptions = {\n  teamId: string;\n  data?: string[]; // urls for vercel, not needed for s3\n};\n\nexport const deleteFiles = async ({ teamId, data }: DeleteFilesOptions) => {\n  // run both delete functions in parallel\n  await Promise.allSettled([\n    deleteAllFilesFromS3Server(teamId),\n    data && deleteFileFromVercelServer(data),\n  ]);\n};\n\nconst deleteFileFromVercelServer = async (urls: string[]) => {\n  const deleteUrlsPromises = urls.map((url) => del(url));\n  await Promise.allSettled(deleteUrlsPromises);\n};\n\nconst deleteAllFilesFromS3Server = async (teamId: string) => {\n  // the teamId is the first prefix in the folder path\n  const folderPath = teamId;\n\n  const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n  try {\n    // List all objects in the folder\n    const listParams = {\n      Bucket: config.bucket,\n      Prefix: `${folderPath}/`, // Ensure this ends with a slash if it's a folder\n    };\n    const listedObjects = await client.send(\n      new ListObjectsV2Command(listParams),\n    );\n\n    if (!listedObjects.Contents) return;\n    if (listedObjects.Contents.length === 0) return;\n\n    // Prepare delete parameters\n    const deleteParams = {\n      Bucket: config.bucket,\n      Delete: {\n        Objects: listedObjects.Contents.map((file) => ({ Key: file.Key })),\n      },\n    };\n\n    // Delete the objects\n    await client.send(new DeleteObjectsCommand(deleteParams));\n\n    if (listedObjects.IsTruncated) {\n      // If there are more files than returned in a single request, recurse\n      await deleteAllFilesFromS3Server(folderPath);\n    }\n  } catch (error) {\n    console.error(\"Error deleting files:\", error);\n  }\n};\n"
  },
  {
    "path": "lib/files/get-file.ts",
    "content": "import { DocumentStorageType } from \"@prisma/client\";\nimport { getDownloadUrl } from \"@vercel/blob\";\nimport { match } from \"ts-pattern\";\n\nexport type GetFileOptions = {\n  type: DocumentStorageType;\n  data: string;\n  isDownload?: boolean;\n};\n\nexport const getFile = async ({\n  type,\n  data,\n  isDownload = false,\n}: GetFileOptions): Promise<string> => {\n  const url = await match(type)\n    .with(DocumentStorageType.VERCEL_BLOB, () => {\n      if (isDownload) {\n        return getDownloadUrl(data);\n      } else {\n        return data;\n      }\n    })\n    .with(DocumentStorageType.S3_PATH, async () => getFileFromS3(data))\n    .exhaustive();\n\n  return url;\n};\n\nconst fetchPresignedUrl = async (\n  endpoint: string,\n  headers: Record<string, string>,\n  key: string,\n): Promise<string> => {\n  const response = await fetch(endpoint, {\n    method: \"POST\",\n    headers,\n    body: JSON.stringify({ key }),\n  });\n\n  if (!response.ok) {\n    const contentType = response.headers.get(\"content-type\");\n    let errorMessage: string;\n\n    if (contentType && contentType.includes(\"application/json\")) {\n      try {\n        const error = await response.json();\n        errorMessage =\n          error.message || `Request failed with status ${response.status}`;\n      } catch (parseError) {\n        const textError = await response.text();\n        errorMessage =\n          textError || `Request failed with status ${response.status}`;\n      }\n    } else {\n      const textError = await response.text();\n      errorMessage =\n        textError || `Request failed with status ${response.status}`;\n    }\n\n    throw new Error(errorMessage);\n  }\n\n  const { url } = (await response.json()) as { url: string };\n  return url;\n};\n\nconst getFileFromS3 = async (key: string) => {\n  const isServer =\n    typeof window === \"undefined\" && !!process.env.INTERNAL_API_KEY;\n\n  if (isServer) {\n    return fetchPresignedUrl(\n      `${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/get-presigned-get-url`,\n      {\n        \"Content-Type\": \"application/json\",\n        Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n      },\n      key,\n    );\n  } else {\n    return fetchPresignedUrl(\n      `/api/file/s3/get-presigned-get-url-proxy`,\n      {\n        \"Content-Type\": \"application/json\",\n      },\n      key,\n    );\n  }\n};\n"
  },
  {
    "path": "lib/files/put-file-server.ts",
    "content": "import { PutObjectCommand } from \"@aws-sdk/client-s3\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { put } from \"@vercel/blob\";\nimport path from \"node:path\";\nimport { match } from \"ts-pattern\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport { safeSlugify } from \"@/lib/utils\";\n\nimport { SUPPORTED_DOCUMENT_MIME_TYPES } from \"../constants\";\nimport { getTeamS3ClientAndConfig } from \"./aws-client\";\n\n// `File` is a web API type and not available server-side, so we need to define our own type\ntype File = {\n  name: string;\n  type: string;\n  buffer: Buffer;\n};\n\nexport const putFileServer = async ({\n  file,\n  teamId,\n  docId,\n  restricted = true,\n}: {\n  file: File;\n  teamId: string;\n  docId?: string;\n  restricted?: boolean;\n}) => {\n  const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;\n\n  const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)\n    .with(\"s3\", async () =>\n      putFileInS3Server({ file, teamId, docId, restricted }),\n    )\n    .with(\"vercel\", async () => putFileInVercelServer(file))\n    .otherwise(() => {\n      return {\n        type: null,\n        data: null,\n        numPages: undefined,\n      };\n    });\n\n  return { type, data };\n};\n\nconst putFileInVercelServer = async (file: File) => {\n  const contents = file.buffer;\n\n  const blob = await put(file.name, contents, {\n    access: \"public\",\n    addRandomSuffix: true,\n  });\n\n  return {\n    type: DocumentStorageType.VERCEL_BLOB,\n    data: blob.url,\n  };\n};\n\nconst putFileInS3Server = async ({\n  file,\n  teamId,\n  docId,\n  restricted = true,\n}: {\n  file: File;\n  teamId: string;\n  docId?: string;\n  restricted?: boolean;\n}) => {\n  if (!docId) {\n    docId = newId(\"doc\");\n  }\n\n  if (\n    restricted &&\n    file.type !== \"image/png\" &&\n    file.type !== \"image/jpeg\" &&\n    file.type !== \"application/pdf\"\n  ) {\n    throw new Error(\"Only PNG, JPEG, PDF or MP4 files are supported\");\n  }\n\n  if (!restricted && !SUPPORTED_DOCUMENT_MIME_TYPES.includes(file.type)) {\n    throw new Error(\"Unsupported file type\");\n  }\n\n  const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n  // Get the basename and extension for the file\n  const { name, ext } = path.parse(file.name);\n\n  const slugifiedName = safeSlugify(name) + ext;\n  const originalFileName = `${name}${ext}`;\n  const key = `${teamId}/${docId}/${slugifiedName}`;\n\n  const params = {\n    Bucket: config.bucket,\n    Key: key,\n    Body: file.buffer,\n    ContentType: file.type,\n    ContentDisposition: `attachment; filename=\"${slugifiedName}\"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`,\n  };\n\n  // Create a new instance of the PutObjectCommand with the parameters\n  const command = new PutObjectCommand(params);\n\n  // Send the command to S3\n  await client.send(command);\n\n  return {\n    type: DocumentStorageType.S3_PATH,\n    data: key,\n  };\n};\n"
  },
  {
    "path": "lib/files/put-file.ts",
    "content": "import { DocumentStorageType } from \"@prisma/client\";\nimport { upload } from \"@vercel/blob/client\";\nimport { match } from \"ts-pattern\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport { getPagesCount } from \"@/lib/utils/get-page-number-count\";\nimport type {\n  MultipartCompleteRequest,\n  MultipartGetPartUrlsRequest,\n  MultipartInitiateRequest,\n} from \"@/lib/zod/schemas/multipart\";\n\nimport { SUPPORTED_DOCUMENT_MIME_TYPES } from \"../constants\";\n\n/**\n * Uploads a file to the configured storage backend (S3 or Vercel).\n *\n * For S3 uploads:\n * - Files larger than 10MB automatically use multipart upload with pre-signed URLs\n * - Files are uploaded in 10MB chunks with parallel processing (batches of 3)\n * - Provides better performance and reliability for large files\n * - Falls back to single upload for smaller files or on multipart failure\n *\n * @param file - The file to upload\n * @param teamId - The team ID for storage configuration\n * @param docId - Optional document ID (generated if not provided)\n * @returns Upload result with storage type, key/URL, page count, and file size\n */\nexport const putFile = async ({\n  file,\n  teamId,\n  docId,\n}: {\n  file: File;\n  teamId: string;\n  docId?: string;\n}): Promise<{\n  type: DocumentStorageType | null;\n  data: string | null;\n  numPages: number | undefined;\n  fileSize: number | undefined;\n}> => {\n  const NEXT_PUBLIC_UPLOAD_TRANSPORT = process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT;\n\n  const { type, data, numPages, fileSize } = await match(\n    NEXT_PUBLIC_UPLOAD_TRANSPORT,\n  )\n    .with(\"s3\", async () => putFileInS3({ file, teamId, docId }))\n    .with(\"vercel\", async () => putFileInVercel(file))\n    .otherwise(() => {\n      return {\n        type: null,\n        data: null,\n        numPages: undefined,\n        fileSize: undefined,\n      };\n    });\n\n  return { type, data, numPages, fileSize };\n};\n\nconst putFileInVercel = async (file: File) => {\n  const newBlob = await upload(file.name, file, {\n    access: \"public\",\n    handleUploadUrl: \"/api/file/browser-upload\",\n  });\n\n  let numPages: number = 1;\n  // get page count for pdf files\n  if (file.type === \"application/pdf\") {\n    const body = await file.arrayBuffer();\n    numPages = await getPagesCount(body);\n  }\n\n  return {\n    type: DocumentStorageType.VERCEL_BLOB,\n    data: newBlob.url,\n    numPages: numPages,\n    fileSize: file.size,\n  };\n};\n\n// Multipart upload threshold: 10MB\nconst MULTIPART_THRESHOLD = 10 * 1024 * 1024;\nconst PART_SIZE = 10 * 1024 * 1024; // 10MB chunks\n\nconst putFileInS3 = async ({\n  file,\n  teamId,\n  docId,\n}: {\n  file: File;\n  teamId: string;\n  docId?: string;\n}) => {\n  if (!docId) {\n    docId = newId(\"doc\");\n  }\n\n  if (\n    !SUPPORTED_DOCUMENT_MIME_TYPES.includes(file.type) &&\n    !file.name.endsWith(\".dwg\") &&\n    !file.name.endsWith(\".dxf\") &&\n    !file.name.endsWith(\".xlsm\")\n  ) {\n    throw new Error(\n      \"Only PDF, Powerpoint, Word, and Excel, ZIP files are supported\",\n    );\n  }\n\n  // Check if file should use multipart upload\n  if (file.size > MULTIPART_THRESHOLD) {\n    return await putFileMultipart({ file, teamId, docId });\n  } else {\n    return await putFileSingle({ file, teamId, docId });\n  }\n};\n\n// Single file upload (existing logic)\nconst putFileSingle = async ({\n  file,\n  teamId,\n  docId,\n}: {\n  file: File;\n  teamId: string;\n  docId: string;\n}) => {\n  const presignedResponse = await fetch(\n    `${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/get-presigned-post-url`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        fileName: file.name,\n        contentType: file.type,\n        teamId: teamId,\n        docId: docId,\n      }),\n    },\n  );\n\n  if (!presignedResponse.ok) {\n    throw new Error(\n      `Failed to get presigned post url, failed with status code ${presignedResponse.status}`,\n    );\n  }\n\n  const { url, key, fileName, contentDisposition } =\n    (await presignedResponse.json()) as {\n      url: string;\n      key: string;\n      fileName: string;\n      contentDisposition: string;\n    };\n\n  const response = await fetch(url, {\n    method: \"PUT\",\n    headers: {\n      \"Content-Type\": file.type,\n      \"Content-Disposition\": contentDisposition,\n    },\n    body: file,\n  });\n\n  if (!response.ok) {\n    throw new Error(\n      `Failed to upload file \"${file.name}\", failed with status code ${response.status}`,\n    );\n  }\n\n  let numPages: number = 1;\n  // get page count for pdf files\n  if (file.type === \"application/pdf\") {\n    const body = await file.arrayBuffer();\n    numPages = await getPagesCount(body);\n  }\n\n  return {\n    type: DocumentStorageType.S3_PATH,\n    data: key,\n    numPages: numPages,\n    fileSize: file.size,\n  };\n};\n\n// Multipart file upload for large files\nconst putFileMultipart = async ({\n  file,\n  teamId,\n  docId,\n}: {\n  file: File;\n  teamId: string;\n  docId: string;\n}) => {\n  try {\n    // Step 1: Initiate multipart upload\n    const initiateResponse = await fetch(\n      `${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          action: \"initiate\",\n          fileName: file.name,\n          contentType: file.type,\n          teamId: teamId,\n          docId: docId,\n        } satisfies MultipartInitiateRequest),\n      },\n    );\n\n    if (!initiateResponse.ok) {\n      throw new Error(\n        `Failed to initiate multipart upload, status: ${initiateResponse.status}`,\n      );\n    }\n\n    const { uploadId, key, fileName } = await initiateResponse.json();\n\n    // Step 2: Get pre-signed URLs for parts\n    const partUrlsResponse = await fetch(\n      `${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          action: \"get-part-urls\",\n          fileName: file.name,\n          contentType: file.type,\n          teamId: teamId,\n          docId: docId,\n          uploadId: uploadId,\n          fileSize: file.size,\n          partSize: PART_SIZE,\n        } satisfies MultipartGetPartUrlsRequest),\n      },\n    );\n\n    if (!partUrlsResponse.ok) {\n      throw new Error(\n        `Failed to get part URLs, status: ${partUrlsResponse.status}`,\n      );\n    }\n\n    const { urls } = await partUrlsResponse.json();\n\n    // Step 3: Upload parts in parallel (batches of 3)\n    const uploadPart = async ({\n      partNumber,\n      url,\n    }: {\n      partNumber: number;\n      url: string;\n    }) => {\n      const start = (partNumber - 1) * PART_SIZE;\n      const end = Math.min(start + PART_SIZE, file.size);\n      const chunk = file.slice(start, end);\n\n      const response = await fetch(url, {\n        method: \"PUT\",\n        body: chunk,\n      });\n\n      if (!response.ok) {\n        throw new Error(\n          `Failed to upload part ${partNumber}, status: ${response.status}`,\n        );\n      }\n\n      const etag = response.headers.get(\"ETag\");\n      if (!etag) {\n        throw new Error(`Missing ETag in response for part ${partNumber}`);\n      }\n\n      return { PartNumber: partNumber, ETag: etag };\n    };\n\n    // Upload parts in batches to avoid overwhelming the connection\n    const batchSize = 5;\n    const parts: Array<{ PartNumber: number; ETag: string }> = [];\n\n    for (let i = 0; i < urls.length; i += batchSize) {\n      const batch = urls.slice(i, i + batchSize);\n      const batchResults = await Promise.all(batch.map(uploadPart));\n      parts.push(...batchResults);\n    }\n\n    // Step 4: Complete multipart upload\n    const completeResponse = await fetch(\n      `${process.env.NEXT_PUBLIC_BASE_URL}/api/file/s3/multipart`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          action: \"complete\",\n          fileName: file.name,\n          contentType: file.type,\n          teamId: teamId,\n          docId: docId,\n          uploadId: uploadId,\n          parts: parts,\n        } satisfies MultipartCompleteRequest),\n      },\n    );\n\n    if (!completeResponse.ok) {\n      throw new Error(\n        `Failed to complete multipart upload, status: ${completeResponse.status}`,\n      );\n    }\n\n    let numPages: number = 1;\n    // get page count for pdf files\n    if (file.type === \"application/pdf\") {\n      const body = await file.arrayBuffer();\n      numPages = await getPagesCount(body);\n    }\n\n    return {\n      type: DocumentStorageType.S3_PATH,\n      data: key,\n      numPages: numPages,\n      fileSize: file.size,\n    };\n  } catch (error) {\n    console.error(\"Multipart upload failed:\", error);\n    // Fallback to single upload on error\n    console.log(\"Falling back to single upload...\");\n    return await putFileSingle({ file, teamId, docId });\n  }\n};\n"
  },
  {
    "path": "lib/files/stream-file-server.ts",
    "content": "import { Upload } from \"@aws-sdk/lib-storage\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport path from \"node:path\";\nimport { Readable } from \"stream\";\n\nimport { safeSlugify } from \"@/lib/utils\";\n\nimport { getTeamS3ClientAndConfig } from \"./aws-client\";\n\ntype StreamFile = {\n  name: string;\n  type: string;\n  stream: Readable;\n};\n\nexport const streamFileServer = async ({\n  file,\n  teamId,\n  docId,\n}: {\n  file: StreamFile;\n  teamId: string;\n  docId: string;\n}) => {\n  const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n  // Get the basename and extension for the file\n  const { name, ext } = path.parse(file.name);\n\n  const slugifiedName = safeSlugify(name) + ext;\n  const originalFileName = `${name}${ext}`;\n  const key = `${teamId}/${docId}/${slugifiedName}`;\n\n  const params = {\n    client,\n    params: {\n      Bucket: config.bucket,\n      Key: key,\n      Body: file.stream,\n      ContentType: file.type,\n      ContentDisposition: `attachment; filename=\"${slugifiedName}\"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`,\n    },\n  };\n\n  // Use Upload for multipart upload support\n  const upload = new Upload(params);\n\n  // Send the upload to S3\n  await upload.done();\n\n  return {\n    type: DocumentStorageType.S3_PATH,\n    data: key,\n  };\n};\n"
  },
  {
    "path": "lib/files/tus-redis-locker.ts",
    "content": "import { ERRORS, Lock, Locker, RequestRelease } from \"@tus/utils\";\nimport { Redis } from \"@upstash/redis\";\n\n/**\n * RedisLocker is an implementation of the Locker interface that manages locks in key-value store using Redis.\n * This class is designed for exclusive access control over resources, often used in scenarios like upload management.\n *\n * Key Features:\n * - Ensures exclusive resource access by using a KV-based map to track locks.\n * - Implements timeout for lock acquisition, mitigating deadlock situations.\n * - Facilitates both immediate and graceful release of locks through different mechanisms.\n *\n * Locking Behavior:\n * - When the `lock` method is invoked for an already locked resource, the `cancelReq` callback is called.\n *   This signals to the current lock holder that another process is requesting the lock, encouraging them to release it as soon as possible.\n * - The lock attempt continues until the specified timeout is reached. If the timeout expires and the lock is still not\n *   available, an error is thrown to indicate lock acquisition failure.\n *\n * Lock Acquisition and Release:\n * - The `lock` method implements a wait mechanism, allowing a lock request to either succeed when the lock becomes available,\n *   or fail after the timeout period.\n * - The `unlock` method releases a lock, making the resource available for other requests.\n */\n\ninterface RedisLockerOptions {\n  acquireLockTimeout?: number;\n  redisClient: Redis;\n}\n\nexport class RedisLocker implements Locker {\n  timeout: number;\n  redisClient: Redis;\n\n  constructor(options: RedisLockerOptions) {\n    this.timeout = options.acquireLockTimeout ?? 1000 * 30; // default: 30 seconds\n    this.redisClient = options.redisClient;\n  }\n\n  newLock(id: string) {\n    return new RedisLock(id, this, this.timeout);\n  }\n}\n\nclass RedisLock implements Lock {\n  constructor(\n    private id: string,\n    private locker: RedisLocker,\n    private timeout: number = 1000 * 30, // default: 30 seconds\n  ) {}\n\n  async lock(\n    signal: AbortSignal,\n    requestRelease: RequestRelease,\n  ): Promise<void> {\n    const abortController = new AbortController();\n    const lock = await Promise.race([\n      this.waitTimeout(signal),\n      this.acquireLock(this.id, requestRelease, signal),\n    ]);\n\n    abortController.abort();\n\n    if (!lock) {\n      throw ERRORS.ERR_LOCK_TIMEOUT;\n    }\n  }\n\n  protected async acquireLock(\n    id: string,\n    requestRelease: RequestRelease,\n    signal: AbortSignal,\n  ): Promise<boolean> {\n    if (signal.aborted) {\n      return false;\n    }\n\n    const lockKey = `tus-lock-${id}`;\n    const lock = await this.locker.redisClient.set(lockKey, \"locked\", {\n      nx: true,\n      px: this.timeout,\n    });\n\n    if (lock) {\n      // Register a release request flag in Redis\n      await this.locker.redisClient.set(`requestRelease:${lockKey}`, \"true\", {\n        px: this.timeout,\n      });\n      return true;\n    }\n\n    // Check if the release was requested\n    const releaseRequestStr: string | null = await this.locker.redisClient.get(\n      `requestRelease:${lockKey}`,\n    );\n    if (releaseRequestStr === \"true\") {\n      await requestRelease?.();\n    }\n\n    await new Promise((resolve, reject) => {\n      setImmediate(() => {\n        this.acquireLock(id, requestRelease, signal)\n          .then(resolve)\n          .catch(reject);\n      });\n    });\n\n    return false;\n  }\n\n  async unlock(): Promise<void> {\n    const lockKey = `tus-lock-${this.id}`;\n    const lockExists = await this.locker.redisClient.del(lockKey);\n    if (!lockExists) {\n      throw new Error(\"Releasing an unlocked lock!\");\n    }\n\n    // Clean up the request release entry\n    await this.locker.redisClient.del(`requestRelease:${lockKey}`);\n  }\n\n  protected waitTimeout(signal: AbortSignal) {\n    return new Promise<boolean>((resolve) => {\n      const timeout = setTimeout(() => {\n        resolve(false);\n      }, this.timeout);\n\n      const abortListener = () => {\n        clearTimeout(timeout);\n        signal.removeEventListener(\"abort\", abortListener);\n        resolve(false);\n      };\n      signal.addEventListener(\"abort\", abortListener);\n    });\n  }\n}\n"
  },
  {
    "path": "lib/files/tus-upload.ts",
    "content": "import * as tus from \"tus-js-client\";\n\nimport { decodeBase64Url } from \"../utils/decode-base64url\";\n\ntype ResumableUploadParams = {\n  file: File;\n  onProgress?: (bytesUploaded: number, bytesTotal: number) => void;\n  onError?: (error: Error | tus.DetailedError) => void;\n  ownerId: string;\n  teamId: string;\n  numPages: number;\n  relativePath: string;\n};\n\ntype UploadResult = {\n  id: string;\n  url: string;\n  relativePath: string;\n  fileName: string;\n  fileType: string;\n  numPages: number;\n  ownerId: string;\n  teamId: string;\n};\n\nexport function resumableUpload({\n  file,\n  onProgress,\n  onError,\n  ownerId,\n  teamId,\n  numPages,\n  relativePath,\n}: ResumableUploadParams) {\n  return new Promise<{ upload: tus.Upload; complete: Promise<UploadResult> }>(\n    (resolve, reject) => {\n      let completeResolve: (\n        value: UploadResult | PromiseLike<UploadResult>,\n      ) => void;\n      const complete = new Promise<UploadResult>((res) => {\n        completeResolve = res;\n      });\n\n      const upload = new tus.Upload(file, {\n        endpoint: `${process.env.NEXT_PUBLIC_BASE_URL}/api/file/tus`,\n        retryDelays: [0, 3000, 5000, 10000],\n        uploadDataDuringCreation: true,\n        removeFingerprintOnSuccess: true,\n        metadata: {\n          fileName: file.name,\n          contentType: file.type,\n          numPages: String(numPages),\n          teamId: teamId,\n          ownerId: ownerId,\n          relativePath: relativePath,\n        },\n        chunkSize: 4 * 1024 * 1024,\n        onError: (error) => {\n          onError && onError(error);\n          console.error(\"Failed because: \" + error);\n          reject(error);\n        },\n        onShouldRetry(error, retryAttempt, options) {\n          console.error(`Should retry upload. Attempt ${retryAttempt}`);\n          var status = error.originalResponse\n            ? error.originalResponse.getStatus()\n            : 0;\n          // Do not retry if the status is a 500.\n          if (status === 500 || status === 403) {\n            return false;\n          }\n          // For any other status code, we retry.\n          return true;\n        },\n        onProgress,\n        onSuccess: () => {\n          console.log(\"Uploaded successfully\");\n          const id = upload.url!.split(\"/api/file/tus/\")[1];\n          // if id contains a slash, then we use it as it otherwise we need to convert from buffer base64URL to utf-8\n          const newId = id.includes(\"/\") ? id : decodeBase64Url(id);\n          completeResolve({\n            id: newId,\n            url: upload.url!,\n            relativePath,\n            fileName: file.name,\n            fileType: file.type,\n            numPages,\n            ownerId,\n            teamId,\n          });\n        },\n      });\n\n      // Check if there are any previous uploads to continue.\n      upload\n        .findPreviousUploads()\n        .then((previousUploads) => {\n          // Found previous uploads so we select the first one.\n          if (previousUploads.length) {\n            upload.resumeFromPreviousUpload(previousUploads[0]);\n          }\n\n          upload.start();\n          resolve({ upload, complete });\n        })\n        .catch((error) => {\n          console.error(\"Error finding previous uploads:\", error);\n          upload.start();\n          resolve({ upload, complete });\n        });\n    },\n  );\n}\n"
  },
  {
    "path": "lib/files/viewer-tus-upload.ts",
    "content": "import * as tus from \"tus-js-client\";\n\nimport { decodeBase64Url } from \"../utils/decode-base64url\";\n\ntype ViewerUploadParams = {\n  file: File;\n  onProgress?: (bytesUploaded: number, bytesTotal: number) => void;\n  onError?: (error: Error | tus.DetailedError) => void;\n  viewerData: {\n    id: string;\n    linkId: string;\n    dataroomId?: string;\n  };\n  teamId: string;\n  numPages: number;\n};\n\ntype UploadResult = {\n  id: string;\n  url: string;\n  fileName: string;\n  fileType: string;\n  numPages: number;\n  viewerId: string;\n  linkId: string;\n  dataroomId?: string;\n  teamId: string;\n};\n\nexport function viewerUpload({\n  file,\n  onProgress,\n  onError,\n  viewerData,\n  teamId,\n  numPages,\n}: ViewerUploadParams) {\n  return new Promise<{ upload: tus.Upload; complete: Promise<UploadResult> }>(\n    (resolve, reject) => {\n      let completeResolve: (\n        value: UploadResult | PromiseLike<UploadResult>,\n      ) => void;\n      const complete = new Promise<UploadResult>((res) => {\n        completeResolve = res;\n      });\n\n      const upload = new tus.Upload(file, {\n        endpoint: `${process.env.NEXT_PUBLIC_BASE_URL}/api/file/tus-viewer`,\n        retryDelays: [0, 3000, 5000, 10000],\n        uploadDataDuringCreation: true,\n        removeFingerprintOnSuccess: true,\n        metadata: {\n          fileName: file.name,\n          contentType: file.type,\n          numPages: String(numPages),\n          teamId: teamId,\n          viewerId: viewerData.id,\n          linkId: viewerData.linkId,\n          dataroomId: viewerData.dataroomId || \"\",\n        },\n        chunkSize: 4 * 1024 * 1024,\n        onError: (error) => {\n          onError && onError(error);\n          console.error(\"Failed because: \" + error);\n          reject(error);\n        },\n        onShouldRetry(error, retryAttempt, options) {\n          console.error(`Should retry upload. Attempt ${retryAttempt}`);\n          var status = error.originalResponse\n            ? error.originalResponse.getStatus()\n            : 0;\n          // Do not retry if the status is a 500 or 403.\n          if (status === 500 || status === 403) {\n            return false;\n          }\n          // For any other status code, we retry.\n          return true;\n        },\n        onProgress,\n        onSuccess: () => {\n          console.log(\"Uploaded successfully\");\n          const id = upload.url!.split(\"/api/file/tus-viewer/\")[1];\n          // if id contains a slash, then we use it as it otherwise we need to convert from buffer base64URL to utf-8\n          const newId = id.includes(\"/\") ? id : decodeBase64Url(id);\n          completeResolve({\n            id: newId,\n            url: upload.url!,\n            fileName: file.name,\n            fileType: file.type,\n            numPages,\n            viewerId: viewerData.id,\n            linkId: viewerData.linkId,\n            dataroomId: viewerData.dataroomId,\n            teamId,\n          });\n        },\n      });\n\n      // Check if there are any previous uploads to continue.\n      upload\n        .findPreviousUploads()\n        .then((previousUploads) => {\n          // Found previous uploads so we select the first one.\n          if (previousUploads.length) {\n            upload.resumeFromPreviousUpload(previousUploads[0]);\n          }\n\n          upload.start();\n          resolve({ upload, complete });\n        })\n        .catch((error) => {\n          console.error(\"Error finding previous uploads:\", error);\n          upload.start();\n          resolve({ upload, complete });\n        });\n    },\n  );\n}\n"
  },
  {
    "path": "lib/folders/create-folder.ts",
    "content": "import { mutate } from \"swr\";\n\nimport { SYSTEM_FILES } from \"../constants\";\n\nexport function isSystemFile(name: string): boolean {\n  return (\n    SYSTEM_FILES.includes(name.toLowerCase()) ||\n    name.toLowerCase().startsWith(\".\")\n  );\n}\n\ninterface CreateFolderResponse {\n  path: string;\n  parentFolderPath?: string;\n  name: string;\n}\n\nexport async function createFolderInDataroom({\n  teamId,\n  dataroomId,\n  name,\n  path,\n}: {\n  teamId: string;\n  dataroomId: string;\n  name: string;\n  path?: string;\n}): Promise<CreateFolderResponse> {\n  const response = await fetch(\n    `/api/teams/${teamId}/datarooms/${dataroomId}/folders`,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        name,\n        path,\n      }),\n    },\n  );\n\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(error.message || \"Failed to create folder in dataroom\");\n  }\n\n  return response.json();\n}\n\nexport async function createFolderInMainDocs({\n  teamId,\n  name,\n  path,\n}: {\n  teamId: string;\n  name: string;\n  path?: string;\n}): Promise<CreateFolderResponse> {\n  const response = await fetch(`/api/teams/${teamId}/folders`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n    },\n    body: JSON.stringify({\n      name,\n      path,\n    }),\n  });\n\n  if (!response.ok) {\n    const error = await response.json();\n    throw new Error(\n      error.message || \"Failed to create folder in all documents\",\n    );\n  }\n\n  return response.json();\n}\n\nexport function determineFolderPaths({\n  currentDataroomPath,\n  currentMainDocsPath,\n  isFirstLevelFolder,\n}: {\n  currentDataroomPath?: string;\n  currentMainDocsPath?: string;\n  isFirstLevelFolder: boolean;\n}): {\n  parentDataroomPath?: string;\n  parentMainDocsPath?: string;\n} {\n  return {\n    parentDataroomPath: currentDataroomPath,\n    parentMainDocsPath: isFirstLevelFolder ? undefined : currentMainDocsPath,\n  };\n}\n\nexport async function createFolderInBoth({\n  teamId,\n  dataroomId,\n  name,\n  parentMainDocsPath,\n  parentDataroomPath,\n  setRejectedFiles,\n  analytics,\n  replicateDataroomFolders = true,\n}: {\n  teamId: string;\n  dataroomId: string;\n  name: string;\n  parentMainDocsPath?: string;\n  parentDataroomPath?: string;\n  setRejectedFiles: (files: { fileName: string; message: string }[]) => void;\n  analytics: any;\n  replicateDataroomFolders?: boolean;\n}): Promise<{ dataroomPath: string; mainDocsPath: string | undefined }> {\n  try {\n    // Always create folder in dataroom\n    const dataroomResponse = await createFolderInDataroom({\n      teamId,\n      dataroomId,\n      name,\n      path: parentDataroomPath,\n    });\n\n    // Conditionally create folder in main docs based on user preference\n    let mainDocsResponse: CreateFolderResponse | undefined;\n    if (replicateDataroomFolders) {\n      mainDocsResponse = await createFolderInMainDocs({\n        teamId,\n        name,\n        path: parentMainDocsPath,\n      });\n    } else {\n      // If not replicating, return undefined to explicitly signal no replication\n      mainDocsResponse = undefined;\n    }\n\n    // Track analytics\n    analytics.capture(\n      replicateDataroomFolders\n        ? \"Folder Added in dataroom and in main documents\"\n        : \"Folder Added in dataroom only\",\n      {\n        folderName: name,\n        dataroomTargetParent: parentDataroomPath,\n        mainDocsTargetParent: parentMainDocsPath,\n        replicateDataroomFolders,\n      },\n    );\n\n    // Mutate dataroom folders\n    mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/folders?root=true`);\n    mutate(`/api/teams/${teamId}/datarooms/${dataroomId}/folders`);\n    mutate(\n      `/api/teams/${teamId}/datarooms/${dataroomId}/folders/${dataroomResponse.path}`,\n    );\n\n    // Only mutate main docs folders if we created them\n    if (replicateDataroomFolders && mainDocsResponse) {\n      mutate(`/api/teams/${teamId}/folders?root=true`);\n      mutate(`/api/teams/${teamId}/documents`);\n    }\n\n    return {\n      dataroomPath: dataroomResponse.path,\n      mainDocsPath: mainDocsResponse?.path,\n    };\n  } catch (error) {\n    console.error(\n      \"An error occurred while creating the folder in both locations: \",\n      error,\n    );\n    setRejectedFiles([\n      {\n        fileName: name,\n        message:\n          error instanceof Error ? error.message : \"Failed to create folder\",\n      },\n    ]);\n    throw error;\n  }\n}\n"
  },
  {
    "path": "lib/hanko.ts",
    "content": "import { tenant } from \"@teamhanko/passkeys-next-auth-provider\";\n\nif (!process.env.HANKO_API_KEY || !process.env.NEXT_PUBLIC_HANKO_TENANT_ID) {\n  // These need to be set in .env.local\n  // You get them from the Passkey API itself, e.g. when first setting up the server.\n  throw new Error(\n    \"Please set HANKO_API_KEY and NEXT_PUBLIC_HANKO_TENANT_ID in your .env.local file.\",\n  );\n}\n\nconst hanko = tenant({\n  apiKey: process.env.HANKO_API_KEY!,\n  tenantId: process.env.NEXT_PUBLIC_HANKO_TENANT_ID!,\n});\n\nexport default hanko;\n"
  },
  {
    "path": "lib/hooks/use-breakpoint.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nexport function useBreakpoint(breakpoint: number) {\n  const [isSmaller, setIsSmaller] = useState<boolean | undefined>(undefined);\n\n  useEffect(() => {\n    // Use matchMedia for better performance\n    const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);\n\n    const handleChange = () => {\n      const newIsSmaller = window.innerWidth <= breakpoint - 1;\n      setIsSmaller((prevIsSmaller) => {\n        // Only update state if the value actually changed\n        if (prevIsSmaller !== newIsSmaller) {\n          return newIsSmaller;\n        }\n        return prevIsSmaller;\n      });\n    };\n\n    // Set initial value\n    handleChange();\n\n    // Listen for changes using matchMedia (more efficient than resize)\n    mediaQuery.addEventListener(\"change\", handleChange);\n\n    // Fallback resize listener with debouncing for edge cases\n    let timeoutId: number;\n    const debouncedResize = () => {\n      clearTimeout(timeoutId);\n      timeoutId = window.setTimeout(handleChange, 100); // 100ms debounce\n    };\n\n    window.addEventListener(\"resize\", debouncedResize);\n\n    return () => {\n      mediaQuery.removeEventListener(\"change\", handleChange);\n      window.removeEventListener(\"resize\", debouncedResize);\n      clearTimeout(timeoutId);\n    };\n  }, [breakpoint]);\n\n  return !!isSmaller; // Convert undefined to false for initial render\n}\n"
  },
  {
    "path": "lib/hooks/use-copy-to-clipboard.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport const useCopyToClipboard = (\n  timeout: number = 3000,\n): [\n  boolean,\n  (\n    value: string | ClipboardItem,\n    options?: { onSuccess?: () => void; throwOnError?: boolean },\n  ) => Promise<void>,\n] => {\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const [copied, setCopied] = useState(false);\n\n  const clearTimer = () => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n  };\n\n  const copyToClipboard = useCallback(\n    async (\n      value: string | ClipboardItem,\n      {\n        onSuccess,\n        throwOnError,\n      }: { onSuccess?: () => void; throwOnError?: boolean } = {},\n    ) => {\n      clearTimer();\n      try {\n        if (typeof value === \"string\") {\n          await navigator.clipboard.writeText(value);\n        } else if (value instanceof ClipboardItem) {\n          await navigator.clipboard.write([value]);\n        }\n        setCopied(true);\n        onSuccess?.();\n\n        // Ensure timeout is a non-negative finite number\n        if (Number.isFinite(timeout) && timeout >= 0) {\n          timer.current = setTimeout(() => setCopied(false), timeout);\n        }\n      } catch (error) {\n        console.error(\"Failed to copy: \", error);\n        if (throwOnError) throw error;\n      }\n    },\n    [timeout],\n  );\n\n  // Cleanup the timer when the component unmounts\n  useEffect(() => {\n    return () => clearTimer();\n  }, []);\n\n  return [copied, copyToClipboard];\n};\n"
  },
  {
    "path": "lib/hooks/use-dataroom-permissions.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\n\nexport const useDataroomPermissions = () => {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const applyPermissions = async (\n    dataroomId: string,\n    documentIds: string[],\n    strategy: \"INHERIT_FROM_PARENT\" | \"ASK_EVERY_TIME\" | \"HIDDEN_BY_DEFAULT\",\n    folderPath?: string,\n    onError?: (message: string) => void,\n  ): Promise<{ success: boolean; error?: string }> => {\n    if (!teamId) {\n      return { success: false, error: \"Team ID not available\" };\n    }\n\n    if (!documentIds || documentIds.length === 0) {\n      return { success: false, error: \"No document IDs provided\" };\n    }\n\n    try {\n      const response = await fetch(\n        `/api/teams/${encodeURIComponent(teamId)}/datarooms/${encodeURIComponent(dataroomId)}/apply-permissions`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            documentIds,\n            strategy,\n            folderPath,\n          }),\n        },\n      );\n\n      if (!response.ok) {\n        const errorData = await response.json().catch(() => ({}));\n        onError?.(errorData.message || `HTTP ${response.status}`);\n        return {\n          success: false,\n          error: errorData.message || `HTTP ${response.status}`,\n        };\n      }\n\n      return { success: true };\n    } catch (error) {\n      console.error(\"Failed to apply permissions:\", error);\n      const errorMessage =\n        error instanceof Error ? error.message : \"Unknown error\";\n      onError?.(errorMessage);\n      return {\n        success: false,\n        error: errorMessage,\n      };\n    }\n  };\n\n  return {\n    applyPermissions,\n  };\n};\n"
  },
  {
    "path": "lib/hooks/use-disable-print.ts",
    "content": "import { useEffect } from \"react\";\n\ninterface UseDisablePrintOptions {\n    className?: string;\n    styleId?: string;\n}\n\nexport function useDisablePrint({\n    className = \"printing-disabled\",\n    styleId = \"printing-disabled-style\",\n}: UseDisablePrintOptions = {}) {\n    useEffect(() => {\n        const style = document.createElement(\"style\");\n        style.id = styleId;\n        style.textContent = `\n      @media print {\n        body.${className} * {\n          display: none !important;\n        }\n      }\n    `;\n        document.head.appendChild(style);\n\n        const handleBeforePrint = () => {\n            document.body.classList.add(className);\n        };\n\n        const handleAfterPrint = () => {\n            document.body.classList.remove(className);\n        };\n\n        window.addEventListener(\"beforeprint\", handleBeforePrint);\n        window.addEventListener(\"afterprint\", handleAfterPrint);\n\n        const mediaQueryList = window.matchMedia?.(\"print\");\n        const mediaQueryHandler = (e: MediaQueryListEvent) => {\n            if (e.matches) {\n                handleBeforePrint();\n            } else {\n                handleAfterPrint();\n            }\n        };\n\n        mediaQueryList?.addEventListener?.(\"change\", mediaQueryHandler);\n\n        return () => {\n            document.getElementById(styleId)?.remove();\n            window.removeEventListener(\"beforeprint\", handleBeforePrint);\n            window.removeEventListener(\"afterprint\", handleAfterPrint);\n            mediaQueryList?.removeEventListener?.(\"change\", mediaQueryHandler);\n        };\n    }, [className, styleId]);\n}\n"
  },
  {
    "path": "lib/hooks/use-feature-flags.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { BetaFeatures } from \"@/lib/featureFlags\";\nimport { fetcher } from \"@/lib/utils\";\n\n/**\n * Hook to fetch and use feature flags for the current team\n */\nexport function useFeatureFlags() {\n  const teamInfo = useTeam();\n\n  const {\n    data: features,\n    error,\n    isLoading,\n  } = useSWR<Record<BetaFeatures, boolean>>(\n    teamInfo?.currentTeam?.id\n      ? `/api/feature-flags?teamId=${teamInfo.currentTeam.id}`\n      : null,\n    fetcher,\n  );\n\n  return {\n    features,\n    isLoading,\n    error,\n    // Helper function to check if a specific feature is enabled\n    isFeatureEnabled: (feature: BetaFeatures) => features?.[feature] || false,\n  };\n}\n"
  },
  {
    "path": "lib/hooks/use-is-admin.ts",
    "content": "import { useSession } from \"next-auth/react\";\n\nimport { useGetTeam } from \"@/lib/swr/use-team\";\nimport { CustomUser } from \"@/lib/types\";\n\n/**\n * Returns whether the current user is an Admin of the active team.\n * Relies on `useGetTeam()` (SWR-cached) so it won't cause extra fetches\n * when the team data is already loaded on a settings page.\n */\nexport function useIsAdmin() {\n  const { data: session, status } = useSession();\n  const { team, loading: teamLoading } = useGetTeam();\n\n  const sessionLoading = status === \"loading\";\n  const loading = teamLoading || sessionLoading;\n\n  const userId = (session?.user as CustomUser)?.id;\n\n  const isAdmin = !loading &&\n    !!team?.users?.some(\n      (u) => u.userId === userId && u.role === \"ADMIN\",\n    );\n\n  return { isAdmin, loading };\n}\n"
  },
  {
    "path": "lib/hooks/use-lazy-pages.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\ntype PageData = {\n  file: string | null;\n  pageNumber: string;\n  embeddedLinks: string[];\n  pageLinks: {\n    href: string;\n    coords: string;\n    isInternal?: boolean;\n    targetPage?: number;\n  }[];\n  metadata: { width: number; height: number; scaleFactor: number };\n};\n\ntype FetchPagesResponse = {\n  pages: { pageNumber: number; file: string }[];\n};\n\ntype UseLazyPagesOptions = {\n  initialPages: PageData[];\n  viewId?: string;\n  documentVersionId: string;\n  preloadRadius?: number;\n  apiEndpoint?: string;\n};\n\nconst DEFAULT_PRELOAD_RADIUS = 5;\n\nexport function useLazyPages({\n  initialPages,\n  viewId,\n  documentVersionId,\n  preloadRadius = DEFAULT_PRELOAD_RADIUS,\n  apiEndpoint = \"/api/views/pages\",\n}: UseLazyPagesOptions) {\n  const [pages, setPages] = useState<PageData[]>(initialPages);\n  const pagesRef = useRef<PageData[]>(pages);\n  const pendingRef = useRef<Set<number>>(new Set());\n\n  useEffect(() => {\n    pagesRef.current = pages;\n  }, [pages]);\n\n  useEffect(() => {\n    setPages(initialPages);\n    pagesRef.current = initialPages;\n  }, [initialPages]);\n\n  const fetchPageUrls = useCallback(\n    async (pageNumbers: number[]) => {\n      const currentPages = pagesRef.current;\n      const needed = pageNumbers.filter(\n        (pn) =>\n          pn >= 1 &&\n          pn <= currentPages.length &&\n          !currentPages[pn - 1]?.file &&\n          !pendingRef.current.has(pn),\n      );\n\n      if (needed.length === 0) return;\n\n      needed.forEach((pn) => pendingRef.current.add(pn));\n\n      try {\n        const response = await fetch(apiEndpoint, {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify({\n            viewId,\n            documentVersionId,\n            pageNumbers: needed,\n          }),\n        });\n\n        if (!response.ok) {\n          needed.forEach((pn) => pendingRef.current.delete(pn));\n          return;\n        }\n\n        const data: FetchPagesResponse = await response.json();\n\n        setPages((prev) => {\n          const updated = [...prev];\n          for (const fetchedPage of data.pages) {\n            const idx = fetchedPage.pageNumber - 1;\n            if (idx >= 0 && idx < updated.length && updated[idx]) {\n              updated[idx] = {\n                ...updated[idx],\n                file: fetchedPage.file,\n              };\n            }\n          }\n          return updated;\n        });\n\n        needed.forEach((pn) => pendingRef.current.delete(pn));\n      } catch {\n        needed.forEach((pn) => pendingRef.current.delete(pn));\n      }\n    },\n    [viewId, documentVersionId, apiEndpoint],\n  );\n\n  const ensurePagesLoaded = useCallback(\n    (currentPage: number) => {\n      const currentPages = pagesRef.current;\n      const start = Math.max(1, currentPage - preloadRadius);\n      const end = Math.min(currentPages.length, currentPage + preloadRadius);\n      const needed: number[] = [];\n\n      for (let i = start; i <= end; i++) {\n        if (!currentPages[i - 1]?.file && !pendingRef.current.has(i)) {\n          needed.push(i);\n        }\n      }\n\n      if (needed.length > 0) {\n        fetchPageUrls(needed);\n      }\n    },\n    [preloadRadius, fetchPageUrls],\n  );\n\n  return { pages, ensurePagesLoaded, fetchPageUrls };\n}\n"
  },
  {
    "path": "lib/hooks/use-mobile.tsx",
    "content": "import * as React from \"react\"\n\nconst MOBILE_BREAKPOINT = 768\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    }\n    mql.addEventListener(\"change\", onChange)\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)\n    return () => mql.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return !!isMobile\n}\n"
  },
  {
    "path": "lib/id-helper.ts",
    "content": "import baseX from \"base-x\";\n\nfunction encodeBase58(buf: Buffer): string {\n  const alphabet = \"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\";\n\n  return baseX(alphabet).encode(buf);\n}\n/**\n * Generate ids similar to stripe\n */\nexport class IdGenerator<TPrefixes extends string> {\n  private prefixes: Record<TPrefixes, string>;\n\n  /**\n   * Create a new id generator with fully typed prefixes\n   * @param prefixes - Relevant prefixes for your domain\n   */\n  constructor(prefixes: Record<TPrefixes, string>) {\n    this.prefixes = prefixes;\n  }\n\n  /**\n   * Generate a new unique base58 encoded uuid with a defined prefix\n   *\n   * @returns xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n   */\n  public id = (prefix: TPrefixes): string => {\n    return [\n      this.prefixes[prefix],\n      encodeBase58(Buffer.from(crypto.randomUUID().replace(/-/g, \"\"), \"hex\")),\n    ].join(\"_\");\n  };\n}\n\nexport const newId = new IdGenerator({\n  view: \"view\",\n  videoView: \"vview\",\n  linkView: \"lview\",\n  inv: \"inv\", // invitation\n  email: \"email\",\n  doc: \"doc\",\n  page: \"page\",\n  dataroom: \"dr\",\n  preview: \"preview\",\n  webhook: \"wh\",\n  webhookEvent: \"evt\",\n  webhookSecret: \"whsec\",\n  token: \"pmk\",\n  clickEvent: \"click\",\n  preset: \"preset\",\n  pending: \"pending\", // for pending uploads\n}).id;\n"
  },
  {
    "path": "lib/incoming-webhooks/index.ts",
    "content": "// lib/incoming-webhooks.ts\nimport crypto from \"crypto\";\n\nimport { newId } from \"../id-helper\";\n\nfunction generateBase62String(length: number): string {\n  const chars =\n    \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\";\n  const bytes = crypto.randomBytes(length);\n  let result = \"\";\n\n  for (let i = 0; i < length; i++) {\n    result += chars[bytes[i] % chars.length];\n  }\n\n  return result;\n}\n\nfunction encodeTeamId(teamId: string): string {\n  // Convert the teamId to a Buffer if it's not already\n  const buffer = Buffer.from(teamId);\n  // Convert to base62 string\n  return buffer\n    .toString(\"base64\")\n    .replace(/\\+/g, \"-\")\n    .replace(/\\//g, \"_\")\n    .replace(/=/g, \"\");\n}\n\nfunction decodeTeamId(encoded: string): string {\n  // Add back the padding\n  const padding = \"=\".repeat((4 - (encoded.length % 4)) % 4);\n  const base64 = encoded.replace(/-/g, \"+\").replace(/_/g, \"/\") + padding;\n\n  return Buffer.from(base64, \"base64\").toString();\n}\n\nexport function generateWebhookId(teamId: string): string {\n  // Format: T{encoded_team_id}/B{8 chars}/{24 chars}\n  const encodedTeamId = encodeTeamId(teamId);\n  const botPart = generateBase62String(8);\n  const secretPart = generateBase62String(24);\n\n  return `T${encodedTeamId}/B${botPart}/${secretPart}`;\n}\n\nexport function extractTeamId(webhookId: string): string | null {\n  try {\n    const [teamPart] = webhookId.split(\"/\");\n    if (!teamPart || !teamPart.startsWith(\"T\")) return null;\n\n    const encodedTeamId = teamPart.slice(1); // Remove 'T' prefix\n    return decodeTeamId(encodedTeamId);\n  } catch {\n    return null;\n  }\n}\n\nexport function isValidWebhookId(webhookId: string): boolean {\n  // Validate format: T{encoded_team_id}/B{8}/{\\24}\n  const parts = webhookId.split(\"/\");\n  return (\n    parts.length === 3 &&\n    parts[0].startsWith(\"T\") &&\n    parts[1].startsWith(\"B\") &&\n    parts[1].length === 9 && // 'B' + 8 chars\n    parts[2].length === 24\n  );\n}\n\nexport function generateWebhookSecret(): string {\n  return newId(\"webhookSecret\"); // whsec_\n}\n"
  },
  {
    "path": "lib/integrations/install.ts",
    "content": "import { waitUntil } from \"@vercel/functions\";\n\nimport prisma from \"@/lib/prisma\";\nimport { sendEmail } from \"@/lib/resend\";\n\nimport IntegrationInstalled from \"@/components/emails/installed-integration-notification\";\n\ninterface InstallIntegration {\n  userId: string;\n  teamId: string;\n  integrationId: string;\n  credentials?: Record<string, any>;\n}\n\n// Install an integration for a user in a workspace\nexport const installIntegration = async ({\n  userId,\n  teamId,\n  integrationId,\n  credentials,\n}: InstallIntegration) => {\n  const installation = await prisma.installedIntegration.upsert({\n    create: {\n      userId,\n      teamId,\n      integrationId,\n      credentials,\n    },\n    update: {\n      credentials,\n    },\n    where: {\n      teamId_integrationId: {\n        teamId,\n        integrationId,\n      },\n    },\n  });\n\n  waitUntil(\n    (async () => {\n      const team = await prisma.team.findUniqueOrThrow({\n        where: {\n          id: teamId,\n        },\n        select: {\n          name: true,\n          users: {\n            where: { userId },\n            select: {\n              user: {\n                select: { email: true },\n              },\n            },\n          },\n          installedIntegrations: {\n            where: { integrationId },\n            select: {\n              integration: {\n                select: {\n                  name: true,\n                  slug: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      const email = team.users.length > 0 ? team.users[0].user.email : null;\n      const integration =\n        team.installedIntegrations.length > 0\n          ? team.installedIntegrations[0].integration\n          : null;\n\n      if (email && integration) {\n        await sendEmail({\n          to: email,\n          system: true,\n          test: process.env.NODE_ENV === \"development\",\n          subject: `The \"${integration.name}\" integration has been added to your team`,\n          react: IntegrationInstalled({\n            email: email,\n            team: {\n              name: team.name,\n            },\n            integration: {\n              name: integration.name,\n              slug: integration.slug,\n            },\n          }),\n        });\n      }\n    })(),\n  );\n\n  return installation;\n};\n"
  },
  {
    "path": "lib/integrations/slack/client.ts",
    "content": "import {\n  SlackChannel,\n  SlackMessage,\n  // SlackOAuthResponse,\n  // SlackWorkspaceInfo,\n} from \"@/lib/integrations/slack/types\";\nimport { decryptSlackToken } from \"@/lib/integrations/slack/utils\";\n\nexport class SlackClient {\n  private clientId: string;\n  private clientSecret: string;\n  private baseUrl = \"https://slack.com/api\";\n  // private oauthUrl = \"https://slack.com/oauth/v2/authorize\";\n\n  constructor() {\n    this.clientId = process.env.SLACK_CLIENT_ID as string;\n    this.clientSecret = process.env.SLACK_CLIENT_SECRET as string;\n\n    if (!this.clientId || !this.clientSecret) {\n      throw new Error(\"SLACK_CLIENT_ID and SLACK_CLIENT_SECRET must be set\");\n    }\n  }\n\n  // private decryptToken(accessToken: string): string {\n  //   return decryptSlackToken(accessToken);\n  // }\n\n  /**\n   * Get OAuth URL for Slack app installation\n   */\n  // getOAuthUrl(state: string, redirectUri: string): string {\n  //   const params = new URLSearchParams({\n  //     client_id: this.clientId,\n  //     scope:\n  //       // \"channels:read,groups:read,mpim:read,im:read,chat:write,chat:write.public,team:read,users:read,users:read.email\",\n  //       \"channels:read,chat:write,chat:write.public,groups:read,team:read,users:read\",\n  //     redirect_uri: redirectUri,\n  //     state: state,\n  //   });\n\n  //   const oauthUrl = `${this.oauthUrl}?${params.toString()}`;\n\n  //   return oauthUrl;\n  // }\n\n  /**\n   * Exchange authorization code for access token\n   */\n  // async exchangeCodeForToken(\n  //   code: string,\n  //   redirectUri: string,\n  // ): Promise<SlackOAuthResponse> {\n  //   const ac = new AbortController();\n  //   const t = setTimeout(() => ac.abort(), 10000);\n  //   const response = await fetch(`${this.baseUrl}/oauth.v2.access`, {\n  //     method: \"POST\",\n  //     headers: {\n  //       \"Content-Type\": \"application/x-www-form-urlencoded\",\n  //     },\n  //     body: new URLSearchParams({\n  //       client_id: this.clientId,\n  //       client_secret: this.clientSecret,\n  //       code: code,\n  //       redirect_uri: redirectUri,\n  //     }),\n  //     signal: ac.signal,\n  //   })\n  //     .catch((e) => {\n  //       throw new Error(`Slack OAuth network error: ${e}`);\n  //     })\n  //     .finally(() => clearTimeout(t));\n\n  //   if (!response.ok) {\n  //     throw new Error(`Slack OAuth failed: ${response.statusText}`);\n  //   }\n\n  //   const data = await response.json();\n\n  //   if (!data.ok) {\n  //     throw new Error(`Slack OAuth error: ${data.error}`);\n  //   }\n\n  //   return data;\n  // }\n\n  /**\n   * Get workspace information\n   */\n  // async getWorkspaceInfo(accessToken: string): Promise<SlackWorkspaceInfo> {\n  //   const decryptedToken = this.decryptToken(accessToken);\n  //   const response = await fetch(`${this.baseUrl}/team.info`, {\n  //     headers: {\n  //       Authorization: `Bearer ${decryptedToken}`,\n  //       \"Content-Type\": \"application/x-www-form-urlencoded\",\n  //     },\n  //   });\n\n  //   if (!response.ok) {\n  //     throw new Error(`Failed to get workspace info: ${response.statusText}`);\n  //   }\n\n  //   const data = await response.json();\n\n  //   if (!data.ok) {\n  //     throw new Error(`Slack API error: ${data.error}`);\n  //   }\n\n  //   return {\n  //     id: data.team.id,\n  //     name: data.team.name,\n  //     url: `https://${data.team.domain}.slack.com`, // not in use\n  //     domain: data.team.domain,\n  //   };\n  // }\n\n  /**\n   * Get bot information\n   */\n  // async getBotInfo(accessToken: string): Promise<{ id: string; name: string }> {\n  //   const decryptedToken = this.decryptToken(accessToken);\n  //   const response = await fetch(`${this.baseUrl}/auth.test`, {\n  //     headers: {\n  //       Authorization: `Bearer ${decryptedToken}`,\n  //       \"Content-Type\": \"application/json\",\n  //     },\n  //   });\n\n  //   if (!response.ok) {\n  //     throw new Error(`Failed to get bot info: ${response.statusText}`);\n  //   }\n\n  //   const data = await response.json();\n\n  //   if (!data.ok) {\n  //     throw new Error(`Slack API error: ${data.error}`);\n  //   }\n\n  //   return {\n  //     id: data.bot_id,\n  //     name: data.user,\n  //   };\n  // }\n\n  async getChannels(accessToken: string): Promise<SlackChannel[]> {\n    const decryptedToken = decryptSlackToken(accessToken);\n    if (!decryptedToken) {\n      throw new Error(\"Missing Slack access token\");\n    }\n\n    const channels: SlackChannel[] = [];\n    let cursor: string | undefined = undefined;\n\n    do {\n      const url = new URL(`${this.baseUrl}/conversations.list`);\n      url.searchParams.set(\"types\", \"public_channel,private_channel\");\n      url.searchParams.set(\"exclude_archived\", \"true\");\n      if (cursor) url.searchParams.set(\"cursor\", cursor);\n\n      const requestUrl = url.toString();\n      const ac = new AbortController();\n      const to = setTimeout(() => ac.abort(), 10000);\n      const resp = await fetch(requestUrl, {\n        headers: {\n          Authorization: `Bearer ${decryptedToken}`,\n          \"Content-Type\": \"application/json\",\n        },\n        signal: ac.signal,\n      }).finally(() => clearTimeout(to));\n      // Basic 429 handling\n      if (resp.status === 429) {\n        const retry = Number(resp.headers.get(\"retry-after\") || 1);\n        await new Promise((r) => setTimeout(r, retry * 1000));\n        continue;\n      }\n      if (!resp.ok)\n        throw new Error(\n          `Failed to get channels: ${resp.status} ${resp.statusText}`,\n        );\n      const data = await resp.json();\n      if (!data.ok) throw new Error(`Slack API error: ${data.error}`);\n      channels.push(\n        ...data.channels.map((channel: any) => ({\n          id: channel.id,\n          name: channel.name,\n          is_private: channel.is_private,\n          is_archived: channel.is_archived,\n          is_member: channel.is_member,\n        })),\n      );\n      cursor = data.response_metadata?.next_cursor || undefined;\n    } while (cursor);\n\n    return channels;\n  }\n\n  /**\n   * Send message to Slack channel\n   */\n  async sendMessage(\n    accessToken: string,\n    message: SlackMessage,\n  ): Promise<{ ok: boolean; ts?: string; error?: string }> {\n    const decryptedToken = decryptSlackToken(accessToken);\n    if (!decryptedToken) {\n      throw new Error(\"Missing Slack access token\");\n    }\n\n    const requestUrl = `${this.baseUrl}/chat.postMessage`;\n    const response = await fetch(requestUrl, {\n      method: \"POST\",\n      headers: {\n        Authorization: `Bearer ${decryptedToken}`,\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify(message),\n    });\n\n    if (!response.ok) {\n      throw new Error(`Failed to send message: ${response.statusText}`);\n    }\n\n    const data = await response.json();\n\n    if (!data.ok) {\n      return {\n        ok: false,\n        error: data.error,\n      };\n    }\n\n    return {\n      ok: true,\n      ts: data.ts,\n    };\n  }\n}\n\n// Lazily instantiate\nlet _slackClient: SlackClient | null = null;\nexport function getSlackClient(): SlackClient {\n  if (!_slackClient) _slackClient = new SlackClient();\n  return _slackClient;\n}\n"
  },
  {
    "path": "lib/integrations/slack/env.ts",
    "content": "import { z } from \"zod\";\n\nexport const envSchema = z.object({\n  SLACK_APP_INSTALL_URL: z.string(),\n  SLACK_CLIENT_ID: z.string(),\n  SLACK_CLIENT_SECRET: z.string(),\n  SLACK_INTEGRATION_ID: z.string(),\n});\n\ntype SlackEnv = z.infer<typeof envSchema>;\n\nlet env: SlackEnv | undefined;\n\nexport const getSlackEnv = () => {\n  if (env) {\n    return env;\n  }\n\n  const parsed = envSchema.safeParse(process.env);\n\n  if (!parsed.success) {\n    throw new Error(\n      \"Slack app environment variables are not configured properly.\",\n    );\n  }\n\n  env = parsed.data;\n\n  return env;\n};\n"
  },
  {
    "path": "lib/integrations/slack/events.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\nimport { SlackClient } from \"./client\";\nimport { getSlackEnv } from \"./env\";\nimport { createSlackMessage } from \"./templates\";\nimport { SlackEventData, SlackIntegrationServer } from \"./types\";\n\nexport class SlackEventManager {\n  private client: SlackClient;\n\n  constructor() {\n    this.client = new SlackClient();\n  }\n\n  /**\n   * Check if the viewer's email domain is in the team's ignored domains list\n   */\n  private isViewerDomainIgnored(\n    viewerEmail: string | undefined,\n    ignoredDomains: string[] | null,\n  ): boolean {\n    if (!viewerEmail || !ignoredDomains || ignoredDomains.length === 0) {\n      return false;\n    }\n\n    const viewerDomain = viewerEmail.split(\"@\").pop();\n    if (!viewerDomain) {\n      return false;\n    }\n\n    // Normalize ignored domains (remove @ prefix if present)\n    const normalizedIgnoredDomains = ignoredDomains.map((d) =>\n      d.startsWith(\"@\") ? d.substring(1) : d,\n    );\n\n    return normalizedIgnoredDomains.includes(viewerDomain);\n  }\n\n  async processEvent(eventData: SlackEventData): Promise<void> {\n    try {\n      const env = getSlackEnv();\n\n      // Fetch integration and team's ignored domains in parallel\n      const [integration, team] = await Promise.all([\n        prisma.installedIntegration.findUnique({\n          where: {\n            teamId_integrationId: {\n              teamId: eventData.teamId,\n              integrationId: env.SLACK_INTEGRATION_ID,\n            },\n          },\n          select: {\n            enabled: true,\n            credentials: true,\n            configuration: true,\n          },\n        }),\n        prisma.team.findUnique({\n          where: { id: eventData.teamId },\n          select: { ignoredDomains: true },\n        }),\n      ]);\n\n      if (!integration || !integration.enabled) {\n        return;\n      }\n\n      // Check if the viewer's email domain is in the ignored domains list\n      if (\n        this.isViewerDomainIgnored(eventData.viewerEmail, team?.ignoredDomains ?? null)\n      ) {\n        // Log only the domain to avoid persisting PII\n        const redactedDomain = eventData.viewerEmail?.split(\"@\").pop() ?? \"unknown-domain\";\n        console.log(\n          `Slack notification skipped for ignored domain: ${redactedDomain}`,\n        );\n        return;\n      }\n\n      await this.sendSlackNotification(\n        eventData,\n        integration as SlackIntegrationServer,\n      );\n    } catch (error) {\n      console.error(\"Error processing Slack event:\", error);\n    }\n  }\n\n  /**\n   * Send slack notification for an event\n   */\n  private async sendSlackNotification(\n    eventData: SlackEventData,\n    integration: SlackIntegrationServer,\n  ): Promise<void> {\n    try {\n      const channels = await this.getNotificationChannels(\n        eventData,\n        integration,\n      );\n\n      if (channels.length === 0) {\n        return;\n      }\n\n      for (const channel of channels) {\n        try {\n          const message = await createSlackMessage(eventData);\n          if (message) {\n            const slackMessage = {\n              ...message,\n              channel: channel.id,\n            };\n            await this.client.sendMessage(\n              integration.credentials.accessToken,\n              slackMessage,\n            );\n          }\n        } catch (channelError) {\n          console.error(\n            `Error sending to channel ${channel.name || channel.id}:`,\n            channelError,\n          );\n        }\n      }\n    } catch (error) {\n      console.error(\"Error sending instant notification:\", error);\n    }\n  }\n\n  // private async getSlackIntegration(teamId: string) {\n  //   const env = getSlackEnv();\n  //   return await prisma.installedIntegration.findUnique({\n  //     where: {\n  //       teamId_integrationId: {\n  //         teamId,\n  //         integrationId: env.SLACK_INTEGRATION_ID,\n  //       },\n  //       enabled: true,\n  //     },\n  //   });\n  // }\n\n  // private isEventTypeEnabled(eventType: string, integration: any): boolean {\n  //   const notificationTypes = integration.notificationTypes || {};\n  //   return notificationTypes[eventType] || false;\n  // }\n\n  private async getNotificationChannels(\n    eventData: SlackEventData,\n    integration: SlackIntegrationServer,\n  ): Promise<any[]> {\n    const enabledChannels = integration.configuration?.enabledChannels || {};\n    return Object.values(enabledChannels)\n      .filter((channel: any) => channel.enabled)\n      .filter(\n        (channel: any) =>\n          channel.notificationTypes &&\n          channel.notificationTypes.includes(eventData.eventType),\n      );\n  }\n}\n\nexport const slackEventManager = new SlackEventManager();\n\nexport async function notifyDocumentView(\n  data: Omit<SlackEventData, \"eventType\">,\n) {\n  await slackEventManager.processEvent({ ...data, eventType: \"document_view\" });\n}\n\nexport async function notifyDataroomAccess(\n  data: Omit<SlackEventData, \"eventType\">,\n) {\n  await slackEventManager.processEvent({\n    ...data,\n    eventType: \"dataroom_access\",\n  });\n}\n\nexport async function notifyDocumentDownload(\n  data: Omit<SlackEventData, \"eventType\">,\n) {\n  await slackEventManager.processEvent({\n    ...data,\n    eventType: \"document_download\",\n  });\n}\n"
  },
  {
    "path": "lib/integrations/slack/install.ts",
    "content": "import { nanoid } from \"nanoid\";\n\nimport { redis } from \"@/lib/redis\";\n\nimport { getSlackEnv } from \"./env\";\n\n// Get the installation URL for Slack\nexport const getSlackInstallationUrl = async (\n  teamId: string,\n): Promise<string> => {\n  const env = getSlackEnv();\n\n  const state = nanoid(16);\n  await redis.set(`slack:install:state:${state}`, teamId, {\n    ex: 30 * 60,\n  });\n\n  const url = new URL(env.SLACK_APP_INSTALL_URL);\n  url.searchParams.set(\n    \"redirect_uri\",\n    `${process.env.NEXT_PUBLIC_BASE_URL}/api/integrations/slack/oauth/callback`,\n  );\n  url.searchParams.set(\"state\", state);\n\n  return url.toString();\n};\n"
  },
  {
    "path": "lib/integrations/slack/templates.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\nimport { SlackEventData, SlackMessage } from \"./types\";\n\n/**\n * Helper function to safely reference a link with fallback handling\n * @param link - The link object that may have undefined or null properties\n * @returns Safe link reference string\n */\nfunction linkRef(\n  link: { name?: string | null; id?: string } | null | undefined,\n): string {\n  if (link?.name) {\n    return `\"${link.name}\"`;\n  }\n  return `\"Link ${link?.id?.slice(0, 5) ?? \"unknown\"}\"`;\n}\n\nexport async function createSlackMessage(\n  eventData: SlackEventData,\n): Promise<SlackMessage | null> {\n  try {\n    // If team is paused, return paused message templates\n    if (eventData.teamIsPaused) {\n      switch (eventData.eventType) {\n        case \"document_view\":\n          return await createDocumentViewPausedMessage(eventData);\n        case \"dataroom_access\":\n          return await createDataroomAccessPausedMessage(eventData);\n        case \"document_download\":\n          // Downloads are blocked when paused, so no message needed\n          return null;\n        default:\n          return null;\n      }\n    }\n\n    switch (eventData.eventType) {\n      case \"document_view\":\n        return await createDocumentViewMessage(eventData);\n      case \"dataroom_access\":\n        return await createDataroomAccessMessage(eventData);\n      case \"document_download\":\n        return await createDocumentDownloadMessage(eventData);\n\n      default:\n        return null;\n    }\n  } catch (error) {\n    console.error(\"Error creating Slack message:\", error);\n    return null;\n  }\n}\n\n/**\n * Document View Message Template\n */\nasync function createDocumentViewMessage(\n  eventData: SlackEventData,\n): Promise<SlackMessage> {\n  const document = eventData.documentId\n    ? await getDocumentInfo(eventData.documentId)\n    : null;\n  const dataroom = eventData.dataroomId\n    ? await getDataroomInfo(eventData.dataroomId)\n    : null;\n  const link = eventData.linkId ? await getLinkInfo(eventData.linkId) : null;\n\n  const viewerDisplay = eventData.viewerEmail || \"Anonymous\";\n  const viewerMention = eventData.viewerEmail\n    ? `<mailto:${eventData.viewerEmail}|${viewerDisplay}>`\n    : viewerDisplay;\n\n  let accessContext = \"\";\n  if (eventData.dataroomId && dataroom) {\n    accessContext = `in dataroom \"${dataroom.name}\"`;\n  } else {\n    accessContext = `via shared link ${linkRef(link)}`;\n  }\n\n  return {\n    text: `Your document has been viewed: ${document?.name || \"Unknown document\"} by ${viewerDisplay} ${accessContext}`,\n    blocks: [\n      {\n        type: \"header\",\n        text: {\n          type: \"plain_text\",\n          text: \"Your document has been viewed\",\n          emoji: false,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Document:*\\n${document?.name || \"Unknown\"}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Viewer:*\\n${viewerMention}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: dataroom?.name\n              ? `*Dataroom:*\\n${dataroom.name}`\n              : link?.name\n                ? `*Shared Link:*\\n${link.name}`\n                : `*Access:*\\nDirect access`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Time:*\\n${new Date().toLocaleString()}`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: eventData.dataroomId\n              ? `Viewed document in dataroom \"${dataroom?.name || \"Unknown\"}\"`\n              : `Viewed document via shared link ${linkRef(link)}`,\n          },\n        ],\n      },\n      {\n        type: \"actions\",\n        elements: [\n          {\n            type: \"button\",\n            text: {\n              type: \"plain_text\",\n              text: \"View document\",\n              emoji: true,\n            },\n            style: \"primary\",\n            url: eventData.documentId\n              ? `${process.env.NEXTAUTH_URL}/documents/${eventData.documentId}`\n              : `${process.env.NEXTAUTH_URL}/dashboard`,\n          },\n        ],\n      },\n    ],\n  };\n}\n\n/**\n * Dataroom Access Message Template\n */\nasync function createDataroomAccessMessage(\n  eventData: SlackEventData,\n): Promise<SlackMessage> {\n  const dataroom = eventData.dataroomId\n    ? await getDataroomInfo(eventData.dataroomId)\n    : null;\n  const link = eventData.linkId ? await getLinkInfo(eventData.linkId) : null;\n\n  const viewerDisplay = eventData.viewerEmail || \"Anonymous\";\n  const viewerMention = eventData.viewerEmail\n    ? `<mailto:${eventData.viewerEmail}|${viewerDisplay}>`\n    : viewerDisplay;\n\n  const accessContext = `via shared link ${linkRef(link)}`;\n\n  return {\n    text: `Your dataroom has been viewed: ${dataroom?.name || \"Unknown dataroom\"} by ${viewerDisplay} ${accessContext}`,\n    blocks: [\n      {\n        type: \"header\",\n        text: {\n          type: \"plain_text\",\n          text: \"Your dataroom has been viewed\",\n          emoji: false,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Dataroom:*\\n${dataroom?.name || \"Unknown\"}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Viewer:*\\n${viewerMention}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: link?.name\n              ? `*Shared Link:*\\n${link.name}`\n              : `*Access:*\\nDirect access`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Time:*\\n${new Date().toLocaleString()}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Documents:*\\n${dataroom?.documentCount || 0} documents`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: `Dataroom accessed via shared link ${linkRef(link)}`,\n          },\n        ],\n      },\n      {\n        type: \"actions\",\n        elements: [\n          {\n            type: \"button\",\n            text: {\n              type: \"plain_text\",\n              text: \"View dataroom\",\n              emoji: true,\n            },\n            style: \"primary\",\n            url: eventData.dataroomId\n              ? `${process.env.NEXTAUTH_URL}/datarooms/${eventData.dataroomId}`\n              : `${process.env.NEXTAUTH_URL}/dashboard`,\n          },\n        ],\n      },\n    ],\n  };\n}\n\n/**\n * Document Download Message Template\n */\nasync function createDocumentDownloadMessage(\n  eventData: SlackEventData,\n): Promise<SlackMessage> {\n  const document = eventData.documentId\n    ? await getDocumentInfo(eventData.documentId)\n    : null;\n  const dataroom = eventData.dataroomId\n    ? await getDataroomInfo(eventData.dataroomId)\n    : null;\n  const link = eventData.linkId ? await getLinkInfo(eventData.linkId) : null;\n\n  const viewerDisplay = eventData.viewerEmail || \"Anonymous\";\n  const viewerMention = eventData.viewerEmail\n    ? `<mailto:${eventData.viewerEmail}|${viewerDisplay}>`\n    : viewerDisplay;\n\n  const isBulkDownload = eventData.metadata?.isBulkDownload;\n  const isFolderDownload = eventData.metadata?.isFolderDownload;\n  const folderName = eventData.metadata?.folderName;\n  const documentCount = eventData.metadata?.documentCount;\n\n  let downloadType = \"Document\";\n  let downloadContext = \"\";\n\n  if (isBulkDownload) {\n    downloadType = \"Dataroom\";\n    downloadContext = `(${documentCount} documents)`;\n  } else if (isFolderDownload) {\n    downloadType = \"Folder\";\n    downloadContext = `\"${folderName}\" (${documentCount} documents)`;\n  } else if (dataroom) {\n    downloadContext = `from dataroom \"${dataroom.name}\"`;\n  } else {\n    downloadContext = `via shared link ${linkRef(link)}`;\n  }\n\n  return {\n    text: `${downloadType} has been downloaded: ${document?.name || downloadContext} by ${viewerDisplay}`,\n    blocks: [\n      {\n        type: \"header\",\n        text: {\n          type: \"plain_text\",\n          text: `${downloadType} has been downloaded`,\n          emoji: false,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: document?.name\n              ? `*${downloadType}:*\\n${document.name}`\n              : `*${downloadType}:*\\n${downloadContext}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Downloaded by:*\\n${viewerMention}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: dataroom?.name\n              ? `*From Dataroom:*\\n${dataroom.name}`\n              : link?.name\n                ? `*Shared Link:*\\n${link.name}`\n                : `*Context:*\\n${downloadContext}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Time:*\\n${new Date().toLocaleString()}`,\n          },\n        ],\n      },\n      {\n        type: \"context\",\n        elements: [\n          {\n            type: \"mrkdwn\",\n            text: isBulkDownload\n              ? `Bulk dataroom download`\n              : isFolderDownload\n                ? `Folder download`\n                : `Document download via shared link ${linkRef(link)}`,\n          },\n        ],\n      },\n      {\n        type: \"actions\",\n        elements: [\n          {\n            type: \"button\",\n            text: {\n              type: \"plain_text\",\n              text: \"View activity\",\n              emoji: true,\n            },\n            style: \"primary\",\n            url: eventData.dataroomId\n              ? `${process.env.NEXTAUTH_URL}/datarooms/${eventData.dataroomId}`\n              : eventData.documentId\n                ? `${process.env.NEXTAUTH_URL}/documents/${eventData.documentId}`\n                : `${process.env.NEXTAUTH_URL}/dashboard`,\n          },\n        ],\n      },\n    ],\n  };\n}\n\n/**\n * Document View Paused Message Template\n */\nasync function createDocumentViewPausedMessage(\n  eventData: SlackEventData,\n): Promise<SlackMessage> {\n  const document = eventData.documentId\n    ? await getDocumentInfo(eventData.documentId)\n    : null;\n  const link = eventData.linkId ? await getLinkInfo(eventData.linkId) : null;\n\n  return {\n    text: `Your document has been viewed: ${document?.name || \"Unknown document\"} by someone`,\n    blocks: [\n      {\n        type: \"header\",\n        text: {\n          type: \"plain_text\",\n          text: \"New Document Visitor\",\n          emoji: false,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Document:*\\n${document?.name || \"Unknown\"}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Viewer:*\\nSomeone`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: link?.name\n              ? `*Shared Link:*\\n${link.name}`\n              : `*Access:*\\nDirect access`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Time:*\\n${new Date().toLocaleString()}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: \"⏸️ Your team is currently paused, so detailed visitor information is not available. To see who visited your documents and access full analytics, please unpause your subscription.\",\n        },\n      },\n      {\n        type: \"actions\",\n        elements: [\n          {\n            type: \"button\",\n            text: {\n              type: \"plain_text\",\n              text: \"Manage Subscription\",\n              emoji: true,\n            },\n            style: \"primary\",\n            url: `${process.env.NEXTAUTH_URL}/settings/billing`,\n          },\n        ],\n      },\n    ],\n  };\n}\n\n/**\n * Dataroom Access Paused Message Template\n */\nasync function createDataroomAccessPausedMessage(\n  eventData: SlackEventData,\n): Promise<SlackMessage> {\n  const dataroom = eventData.dataroomId\n    ? await getDataroomInfo(eventData.dataroomId)\n    : null;\n  const link = eventData.linkId ? await getLinkInfo(eventData.linkId) : null;\n\n  return {\n    text: `Your dataroom has been viewed: ${dataroom?.name || \"Unknown dataroom\"} by someone`,\n    blocks: [\n      {\n        type: \"header\",\n        text: {\n          type: \"plain_text\",\n          text: \"New Dataroom Visitor\",\n          emoji: false,\n        },\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: `*Dataroom:*\\n${dataroom?.name || \"Unknown\"}`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Viewer:*\\nSomeone`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        fields: [\n          {\n            type: \"mrkdwn\",\n            text: link?.name\n              ? `*Shared Link:*\\n${link.name}`\n              : `*Access:*\\nDirect access`,\n          },\n          {\n            type: \"mrkdwn\",\n            text: `*Time:*\\n${new Date().toLocaleString()}`,\n          },\n        ],\n      },\n      {\n        type: \"section\",\n        text: {\n          type: \"mrkdwn\",\n          text: \"⏸️ Your team is currently paused, so detailed visitor information is not available. To see who visited your dataroom and access full analytics, please unpause your subscription.\",\n        },\n      },\n      {\n        type: \"actions\",\n        elements: [\n          {\n            type: \"button\",\n            text: {\n              type: \"plain_text\",\n              text: \"Manage Subscription\",\n              emoji: true,\n            },\n            style: \"primary\",\n            url: `${process.env.NEXTAUTH_URL}/settings/billing`,\n          },\n        ],\n      },\n    ],\n  };\n}\n\n// Helper functions\nasync function getDocumentInfo(documentId: string) {\n  try {\n    return await prisma.document.findUnique({\n      where: { id: documentId },\n      select: {\n        id: true,\n        name: true,\n        description: true,\n        type: true,\n      },\n    });\n  } catch (error) {\n    console.error(\"Error fetching document info:\", error);\n    return null;\n  }\n}\n\nasync function getDataroomInfo(dataroomId: string) {\n  try {\n    return await prisma.dataroom\n      .findUnique({\n        where: { id: dataroomId },\n        select: {\n          id: true,\n          name: true,\n          description: true,\n          _count: {\n            select: {\n              documents: true,\n            },\n          },\n        },\n      })\n      .then((dataroom) =>\n        dataroom\n          ? {\n              ...dataroom,\n              documentCount: dataroom._count.documents,\n            }\n          : null,\n      );\n  } catch (error) {\n    console.error(\"Error fetching dataroom info:\", error);\n    return null;\n  }\n}\n\nasync function getLinkInfo(linkId: string) {\n  try {\n    return await prisma.link.findUnique({\n      where: { id: linkId },\n      select: {\n        id: true,\n        name: true,\n        linkType: true,\n      },\n    });\n  } catch (error) {\n    console.error(\"Error fetching link info:\", error);\n    return null;\n  }\n}\n\nasync function getViewInfo(viewId: string) {\n  try {\n    return await prisma.view.findUnique({\n      where: { id: viewId },\n      select: {\n        id: true,\n        viewerEmail: true,\n        viewerId: true,\n        viewedAt: true,\n        viewType: true,\n        documentId: true,\n        dataroomId: true,\n      },\n    });\n  } catch (error) {\n    console.error(\"Error fetching view info:\", error);\n    return null;\n  }\n}\n"
  },
  {
    "path": "lib/integrations/slack/types.ts",
    "content": "import { InstalledIntegration } from \"@prisma/client\";\n\nexport type SlackCredential = {\n  appId: string;\n  botUserId: string;\n  scope: string;\n  accessToken: string;\n  tokenType: string;\n  authUser: { id: string };\n  team: { id: string; name: string };\n};\n\nexport type SlackCredentialPublic = {\n  team: { id: string; name: string };\n};\n\nexport type SlackConfiguration = {\n  enabledChannels: Record<string, SlackChannelConfig>;\n};\n\n// from lib/types/slack.ts\nexport interface SlackChannel {\n  id: string;\n  name: string;\n  is_archived: boolean;\n  is_private: boolean;\n  is_member?: boolean;\n}\n\nexport type SlackIntegration = Omit<\n  InstalledIntegration,\n  \"credentials\" | \"configuration\"\n> & {\n  credentials: SlackCredentialPublic;\n  configuration: SlackConfiguration | null;\n};\n\nexport type SlackIntegrationServer = Omit<\n  InstalledIntegration,\n  \"credentials\" | \"configuration\"\n> & {\n  credentials: SlackCredential;\n  configuration: SlackConfiguration | null;\n};\n\nexport interface SlackMessage {\n  channel?: string;\n  text?: string;\n  blocks?: any[];\n  thread_ts?: string;\n  unfurl_links?: boolean;\n  unfurl_media?: boolean;\n}\n\nexport interface SlackEventData {\n  teamId: string;\n  eventType: SlackNotificationType;\n  documentId?: string;\n  dataroomId?: string;\n  viewId?: string;\n  linkId?: string;\n  viewerEmail?: string;\n  viewerId?: string;\n  userId?: string;\n  metadata?: Record<string, any>;\n  teamIsPaused?: boolean;\n}\n\nexport type SlackNotificationType =\n  | \"document_view\"\n  | \"dataroom_access\"\n  | \"document_download\";\n\nexport interface SlackChannelConfig {\n  id: string;\n  name: string;\n  enabled: boolean;\n  notificationTypes: SlackNotificationType[];\n}\n"
  },
  {
    "path": "lib/integrations/slack/uninstall.ts",
    "content": "import { InstalledIntegration } from \"@prisma/client\";\n\nimport { getSlackEnv } from \"./env\";\nimport { SlackCredential } from \"./types\";\nimport { decryptSlackToken } from \"./utils\";\n\nexport const uninstallSlackIntegration = async ({\n  installation,\n}: {\n  installation: InstalledIntegration;\n}) => {\n  const env = getSlackEnv();\n  const credentials = installation.credentials as SlackCredential;\n\n  const controller = new AbortController();\n  const timeout = setTimeout(() => controller.abort(), 10_000);\n  const response = await fetch(\"https://slack.com/api/apps.uninstall\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n    body: new URLSearchParams({\n      token: decryptSlackToken(credentials.accessToken),\n      client_id: env.SLACK_CLIENT_ID,\n      client_secret: env.SLACK_CLIENT_SECRET,\n    }),\n    signal: controller.signal,\n  });\n  clearTimeout(timeout);\n\n  const data = await response.json();\n\n  if (!response.ok || !data.ok) {\n    console.error(\"[Slack App]\", data);\n    throw new Error(\"Failed to remove the app from the Slack workspace.\");\n  }\n};\n"
  },
  {
    "path": "lib/integrations/slack/utils.ts",
    "content": "import crypto from \"crypto\";\n\n/**\n * Derives a 32-byte encryption key from the environment variable using SHA-256\n * @param keyMaterial - The raw key material from environment\n * @returns 32-byte Buffer for AES-256\n */\nfunction deriveKey(keyMaterial: string): Buffer {\n  return crypto.createHash(\"sha256\").update(keyMaterial, \"utf8\").digest();\n}\n\n/**\n * Checks if a token is already encrypted by detecting the encrypted format\n * @param token - Token to check\n * @returns true if already encrypted\n */\nfunction isTokenEncrypted(token: string): boolean {\n  if (!token) return false;\n\n  // Check for encrypted format (iv:encrypted:authTag - 3 parts, all hex)\n  const parts = token.split(\":\");\n  if (parts.length === 3) {\n    const [iv, encrypted, authTag] = parts;\n    // New format: 24-char hex IV (12 bytes), hex encrypted data, 32-char hex authTag (16 bytes)\n    return (\n      /^[0-9a-f]{24}$/.test(iv) &&\n      /^[0-9a-f]+$/.test(encrypted) &&\n      /^[0-9a-f]{32}$/.test(authTag)\n    );\n  }\n\n  return false;\n}\n\nexport function encryptSlackToken(token: string): string {\n  if (!token) return \"\";\n\n  // Avoid double-encryption with improved detection\n  if (isTokenEncrypted(token)) {\n    return token;\n  }\n\n  const encryptionKey = process.env.NEXT_PRIVATE_SLACK_ENCRYPTION_KEY;\n  if (!encryptionKey) {\n    throw new Error(\n      \"NEXT_PRIVATE_SLACK_ENCRYPTION_KEY environment variable is required for token encryption\",\n    );\n  }\n\n  // Derive key using raw SHA-256 digest Buffer\n  const key = deriveKey(encryptionKey);\n\n  // Use 12-byte IV for GCM (recommended size)\n  const iv = crypto.randomBytes(12);\n\n  // Create AES-256-GCM cipher\n  const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, iv);\n\n  // Encrypt the token\n  let encrypted = cipher.update(token, \"utf8\", \"hex\");\n  encrypted += cipher.final(\"hex\");\n\n  // Get authentication tag\n  const authTag = cipher.getAuthTag();\n\n  // Format: iv:encrypted:authTag (all hex-encoded)\n  return `${iv.toString(\"hex\")}:${encrypted}:${authTag.toString(\"hex\")}`;\n}\n\nexport function decryptSlackToken(encryptedToken: string): string {\n  if (!encryptedToken) return \"\";\n\n  const encryptionKey = process.env.NEXT_PRIVATE_SLACK_ENCRYPTION_KEY;\n  if (!encryptionKey) {\n    throw new Error(\n      \"NEXT_PRIVATE_SLACK_ENCRYPTION_KEY environment variable is required for token decryption\",\n    );\n  }\n\n  // Derive key using raw SHA-256 digest Buffer\n  const key = deriveKey(encryptionKey);\n\n  const parts = encryptedToken.split(\":\");\n\n  // Handle new GCM format: iv:encrypted:authTag\n  if (parts.length === 3) {\n    try {\n      const [ivHex, encryptedHex, authTagHex] = parts;\n\n      // Parse components\n      const iv = Buffer.from(ivHex, \"hex\");\n      const encrypted = Buffer.from(encryptedHex, \"hex\");\n      const authTag = Buffer.from(authTagHex, \"hex\");\n\n      // Validate sizes\n      if (iv.length !== 12) {\n        throw new Error(\"Invalid IV length for GCM\");\n      }\n      if (authTag.length !== 16) {\n        throw new Error(\"Invalid auth tag length for GCM\");\n      }\n\n      // Create decipher and set auth tag\n      const decipher = crypto.createDecipheriv(\"aes-256-gcm\", key, iv);\n      decipher.setAuthTag(authTag);\n\n      // Decrypt\n      let decrypted = decipher.update(encrypted, undefined, \"utf8\");\n      decrypted += decipher.final(\"utf8\");\n\n      return decrypted;\n    } catch (error) {\n      // Surface authentication failures as decryption errors\n      throw new Error(\n        `Token decryption failed: ${error instanceof Error ? error.message : \"Unknown error\"}`,\n      );\n    }\n  }\n\n  // If not in expected format, return as-is (might be plaintext)\n  return encryptedToken;\n}\n"
  },
  {
    "path": "lib/jackson.ts",
    "content": "import type {\n  IConnectionAPIController,\n  IDirectorySyncController,\n  IOAuthController,\n  JacksonOption,\n} from \"@boxyhq/saml-jackson\";\nimport samlJackson from \"@boxyhq/saml-jackson\";\nimport crypto from \"crypto\";\n\nexport const samlAudience = \"https://saml.papermark.com\";\n\nexport { JACKSON_PRODUCT as jacksonProduct } from \"@/ee/features/security/sso/product\";\n\n// Jackson's AES-256-GCM encrypter requires exactly 32 bytes.\n// Derive a stable 32-byte key from NEXTAUTH_SECRET using SHA-256.\nconst encryptionKey = crypto\n  .createHash(\"sha256\")\n  .update(process.env.NEXTAUTH_SECRET || \"\")\n  .digest(\"base64url\")\n  .substring(0, 32);\n\nfunction sanitizePostgresUrl(rawUrl: string): string {\n  try {\n    const parsed = new URL(rawUrl);\n\n    // Some providers emit ssl cert params with value \"system\", which can cause\n    // node-postgres to attempt reading a local file literally named \"system\".\n    for (const param of [\"sslcert\", \"sslrootcert\", \"sslkey\", \"sslcrl\"]) {\n      const value = parsed.searchParams.get(param);\n      if (value?.toLowerCase() === \"system\") {\n        parsed.searchParams.delete(param);\n      }\n    }\n\n    return parsed.toString();\n  } catch {\n    // If URL parsing fails, pass through so downstream errors remain visible.\n    return rawUrl;\n  }\n}\n\nfunction getJacksonDbUrl(): string {\n  const candidates = [\n    process.env.POSTGRES_PRISMA_URL,\n    process.env.POSTGRES_PRISMA_URL_NON_POOLING,\n  ];\n\n  const dbUrl = candidates.find((value) => typeof value === \"string\" && value.trim().length > 0);\n\n  if (!dbUrl) {\n    throw new Error(\n      \"Missing Jackson DB URL. Set POSTGRES_PRISMA_URL or POSTGRES_PRISMA_URL_NON_POOLING.\",\n    );\n  }\n\n  return sanitizePostgresUrl(dbUrl);\n}\n\nfunction getJacksonOptions(): JacksonOption {\n  return {\n    externalUrl: process.env.NEXTAUTH_URL || \"https://app.papermark.com\",\n    samlPath: \"/api/auth/saml/callback\",\n    samlAudience,\n    db: {\n      engine: \"sql\",\n      type: \"postgres\",\n      url: getJacksonDbUrl(),\n      encryptionKey,\n    },\n    idpEnabled: true, // to allow to SSO from their IDP\n    scimPath: \"/api/scim/v2.0\",\n    clientSecretVerifier: process.env.NEXTAUTH_SECRET as string,\n  };\n}\n\ndeclare global {\n  var apiController: IConnectionAPIController | undefined;\n  var oauthController: IOAuthController | undefined;\n  var directorySyncController: IDirectorySyncController | undefined;\n}\n\nexport async function jackson() {\n  if (\n    !globalThis.apiController ||\n    !globalThis.oauthController ||\n    !globalThis.directorySyncController\n  ) {\n    const ret = await samlJackson(getJacksonOptions());\n    globalThis.apiController = ret.connectionAPIController;\n    globalThis.oauthController = ret.oauthController;\n    globalThis.directorySyncController = ret.directorySyncController;\n  }\n\n  return {\n    apiController: globalThis.apiController,\n    oauthController: globalThis.oauthController,\n    directorySyncController: globalThis.directorySyncController,\n  };\n}\n"
  },
  {
    "path": "lib/middleware/app.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { getToken } from \"next-auth/jwt\";\n\nconst LOGIN_PATH = \"/login\";\nconst DEFAULT_AUTH_REDIRECT_PATH = \"/dashboard\";\n\nfunction normalizeNextPath(nextPath: string | null): string {\n  if (!nextPath) {\n    return DEFAULT_AUTH_REDIRECT_PATH;\n  }\n\n  let normalized = nextPath;\n\n  // Handle already-encoded and double-encoded `next` values.\n  for (let i = 0; i < 3; i += 1) {\n    try {\n      const decoded = decodeURIComponent(normalized);\n      if (decoded === normalized) {\n        break;\n      }\n      normalized = decoded;\n    } catch {\n      break;\n    }\n  }\n\n  if (!normalized.startsWith(\"/\")) {\n    return DEFAULT_AUTH_REDIRECT_PATH;\n  }\n\n  return normalized;\n}\n\nexport default async function AppMiddleware(req: NextRequest) {\n  const url = req.nextUrl;\n  const path = url.pathname;\n  const isInvited = url.searchParams.has(\"invitation\");\n  const token = (await getToken({\n    req,\n    secret: process.env.NEXTAUTH_SECRET,\n  })) as {\n    email?: string;\n    user?: {\n      createdAt?: string;\n    };\n  };\n\n  // UNAUTHENTICATED if there's no token and the path isn't /login, redirect to /login\n  if (!token?.email && path !== LOGIN_PATH) {\n    const loginUrl = new URL(LOGIN_PATH, req.url);\n    // Append \"next\" parameter only if not navigating to the root\n    if (path !== \"/\") {\n      const nextPath =\n        path === \"/auth/confirm-email-change\" ? `${path}${url.search}` : path;\n\n      loginUrl.searchParams.set(\"next\", nextPath);\n    }\n    return NextResponse.redirect(loginUrl);\n  }\n\n  if (!token?.email && path === LOGIN_PATH) {\n    const rawNextPath = url.searchParams.get(\"next\");\n\n    if (rawNextPath) {\n      const normalizedNextPath = normalizeNextPath(rawNextPath);\n      const canonicalLoginUrl = new URL(LOGIN_PATH, req.url);\n      canonicalLoginUrl.searchParams.set(\"next\", normalizedNextPath);\n\n      if (canonicalLoginUrl.search !== url.search) {\n        return NextResponse.redirect(canonicalLoginUrl, { status: 308 });\n      }\n\n      // Keep the base /login URL indexable for now, but deindex parameterized variants.\n      const response = NextResponse.next();\n      response.headers.set(\"X-Robots-Tag\", \"noindex, nofollow\");\n      return response;\n    }\n\n    return NextResponse.next();\n  }\n\n  // AUTHENTICATED if the user was created in the last 10 seconds, redirect to \"/welcome\"\n  if (\n    token?.email &&\n    token?.user?.createdAt &&\n    new Date(token?.user?.createdAt).getTime() > Date.now() - 10000 &&\n    path !== \"/welcome\" &&\n    !isInvited\n  ) {\n    return NextResponse.redirect(new URL(\"/welcome\", req.url));\n  }\n\n  // AUTHENTICATED if the path is /login, redirect to the next path\n  if (token?.email && path === LOGIN_PATH) {\n    const nextPath = normalizeNextPath(url.searchParams.get(\"next\"));\n    return NextResponse.redirect(new URL(nextPath, req.url));\n  }\n\n  return NextResponse.next();\n}\n"
  },
  {
    "path": "lib/middleware/domain.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nimport { BLOCKED_PATHNAMES } from \"@/lib/constants\";\nimport { getDomainRedirectUrl } from \"@/lib/api/domains/redis\";\n\nexport default async function DomainMiddleware(req: NextRequest) {\n  const path = req.nextUrl.pathname;\n  const host = req.headers.get(\"host\");\n\n  // If it's the root path, check for a configured redirect URL in Redis\n  if (path === \"/\") {\n    if (host) {\n      const redirectUrl = await getDomainRedirectUrl(host);\n      if (redirectUrl) {\n        // 302: intentionally non-permanent since the target is user-configurable\n        return NextResponse.redirect(new URL(redirectUrl, req.url), {\n          status: 302,\n        });\n      }\n    }\n\n    return NextResponse.redirect(new URL(\"https://www.papermark.com\", req.url));\n  }\n\n  const url = req.nextUrl.clone();\n\n  // Check for blocked pathnames\n  if (BLOCKED_PATHNAMES.includes(path) || path.includes(\".\")) {\n    url.pathname = \"/404\";\n    return NextResponse.rewrite(url, { status: 404 });\n  }\n\n  // Rewrite the URL to the correct page component for custom domains\n  // Rewrite to the pages/view/domains/[domain]/[slug] route\n  url.pathname = `/view/domains/${host}${path}`;\n\n  return NextResponse.rewrite(url, {\n    headers: {\n      \"X-Robots-Tag\": \"noindex\",\n      \"X-Powered-By\":\n        \"Papermark - Secure Data Room Infrastructure for the modern web\",\n    },\n  });\n}\n"
  },
  {
    "path": "lib/middleware/incoming-webhooks.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nexport default async function IncomingWebhookMiddleware(req: NextRequest) {\n  const url = req.nextUrl.clone();\n  const path = url.pathname;\n\n  // Only handle /services/* paths\n  if (path.startsWith(\"/services/\")) {\n    // Rewrite to /api/webhooks/services/*\n    url.pathname = `/api/webhooks${path}`;\n\n    return NextResponse.rewrite(url);\n  }\n\n  // Return 404 for all other paths\n  url.pathname = \"/404\";\n  return NextResponse.rewrite(url, { status: 404 });\n}\n\nexport function isWebhookPath(host: string | null) {\n  if (!process.env.NEXT_PUBLIC_WEBHOOK_BASE_HOST) {\n    return false;\n  }\n\n  if (host === process.env.NEXT_PUBLIC_WEBHOOK_BASE_HOST) {\n    return true;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "lib/middleware/posthog.ts",
    "content": "import { NextRequest, NextResponse } from \"next/server\";\n\nexport default async function PostHogMiddleware(req: NextRequest) {\n  let url = req.nextUrl.clone();\n  const hostname = url.pathname.startsWith(\"/ingest/static/\")\n    ? \"eu-assets.i.posthog.com\"\n    : \"eu.i.posthog.com\";\n\n  // Handle OPTIONS method for CORS preflight\n  if (req.method === \"OPTIONS\") {\n    return new NextResponse(\"\", {\n      status: 200,\n    });\n  }\n\n  // Create clean headers object with only necessary headers\n  const forwardHeaders = new Headers();\n\n  // Headers that PostHog needs\n  const allowedHeaders = [\n    \"accept\",\n    \"accept-encoding\",\n    \"accept-language\",\n    \"authorization\",\n    \"content-type\",\n    \"content-length\",\n    \"user-agent\",\n    \"referer\",\n    \"origin\",\n    \"forwarded\",\n    \"x-forwarded-for\",\n    \"x-forwarded-host\",\n    \"x-forwarded-proto\",\n    \"x-real-ip\",\n    // PostHog specific headers\n    \"x-posthog-*\",\n  ];\n\n  // Copy allowed headers from the original request\n  for (const [key, value] of req.headers.entries()) {\n    const lowerKey = key.toLowerCase();\n\n    // Check if header is allowed\n    if (\n      allowedHeaders.some((allowed) =>\n        allowed.endsWith(\"*\")\n          ? lowerKey.startsWith(allowed.slice(0, -1))\n          : lowerKey === allowed,\n      )\n    ) {\n      forwardHeaders.set(key, value);\n    }\n  }\n\n  // Set the correct host for PostHog\n  forwardHeaders.set(\"host\", hostname);\n\n  url.protocol = \"https\";\n  url.hostname = hostname;\n  url.port = \"443\";\n  url.pathname = url.pathname.replace(/^\\/ingest/, \"\");\n\n  return NextResponse.rewrite(url, {\n    request: {\n      headers: forwardHeaders,\n    },\n  });\n}\n"
  },
  {
    "path": "lib/notion/config.ts",
    "content": "export const isDev =\n  process.env.NODE_ENV === \"development\" || !process.env.NODE_ENV;\n"
  },
  {
    "path": "lib/notion/index.ts",
    "content": "import { NotionAPI } from \"notion-client\";\n\nconst notion = new NotionAPI();\nexport default notion;\n"
  },
  {
    "path": "lib/notion/utils.ts",
    "content": "import { NotionAPI } from \"notion-client\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { getPageContentBlockIds, parsePageId } from \"notion-utils\";\n\nimport notion from \"./index\";\n\n/**\n * Extracts all page reference IDs from rich text decorations in the recordMap.\n * This handles the \"p\" decorator which is used for page mentions/links.\n */\nfunction extractPageReferencesFromRichText(\n  value: any[] | undefined,\n): string[] {\n  if (!value || !Array.isArray(value)) return [];\n\n  const pageIds: string[] = [];\n\n  for (const segment of value) {\n    if (!Array.isArray(segment)) continue;\n\n    const decorations = segment[1];\n    if (!decorations || !Array.isArray(decorations)) continue;\n\n    for (const decoration of decorations) {\n      if (!Array.isArray(decoration)) continue;\n\n      // \"p\" decorator indicates a page reference\n      if (decoration[0] === \"p\" && decoration[1]) {\n        pageIds.push(decoration[1]);\n      }\n\n      // \"‣\" (U+2023) decorator can also reference pages\n      if (decoration[0] === \"\\u2023\" && Array.isArray(decoration[1])) {\n        const [linkType, id] = decoration[1];\n        // Skip user references (\"u\"), only handle page references\n        if (linkType !== \"u\" && id) {\n          pageIds.push(id);\n        }\n      }\n    }\n  }\n\n  return pageIds;\n}\n\n/**\n * Normalizes the block entries in a recordMap to ensure a consistent structure.\n *\n * The Notion API (via `getBlocks` / newer API responses) sometimes returns blocks\n * in a double-nested format:\n *   { spaceId, value: { value: { id, type, ... }, role: \"reader\" } }\n *\n * But `react-notion-x` expects:\n *   { value: { id, type, ... }, role: \"reader\" }\n *\n * This function flattens any double-nested entries so the renderer doesn't crash\n * when calling `uuidToId` on an undefined block id.\n */\nexport function normalizeRecordMap(recordMap: ExtendedRecordMap): void {\n  // Normalize blocks\n  for (const blockId of Object.keys(recordMap.block)) {\n    const entry = recordMap.block[blockId] as any;\n    if (\n      entry?.value &&\n      typeof entry.value === \"object\" &&\n      entry.value.value &&\n      typeof entry.value.value === \"object\" &&\n      entry.value.value.id\n    ) {\n      // Double-nested: flatten { value: { value: {...}, role }, spaceId } → { value: {...}, role }\n      recordMap.block[blockId] = {\n        value: entry.value.value,\n        role: entry.value.role ?? entry.role ?? \"reader\",\n      } as any;\n    }\n  }\n\n  // Normalize collections (same pattern can occur)\n  if (recordMap.collection) {\n    for (const collectionId of Object.keys(recordMap.collection)) {\n      const entry = recordMap.collection[collectionId] as any;\n      if (\n        entry?.value &&\n        typeof entry.value === \"object\" &&\n        entry.value.value &&\n        typeof entry.value.value === \"object\" &&\n        entry.value.value.id\n      ) {\n        recordMap.collection[collectionId] = {\n          value: entry.value.value,\n          role: entry.value.role ?? entry.role ?? \"reader\",\n        } as any;\n      }\n    }\n  }\n}\n\n/**\n * Fetches missing page blocks that are referenced in rich text or as alias\n * targets but not present in the recordMap.\n */\nexport async function fetchMissingPageReferences(\n  recordMap: ExtendedRecordMap,\n): Promise<void> {\n  normalizeRecordMap(recordMap);\n\n  const allPageReferenceIds = new Set<string>();\n\n  for (const blockId of Object.keys(recordMap.block)) {\n    const block = recordMap.block[blockId]?.value as any;\n    if (!block) continue;\n\n    // Collect page references from rich text properties\n    if (block.properties) {\n      for (const propKey of Object.keys(block.properties)) {\n        const propValue = block.properties[propKey];\n        const pageIds = extractPageReferencesFromRichText(propValue);\n        pageIds.forEach((id) => allPageReferenceIds.add(id));\n      }\n    }\n\n    // Collect alias (link-to-page) targets\n    if (block.type === \"alias\" && block.format?.alias_pointer?.id) {\n      allPageReferenceIds.add(block.format.alias_pointer.id);\n    }\n\n    // Collect child page references from content array\n    if (block.content && Array.isArray(block.content)) {\n      for (const childId of block.content) {\n        if (typeof childId === \"string\" && !recordMap.block[childId]) {\n          allPageReferenceIds.add(childId);\n        }\n      }\n    }\n  }\n\n  const missingPageIds = Array.from(allPageReferenceIds).filter(\n    (id) => !recordMap.block[id],\n  );\n\n  if (missingPageIds.length === 0) return;\n\n  try {\n    const newBlocks = await notion.getBlocks(missingPageIds);\n    if (newBlocks?.recordMap?.block) {\n      recordMap.block = {\n        ...recordMap.block,\n        ...newBlocks.recordMap.block,\n      };\n    }\n    normalizeRecordMap(recordMap);\n  } catch (err) {\n    console.warn(\"Failed to fetch missing page references:\", err);\n  }\n}\n\nexport const addSignedUrls: NotionAPI[\"addSignedUrls\"] = async ({\n  recordMap,\n  contentBlockIds,\n}) => {\n  recordMap.signed_urls = {};\n\n  if (!contentBlockIds) {\n    contentBlockIds = getPageContentBlockIds(recordMap);\n  }\n\n  const allFileInstances = contentBlockIds.flatMap((blockId) => {\n    const block = recordMap.block[blockId]?.value;\n\n    if (\n      block &&\n      (block.type === \"pdf\" ||\n        block.type === \"audio\" ||\n        (block.type === \"image\" && block.file_ids?.length) ||\n        block.type === \"video\" ||\n        block.type === \"file\" ||\n        block.type === \"page\")\n    ) {\n      const source =\n        block.type === \"page\"\n          ? block.format?.page_cover\n          : block.properties?.source?.[0]?.[0];\n\n      if (source) {\n        if (\n          source.includes(\"secure.notion-static.com\") ||\n          source.includes(\"prod-files-secure\") ||\n          source.includes(\"attachment:\")\n        ) {\n          return {\n            permissionRecord: {\n              table: \"block\",\n              id: block.id,\n            },\n            url: source,\n          };\n        }\n      }\n    }\n\n    return [];\n  });\n\n  if (allFileInstances.length > 0) {\n    try {\n      const { signedUrls } = await notion.getSignedFileUrls(allFileInstances);\n\n      if (signedUrls.length === allFileInstances.length) {\n        for (const [i, file] of allFileInstances.entries()) {\n          const signedUrl = signedUrls[i];\n          if (!signedUrl) continue;\n\n          const blockId = file.permissionRecord.id;\n          if (!blockId) continue;\n\n          recordMap.signed_urls[blockId] = signedUrl;\n        }\n      }\n    } catch (err) {\n      console.warn(\"NotionAPI getSignedfileUrls error\", err);\n    }\n  }\n};\n\n/**\n * Extracts page ID from custom Notion domain URLs\n * For custom domains, the page ID is typically embedded in the URL slug\n */\nexport function extractPageIdFromCustomNotionUrl(url: string): string | null {\n  try {\n    const urlObj = new URL(url);\n    const pathname = urlObj.pathname;\n\n    // Try robust parser first (handles hyphenated and plain IDs)\n    const parsed = parsePageId(url) || parsePageId(pathname);\n    if (parsed) return parsed;\n\n    // Fallback: match either plain 32-hex or hyphenated UUID-like Notion ID\n    const pageIdMatch = pathname.match(\n      /\\b(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\\b/i,\n    );\n    if (pageIdMatch) return parsePageId(pageIdMatch[0]) ?? pageIdMatch[0];\n\n    return null;\n  } catch {\n    return null;\n  }\n}\n\n/**\n * Check if a URL is potentially a custom Notion domain by attempting to extract a page ID\n * and verifying the page exists\n */\nexport async function isCustomNotionDomain(url: string): Promise<boolean> {\n  try {\n    const pageId = extractPageIdFromCustomNotionUrl(url);\n    if (!pageId) {\n      return false;\n    }\n\n    // Try to fetch the page to verify it exists and is accessible\n    await notion.getPage(pageId);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nexport async function getNotionPageIdFromSlug(\n  url: string,\n): Promise<string | null> {\n  // Parse the URL to extract domain and slug\n  const urlObj = new URL(url);\n  const hostname = urlObj.hostname;\n\n  const isNotionSo = hostname === \"www.notion.so\" || hostname === \"notion.so\";\n  const isNotionSite = hostname.endsWith(\".notion.site\");\n\n  // notion.so: extract ID from path directly\n  if (isNotionSo) {\n    const pageId = parsePageId(url) ?? parsePageId(urlObj.pathname);\n    if (pageId) return pageId;\n    throw new Error(`Unable to extract page ID from Notion URL: ${url}`);\n  }\n\n  // Custom domains (non notion.site, non notion.so)\n  if (!isNotionSite) {\n    const pageId = extractPageIdFromCustomNotionUrl(url);\n    if (pageId) {\n      // Verify the page exists before returning the ID\n      try {\n        await notion.getPage(pageId);\n        return pageId;\n      } catch {\n        throw new Error(`Custom Notion domain page not accessible: ${url}`);\n      }\n    }\n    throw new Error(`Unable to extract page ID from custom domain URL: ${url}`);\n  }\n\n  // Extract domain from hostname (e.g., \"domain\" from \"domain.notion.site\")\n  const domainMatch = hostname.match(/^([^.]+)\\.notion\\.site$/);\n  if (!domainMatch) {\n    throw new Error(`Invalid Notion site URL format: ${url}`);\n  }\n\n  const spaceDomain = domainMatch[1];\n\n  // Extract slug from pathname (remove leading slash)\n  // If slug is missing, we will try to get page just by using spaceDomain\n  let slug = urlObj.pathname.substring(1) || \"\";\n\n  // Make request to Notion's internal API\n  const apiUrl = `https://${spaceDomain}.notion.site/api/v3/getPublicPageDataForDomain`;\n  const payload = {\n    type: \"block-space\",\n    name: \"page\",\n    slug: slug,\n    spaceDomain: spaceDomain,\n    requestedOnPublicDomain: true,\n  };\n\n  const response = await fetch(apiUrl, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      \"User-Agent\": \"Mozilla/5.0 (compatible; MyApp/1.0)\",\n    },\n    body: JSON.stringify(payload),\n  });\n\n  if (!response.ok) {\n    throw new Error(\n      `Notion API request failed: ${response.status} ${response.statusText}`,\n    );\n  }\n\n  const data = await response.json();\n\n  if (data.pageId) {\n    return data.pageId;\n  } else {\n    throw new Error(\"No pageId found in Notion API response\");\n  }\n}\n"
  },
  {
    "path": "lib/openai.ts",
    "content": "import OpenAI from \"openai\";\n\n// Create an OpenAI API client (that's edge friendly!)\nexport const openai = new OpenAI({\n  apiKey: process.env.OPENAI_API_KEY || \"\",\n});\n"
  },
  {
    "path": "lib/posthog.ts",
    "content": "export function getPostHogConfig(): { key: string; host: string } | null {\n  const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;\n  const postHogHost = `${process.env.NEXT_PUBLIC_BASE_URL}/ingest`;\n\n  if (!postHogKey || !postHogHost) {\n    return null;\n  }\n\n  return {\n    key: postHogKey,\n    host: postHogHost,\n  };\n}\n"
  },
  {
    "path": "lib/prisma.ts",
    "content": "import { PrismaClient } from \"@prisma/client\";\n\ndeclare global {\n  var prisma: PrismaClient | undefined;\n}\n\nconst prisma = global.prisma || new PrismaClient();\n\nif (process.env.NODE_ENV === \"development\") global.prisma = prisma;\n\nexport default prisma;\n"
  },
  {
    "path": "lib/redis/dataroom-notification-queue.ts",
    "content": "import { redis } from \"@/lib/redis\";\n\nconst ITEM_TTL_SECONDS = 8 * 24 * 60 * 60; // 8 days\n\ntype QueueItem = {\n  dataroomDocumentId: string;\n  senderUserId: string;\n  queuedAt: number;\n};\n\ntype DigestViewerEntry = {\n  viewerId: string;\n  dataroomId: string;\n  teamId: string;\n};\n\nfunction itemsKey(viewerId: string, dataroomId: string) {\n  return `dataroom_digest_items:${viewerId}:${dataroomId}`;\n}\n\nfunction viewerSetKey(frequency: \"daily\" | \"weekly\") {\n  return `dataroom_digest_viewers:${frequency}`;\n}\n\nfunction encodeViewerEntry(entry: DigestViewerEntry): string {\n  return `${entry.viewerId}:${entry.dataroomId}:${entry.teamId}`;\n}\n\nfunction decodeViewerEntry(encoded: string): DigestViewerEntry {\n  const [viewerId, dataroomId, teamId] = encoded.split(\":\");\n  return { viewerId, dataroomId, teamId };\n}\n\nexport async function queueNotification({\n  frequency,\n  viewerId,\n  dataroomId,\n  teamId,\n  dataroomDocumentId,\n  senderUserId,\n}: {\n  frequency: \"daily\" | \"weekly\";\n  viewerId: string;\n  dataroomId: string;\n  teamId: string;\n  dataroomDocumentId: string;\n  senderUserId: string;\n}) {\n  const key = itemsKey(viewerId, dataroomId);\n  const item: QueueItem = {\n    dataroomDocumentId,\n    senderUserId,\n    queuedAt: Date.now(),\n  };\n\n  const pipeline = redis.pipeline();\n  pipeline.rpush(key, JSON.stringify(item));\n  pipeline.expire(key, ITEM_TTL_SECONDS);\n  pipeline.sadd(\n    viewerSetKey(frequency),\n    encodeViewerEntry({ viewerId, dataroomId, teamId }),\n  );\n  await pipeline.exec();\n}\n\nexport type DigestBatch = {\n  viewerId: string;\n  dataroomId: string;\n  teamId: string;\n  items: QueueItem[];\n};\n\nexport async function popDigestQueue(\n  frequency: \"daily\" | \"weekly\",\n): Promise<DigestBatch[]> {\n  const setKey = viewerSetKey(frequency);\n\n  const members = await redis.smembers(setKey);\n  if (!members || members.length === 0) return [];\n\n  // Remove the entire set atomically\n  await redis.del(setKey);\n\n  const batches: DigestBatch[] = [];\n\n  for (const member of members) {\n    const entry = decodeViewerEntry(member);\n    const key = itemsKey(entry.viewerId, entry.dataroomId);\n\n    // Get all items then delete the list\n    const rawItems = await redis.lrange(key, 0, -1);\n    await redis.del(key);\n\n    if (!rawItems || rawItems.length === 0) continue;\n\n    const items: QueueItem[] = rawItems\n      .map((raw) => {\n        try {\n          return typeof raw === \"string\"\n            ? (JSON.parse(raw) as QueueItem)\n            : (raw as QueueItem);\n        } catch (error) {\n          console.warn(\n            `[dataroom-digest] Skipping corrupted queue item for viewer=${entry.viewerId} dataroom=${entry.dataroomId}:`,\n            { raw, error },\n          );\n          return null;\n        }\n      })\n      .filter((item): item is QueueItem => item !== null);\n\n    batches.push({\n      viewerId: entry.viewerId,\n      dataroomId: entry.dataroomId,\n      teamId: entry.teamId,\n      items,\n    });\n  }\n\n  return batches;\n}\n"
  },
  {
    "path": "lib/redis-download-job-store.ts",
    "content": "import { nanoid } from \"@/lib/utils\";\n\nimport { redis } from \"./redis\";\n\nexport type DownloadJobStatus =\n  | \"PENDING\"\n  | \"PROCESSING\"\n  | \"COMPLETED\"\n  | \"FAILED\";\n\nexport interface DownloadJob {\n  id: string;\n  type: \"bulk\" | \"folder\";\n  status: DownloadJobStatus;\n  dataroomId: string;\n  dataroomName: string;\n  totalFiles: number;\n  processedFiles: number;\n  progress: number; // 0-100\n  downloadUrls?: string[]; // Multiple ZIPs if large (S3 presigned URLs auto-expire)\n  downloadS3Keys?: { bucket: string; key: string; region: string }[]; // S3 object references for on-demand presigning\n  error?: string;\n  teamId: string;\n  userId: string;\n  /** Viewer download: link this job belongs to */\n  linkId?: string;\n  /** Viewer download: viewer record id */\n  viewerId?: string;\n  /** Viewer download: email used for job list and email notification */\n  viewerEmail?: string;\n  triggerRunId?: string; // Trigger.dev run ID for cancellation\n  createdAt: string;\n  updatedAt: string;\n  completedAt?: string;\n  expiresAt?: string; // When download links expire\n  emailNotification?: boolean;\n  emailAddress?: string;\n  /** Folder download: name of the folder (when type === \"folder\") */\n  folderName?: string;\n}\n\nconst JOB_PREFIX = \"download_job:\";\nconst TEAM_JOBS_PREFIX = \"team_download_jobs:\";\nconst VIEWER_JOBS_PREFIX = \"viewer_download_jobs:\";\nconst JOB_TTL = 60 * 60 * 24 * 3; // 3 days - Redis handles cleanup via TTL\n\nexport class RedisDownloadJobStore {\n  private getJobKey(jobId: string): string {\n    return `${JOB_PREFIX}${jobId}`;\n  }\n\n  private getTeamJobsKey(teamId: string): string {\n    return `${TEAM_JOBS_PREFIX}${teamId}`;\n  }\n\n  private getViewerJobsKey(linkId: string, viewerEmail: string): string {\n    return `${VIEWER_JOBS_PREFIX}${linkId}:${viewerEmail.toLowerCase()}`;\n  }\n\n  async createJob(\n    jobData: Omit<DownloadJob, \"id\" | \"createdAt\" | \"updatedAt\">,\n  ): Promise<DownloadJob> {\n    const jobId = nanoid();\n    const now = new Date().toISOString();\n\n    const job: DownloadJob = {\n      ...jobData,\n      id: jobId,\n      createdAt: now,\n      updatedAt: now,\n    };\n\n    const jobKey = this.getJobKey(jobId);\n    const teamJobsKey = this.getTeamJobsKey(jobData.teamId);\n\n    // Store job data with TTL (Redis handles cleanup)\n    await redis.setex(jobKey, JOB_TTL, JSON.stringify(job));\n\n    // Add to team's job list (sorted by creation time)\n    await redis.zadd(teamJobsKey, { score: Date.now(), member: jobId });\n    await redis.expire(teamJobsKey, JOB_TTL);\n\n    // If viewer download, add to viewer index for getViewerJobs\n    if (jobData.linkId && jobData.viewerEmail) {\n      const viewerJobsKey = this.getViewerJobsKey(\n        jobData.linkId,\n        jobData.viewerEmail,\n      );\n      await redis.zadd(viewerJobsKey, { score: Date.now(), member: jobId });\n      await redis.expire(viewerJobsKey, JOB_TTL);\n    }\n\n    return job;\n  }\n\n  async getJob(jobId: string): Promise<DownloadJob | null> {\n    const jobKey = this.getJobKey(jobId);\n    const jobData = await redis.get(jobKey);\n\n    if (!jobData) {\n      return null;\n    }\n\n    try {\n      // Check if data is already an object (Redis client auto-parsed)\n      if (typeof jobData === \"object\") {\n        return jobData as DownloadJob;\n      }\n      // Otherwise parse the JSON string\n      return JSON.parse(jobData as string);\n    } catch (error) {\n      console.error(\"Error parsing download job data:\", error);\n      return null;\n    }\n  }\n\n  async updateJob(\n    jobId: string,\n    updates: Partial<DownloadJob>,\n  ): Promise<DownloadJob | null> {\n    const job = await this.getJob(jobId);\n    if (!job) {\n      return null;\n    }\n\n    const updatedJob: DownloadJob = {\n      ...job,\n      ...updates,\n      updatedAt: new Date().toISOString(),\n    };\n\n    const jobKey = this.getJobKey(jobId);\n    await redis.setex(jobKey, JOB_TTL, JSON.stringify(updatedJob));\n\n    return updatedJob;\n  }\n\n  private async getTeamJobs(\n    teamId: string,\n    limit: number = 20,\n  ): Promise<DownloadJob[]> {\n    const teamJobsKey = this.getTeamJobsKey(teamId);\n\n    // Get job IDs sorted by creation time (newest first)\n    const jobIds = await redis.zrange(teamJobsKey, 0, limit - 1, { rev: true });\n\n    if (!jobIds.length) {\n      return [];\n    }\n\n    // Get all job data\n    const jobs = await Promise.all(\n      (jobIds as string[]).map((jobId: string) => this.getJob(jobId)),\n    );\n\n    // Filter out null values (expired jobs)\n    return jobs.filter(\n      (job: DownloadJob | null): job is DownloadJob => job !== null,\n    );\n  }\n\n  async getDataroomJobs(\n    dataroomId: string,\n    teamId: string,\n    limit: number = 10,\n  ): Promise<DownloadJob[]> {\n    const teamJobs = await this.getTeamJobs(teamId, limit * 2); // Get more to filter\n\n    // Filter jobs by dataroom\n    return teamJobs\n      .filter((job) => {\n        const matchesDataroom = job.dataroomId === dataroomId;\n        const matchStatus = job.status !== \"FAILED\";\n        return matchesDataroom && matchStatus;\n      })\n      .slice(0, limit);\n  }\n\n  /**\n   * List download jobs for a viewer (link + email). Used on viewer downloads page.\n   */\n  async getViewerJobs(\n    linkId: string,\n    viewerEmail: string,\n    limit: number = 20,\n  ): Promise<DownloadJob[]> {\n    const viewerJobsKey = this.getViewerJobsKey(linkId, viewerEmail);\n    const jobIds = await redis.zrange(viewerJobsKey, 0, limit - 1, {\n      rev: true,\n    });\n\n    if (!jobIds.length) {\n      return [];\n    }\n\n    const jobs = await Promise.all(\n      (jobIds as string[]).map((jobId: string) => this.getJob(jobId)),\n    );\n\n    return jobs.filter(\n      (job: DownloadJob | null): job is DownloadJob => job !== null,\n    );\n  }\n}\n\nexport const downloadJobStore = new RedisDownloadJobStore();\n"
  },
  {
    "path": "lib/redis-job-store.ts",
    "content": "import { nanoid } from \"@/lib/utils\";\n\nimport { redis } from \"./redis\";\n\nexport type ExportJobStatus = \"PENDING\" | \"PROCESSING\" | \"COMPLETED\" | \"FAILED\";\n\nexport interface ExportJob {\n  id: string;\n  type: \"document\" | \"dataroom\" | \"dataroom-group\";\n  status: ExportJobStatus;\n  resourceId: string;\n  resourceName?: string;\n  groupId?: string;\n  result?: string; // CSV data or blob URL\n  error?: string;\n  userId: string;\n  teamId: string;\n  triggerRunId?: string; // Trigger.dev run ID for cancellation\n  createdAt: string;\n  updatedAt: string;\n  completedAt?: string;\n  emailNotification?: boolean;\n  emailAddress?: string;\n}\n\nexport interface ExportJobCleanupItem {\n  blobUrl: string;\n  jobId: string;\n  scheduledAt: string;\n}\n\nconst JOB_PREFIX = \"export_job:\";\nconst USER_JOBS_PREFIX = \"user_jobs:\";\nconst TEAM_JOBS_PREFIX = \"team_jobs:\";\nconst CLEANUP_QUEUE_PREFIX = \"cleanup_blobs:\";\nconst JOB_TTL = 60 * 60 * 24 * 3; // 3 days\n\nexport class RedisJobStore {\n  private getJobKey(jobId: string): string {\n    return `${JOB_PREFIX}${jobId}`;\n  }\n\n  private getUserJobsKey(userId: string): string {\n    return `${USER_JOBS_PREFIX}${userId}`;\n  }\n\n  private getTeamJobsKey(teamId: string): string {\n    return `${TEAM_JOBS_PREFIX}${teamId}`;\n  }\n\n  private getCleanupQueueKey(): string {\n    return `${CLEANUP_QUEUE_PREFIX}pending`;\n  }\n\n  async createJob(\n    jobData: Omit<ExportJob, \"id\" | \"createdAt\" | \"updatedAt\">,\n  ): Promise<ExportJob> {\n    const jobId = nanoid();\n    const now = new Date().toISOString();\n\n    const job: ExportJob = {\n      ...jobData,\n      id: jobId,\n      createdAt: now,\n      updatedAt: now,\n    };\n\n    const jobKey = this.getJobKey(jobId);\n    const userJobsKey = this.getUserJobsKey(jobData.userId);\n    const teamJobsKey = this.getTeamJobsKey(jobData.teamId);\n\n    // Store job data with TTL\n    await redis.setex(jobKey, JOB_TTL, JSON.stringify(job));\n\n    // Add to user's job list (sorted by creation time)\n    await redis.zadd(userJobsKey, { score: Date.now(), member: jobId });\n    await redis.expire(userJobsKey, JOB_TTL);\n\n    // Add to team's job list (sorted by creation time)\n    await redis.zadd(teamJobsKey, { score: Date.now(), member: jobId });\n    await redis.expire(teamJobsKey, JOB_TTL);\n\n    return job;\n  }\n\n  async getJob(jobId: string): Promise<ExportJob | null> {\n    const jobKey = this.getJobKey(jobId);\n    const jobData = await redis.get(jobKey);\n\n    if (!jobData) {\n      return null;\n    }\n\n    try {\n      // Check if data is already an object (Redis client auto-parsed)\n      if (typeof jobData === \"object\") {\n        return jobData as ExportJob;\n      }\n      // Otherwise parse the JSON string\n      return JSON.parse(jobData as string);\n    } catch (error) {\n      console.error(\"Error parsing job data:\", error);\n      return null;\n    }\n  }\n\n  async updateJob(\n    jobId: string,\n    updates: Partial<ExportJob>,\n  ): Promise<ExportJob | null> {\n    const job = await this.getJob(jobId);\n    if (!job) {\n      return null;\n    }\n\n    const updatedJob: ExportJob = {\n      ...job,\n      ...updates,\n      updatedAt: new Date().toISOString(),\n    };\n\n    const jobKey = this.getJobKey(jobId);\n    await redis.setex(jobKey, JOB_TTL, JSON.stringify(updatedJob));\n\n    // If this update includes a blob URL, schedule it for cleanup\n    if (updates.result?.startsWith(\"https://\")) {\n      await this.scheduleBlobForCleanup(updates.result, jobId);\n    }\n\n    return updatedJob;\n  }\n\n  async scheduleBlobForCleanup(blobUrl: string, jobId: string): Promise<void> {\n    const cleanupTime = Date.now() + JOB_TTL * 1000; // Convert to milliseconds\n    const cleanupQueueKey = this.getCleanupQueueKey();\n\n    // Store blob URL with cleanup timestamp\n    await redis.zadd(cleanupQueueKey, {\n      score: cleanupTime,\n      member: JSON.stringify({\n        blobUrl,\n        jobId,\n        scheduledAt: new Date().toISOString(),\n      }),\n    });\n  }\n\n  async getBlobsForCleanup(\n    beforeTimestamp?: number,\n  ): Promise<Array<ExportJobCleanupItem>> {\n    const cleanupQueueKey = this.getCleanupQueueKey();\n    const maxScore = beforeTimestamp || Date.now();\n\n    // Get all items scheduled for cleanup before the specified timestamp\n    const items = await redis.zrange(cleanupQueueKey, 0, maxScore, {\n      byScore: true,\n    });\n\n    const blobs: Array<ExportJobCleanupItem> = [];\n\n    for (const item of items) {\n      try {\n        let parsed: ExportJobCleanupItem;\n        // Check if data is already an object (Redis client auto-parsed)\n        if (typeof item === \"object\" && item !== null) {\n          parsed = item as ExportJobCleanupItem;\n        } else {\n          // Otherwise parse the JSON string\n          parsed = JSON.parse(item as string);\n        }\n        blobs.push(parsed);\n      } catch (error) {\n        console.error(\"Error parsing cleanup item:\", error);\n      }\n    }\n\n    return blobs;\n  }\n\n  async removeBlobFromCleanupQueue(\n    blobUrl: string,\n    jobId: string,\n  ): Promise<void> {\n    const cleanupQueueKey = this.getCleanupQueueKey();\n    const itemToRemove = JSON.stringify({\n      blobUrl,\n      jobId,\n      scheduledAt: new Date().toISOString(),\n    });\n\n    // Remove the specific item from the cleanup queue\n    await redis.zrem(cleanupQueueKey, itemToRemove);\n  }\n\n  async deleteJob(jobId: string): Promise<boolean> {\n    const job = await this.getJob(jobId);\n    if (!job) {\n      return false;\n    }\n\n    const jobKey = this.getJobKey(jobId);\n    const userJobsKey = this.getUserJobsKey(job.userId);\n    const teamJobsKey = this.getTeamJobsKey(job.teamId);\n\n    // Remove from all locations\n    await Promise.all([\n      redis.del(jobKey),\n      redis.zrem(userJobsKey, jobId),\n      redis.zrem(teamJobsKey, jobId),\n    ]);\n\n    return true;\n  }\n\n  async getUserJobs(userId: string, limit: number = 20): Promise<ExportJob[]> {\n    const userJobsKey = this.getUserJobsKey(userId);\n\n    // Get job IDs sorted by creation time (newest first)\n    const jobIds = await redis.zrange(userJobsKey, 0, limit - 1, { rev: true });\n\n    if (!jobIds.length) {\n      return [];\n    }\n\n    // Get all job data\n    const jobs = await Promise.all(\n      (jobIds as string[]).map((jobId: string) => this.getJob(jobId)),\n    );\n\n    // Filter out null values (deleted jobs)\n    return jobs.filter(\n      (job: ExportJob | null): job is ExportJob => job !== null,\n    );\n  }\n\n  async getTeamJobs(teamId: string, limit: number = 20): Promise<ExportJob[]> {\n    const teamJobsKey = this.getTeamJobsKey(teamId);\n\n    // Get job IDs sorted by creation time (newest first)\n    const jobIds = await redis.zrange(teamJobsKey, 0, limit - 1, { rev: true });\n\n    if (!jobIds.length) {\n      return [];\n    }\n\n    // Get all job data\n    const jobs = await Promise.all(\n      (jobIds as string[]).map((jobId: string) => this.getJob(jobId)),\n    );\n\n    // Filter out null values (deleted jobs)\n    return jobs.filter(\n      (job: ExportJob | null): job is ExportJob => job !== null,\n    );\n  }\n\n  async getResourceJobs(\n    resourceId: string,\n    teamId: string,\n    type?: \"document\" | \"dataroom\" | \"dataroom-group\",\n    groupId?: string,\n    limit: number = 10,\n  ): Promise<ExportJob[]> {\n    const teamJobs = await this.getTeamJobs(teamId, limit * 2); // Get more to filter\n\n    // Filter jobs by resource and type\n    return teamJobs\n      .filter((job) => {\n        const matchesResource = job.resourceId === resourceId;\n        const matchesType = !type || job.type === type;\n        const matchesGroup = !groupId || job.groupId === groupId;\n        const matchStatus = job.status !== \"FAILED\";\n        return matchesResource && matchesType && matchesGroup && matchStatus;\n      })\n      .slice(0, limit);\n  }\n\n  async getUserTeamJobs(\n    userId: string,\n    teamId: string,\n    limit: number = 20,\n  ): Promise<ExportJob[]> {\n    const userJobs = await this.getUserJobs(userId, limit * 2); // Get more to filter\n\n    // Filter jobs by team\n    return userJobs.filter((job) => job.teamId === teamId).slice(0, limit);\n  }\n\n  async cleanupExpiredJobs(): Promise<void> {\n    // This would be called by a cron job or cleanup task\n    // For now, we rely on Redis TTL for automatic cleanup\n  }\n}\n\nexport const jobStore = new RedisJobStore();\n"
  },
  {
    "path": "lib/redis.ts",
    "content": "import { Ratelimit } from \"@upstash/ratelimit\";\nimport { Redis } from \"@upstash/redis\";\n\nexport const redis = new Redis({\n  url: process.env.UPSTASH_REDIS_REST_URL as string,\n  token: process.env.UPSTASH_REDIS_REST_TOKEN as string,\n});\n\nexport const lockerRedisClient = new Redis({\n  url: process.env.UPSTASH_REDIS_REST_LOCKER_URL as string,\n  token: process.env.UPSTASH_REDIS_REST_LOCKER_TOKEN as string,\n});\n\n// Create a new ratelimiter, that allows 10 requests per 10 seconds by default\nexport const ratelimit = (\n  requests: number = 10,\n  seconds:\n    | `${number} ms`\n    | `${number} s`\n    | `${number} m`\n    | `${number} h`\n    | `${number} d` = \"10 s\",\n) => {\n  return new Ratelimit({\n    redis: redis,\n    limiter: Ratelimit.slidingWindow(requests, seconds),\n    analytics: true,\n    prefix: \"papermark\",\n  });\n};\n"
  },
  {
    "path": "lib/resend.ts",
    "content": "import { JSXElementConstructor, ReactElement } from \"react\";\n\nimport { render, toPlainText } from \"@react-email/render\";\nimport { Resend } from \"resend\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log, nanoid } from \"@/lib/utils\";\n\nexport const resend = process.env.RESEND_API_KEY\n  ? new Resend(process.env.RESEND_API_KEY)\n  : null;\n\nexport const sendEmail = async ({\n  to,\n  subject,\n  react,\n  from,\n  marketing,\n  system,\n  verify,\n  test,\n  cc,\n  replyTo,\n  scheduledAt,\n  unsubscribeUrl,\n}: {\n  to: string;\n  subject: string;\n  react: ReactElement<any, string | JSXElementConstructor<any>>;\n  from?: string;\n  marketing?: boolean;\n  system?: boolean;\n  verify?: boolean;\n  test?: boolean;\n  cc?: string | string[];\n  replyTo?: string;\n  scheduledAt?: string;\n  unsubscribeUrl?: string;\n}) => {\n  if (!resend) {\n    // Throw an error if resend is not initialized\n    throw new Error(\"Resend not initialized\");\n  }\n\n  const html = await render(react);\n  const plainText = toPlainText(html);\n\n  const fromAddress =\n    from ??\n    (marketing\n      ? \"Marc from Papermark <marc@updates.papermark.com>\"\n      : system\n        ? \"Papermark <system@papermark.com>\"\n        : verify\n          ? \"Papermark <system@verify.papermark.com>\"\n          : !!scheduledAt\n            ? \"Marc Seitz <marc@papermark.com>\"\n            : \"Marc from Papermark <marc@papermark.com>\");\n\n  try {\n    const { data, error } = await resend.emails.send({\n      from: fromAddress,\n      to: test ? \"delivered@resend.dev\" : to,\n      cc: cc,\n      replyTo: marketing ? \"marc@papermark.com\" : replyTo,\n      subject,\n      react,\n      scheduledAt,\n      text: plainText,\n      headers: {\n        \"X-Entity-Ref-ID\": nanoid(),\n        ...(unsubscribeUrl\n          ? {\n              \"List-Unsubscribe\": `<${unsubscribeUrl}>`,\n              \"List-Unsubscribe-Post\": \"List-Unsubscribe=One-Click\",\n            }\n          : {}),\n      },\n    });\n\n    // Check if the email sending operation returned an error and throw it\n    if (error) {\n      log({\n        message: `Resend returned error when sending email: ${error.name} \\n\\n ${error.message}`,\n        type: \"error\",\n        mention: true,\n      });\n      throw error;\n    }\n\n    // If there's no error, return the data\n    return data;\n  } catch (exception) {\n    // Log and rethrow any caught exceptions for upstream handling\n    log({\n      message: `Unexpected error when sending email: ${exception}`,\n      type: \"error\",\n      mention: true,\n    });\n    throw exception; // Rethrow the caught exception\n  }\n};\n\nexport const subscribe = async (email: string): Promise<void> => {\n  if (!resend) {\n    console.error(\"RESEND_API_KEY is not set in the .env. Skipping.\");\n    return;\n  }\n\n  const { data, error } = await resend.contacts.create({\n    email,\n  });\n\n  if (error || !data?.id) {\n    console.error(\"Failed to create contact:\", error);\n    return;\n  }\n\n  if (\n    process.env.NODE_ENV === \"production\" &&\n    process.env.RESEND_MARKETING_SEGMENT_ID\n  ) {\n    await resend.contacts.segments.add({\n      contactId: data.id,\n      segmentId: process.env.RESEND_MARKETING_SEGMENT_ID as string,\n    });\n  }\n};\n\nexport const unsubscribe = async (email: string): Promise<void> => {\n  if (!resend) {\n    console.error(\"RESEND_API_KEY is not set in the .env. Skipping.\");\n    return;\n  }\n\n  if (!email) {\n    return;\n  }\n\n  const user = await prisma.user.findUnique({\n    where: { email },\n    select: { email: true },\n  });\n\n  if (!user || !user.email) {\n    return;\n  }\n\n  await resend.contacts.update({\n    email: user.email,\n    unsubscribed: true,\n  });\n};\n"
  },
  {
    "path": "lib/sheet/index.ts",
    "content": "import * as XLSX from \"xlsx\";\n\ntype RowData = { [key: string]: any };\ntype SheetData = {\n  sheetName: string;\n  columnData: string[];\n  rowData: RowData[];\n};\n\n// Custom sort function to sort keys A, B, .. Z, AA, AB, ..\nconst customSort = (a: string, b: string) => {\n  if (a.length === b.length) {\n    return a.localeCompare(b);\n  }\n  return a.length - b.length;\n};\n\nexport const parseSheet = async ({ fileUrl }: { fileUrl: string }) => {\n  const response = await fetch(fileUrl);\n  const arrayBuffer = await response.arrayBuffer();\n  const data = new Uint8Array(arrayBuffer);\n  const workbook = XLSX.read(data, { type: \"array\" });\n\n  const result: SheetData[] = [];\n\n  // Iterate through all sheets in the workbook\n  workbook.SheetNames.forEach((sheetName) => {\n    const worksheet = workbook.Sheets[sheetName];\n    const json: RowData[] = XLSX.utils.sheet_to_json(worksheet, {\n      header: \"A\",\n    });\n\n    // Collect all unique keys from the JSON data\n    const allKeys = Array.from(new Set(json.flatMap(Object.keys)));\n\n    // Sort the keys alphabetically\n    allKeys.sort(customSort);\n\n    // Ensure each row has the same set of keys\n    const normalizedData = json.map((row) => {\n      const normalizedRow: RowData = {};\n      allKeys.forEach((key) => {\n        normalizedRow[key] = row[key] || \"\";\n      });\n      return normalizedRow;\n    });\n\n    // Store column and row data for the current sheet\n    result.push({\n      sheetName,\n      columnData: allKeys,\n      rowData: normalizedData,\n    });\n  });\n\n  return result;\n};\n"
  },
  {
    "path": "lib/swr/use-agreements.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { Agreement } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport interface AgreementWithLinksCount extends Agreement {\n  _count: {\n    links: number;\n  };\n}\n\nexport function useAgreements() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: agreements, error } = useSWR<AgreementWithLinksCount[]>(\n    teamId && `/api/teams/${teamId}/agreements`,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    agreements: agreements || [],\n    loading: !agreements && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-annotations.ts",
    "content": "import useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport interface Annotation {\n  id: string;\n  title: string;\n  content: any;\n  pages: number[];\n  isVisible: boolean;\n  createdAt: string;\n  updatedAt: string;\n  createdBy: {\n    id: string;\n    name: string | null;\n    email: string | null;\n  };\n  images: {\n    id: string;\n    filename: string;\n    url: string;\n    size: number | null;\n    mimeType: string | null;\n    createdAt: string;\n  }[];\n}\n\nexport function useAnnotations(documentId: string, teamId: string) {\n  const { data, error, mutate } = useSWR<Annotation[]>(\n    `/api/teams/${teamId}/documents/${documentId}/annotations`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n    },\n  );\n\n  return {\n    annotations: data,\n    loading: !error && !data,\n    error,\n    mutate,\n  };\n}\n\nexport function useViewerAnnotations(\n  linkId: string,\n  documentId?: string,\n  viewId?: string,\n) {\n  // Don't fetch annotations if viewId is not available\n  // This prevents 404 errors when the view hasn't been created yet\n  const shouldFetch = !!viewId;\n\n  // For dataroom document links, use the specific document endpoint\n  // For regular document links, use the general link endpoint\n  const endpoint = shouldFetch\n    ? documentId\n      ? `/api/links/${linkId}/documents/${documentId}/annotations?viewId=${viewId}`\n      : `/api/links/${linkId}/annotations?viewId=${viewId}`\n    : null;\n\n  const { data, error, mutate } = useSWR<Omit<Annotation, \"createdBy\">[]>(\n    endpoint,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n    },\n  );\n\n  return {\n    annotations: data,\n    loading: shouldFetch && !error && !data,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-billing.ts",
    "content": "import { useMemo } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PLAN_NAME_MAP } from \"@/ee/stripe/constants\";\nimport { SubscriptionDiscount } from \"@/ee/stripe/functions/get-subscription-item\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ninterface BillingProps {\n  id: string;\n  plan: string;\n  startsAt: Date | null;\n  endsAt: Date | null;\n  subscriptionId: string | null;\n  _count: {\n    documents: number;\n  };\n}\n\nexport function useBilling() {\n  const teamInfo = useTeam();\n\n  const { data, error } = useSWR<BillingProps>(\n    teamInfo?.currentTeam && `/api/teams/${teamInfo.currentTeam.id}/billing`,\n    fetcher,\n    {\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    ...data,\n    error,\n    loading: !data && !error,\n  };\n}\n\nexport type BasePlan =\n  | \"free\"\n  | \"starter\"\n  | \"pro\"\n  | \"trial\"\n  | \"business\"\n  | \"datarooms\"\n  | \"datarooms-plus\"\n  | \"datarooms-premium\";\n\ntype PlanWithTrial = `${BasePlan}+drtrial`;\ntype PlanWithOld = `${BasePlan}+old` | `${BasePlan}+drtrial+old`;\n\ntype PlanResponse = {\n  plan: BasePlan | PlanWithTrial | PlanWithOld;\n  startsAt: Date | null;\n  endsAt: Date | null;\n  pausedAt: Date | null;\n  pauseStartsAt: Date | null;\n  pauseEndsAt: Date | null;\n  isPaused: boolean;\n  cancelledAt: Date | null;\n  isCustomer: boolean;\n  subscriptionCycle: \"monthly\" | \"yearly\";\n  discount: SubscriptionDiscount | null;\n};\n\ninterface PlanDetails {\n  plan: BasePlan | null;\n  trial: string | null;\n  old: boolean;\n}\n\nfunction parsePlan(plan: BasePlan | PlanWithTrial | PlanWithOld): PlanDetails {\n  if (!plan || typeof plan !== \"string\") {\n    return { plan: null, trial: null, old: false };\n  }\n\n  try {\n    // Split the plan on '+'\n    const parts = plan.split(\"+\");\n    return {\n      plan: parts[0] as BasePlan, // Always the base plan\n      trial: parts.includes(\"drtrial\") ? \"drtrial\" : null, // 'drtrial' if present, otherwise null\n      old: parts.includes(\"old\"), // true if 'old' is present, otherwise false\n    };\n  } catch (error) {\n    console.error(\"Error parsing plan:\", error);\n    return { plan: null, trial: null, old: false };\n  }\n}\n\nexport function usePlan({\n  withDiscount = false,\n}: { withDiscount?: boolean } = {}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const {\n    data: plan,\n    error,\n    mutate,\n  } = useSWR<PlanResponse>(\n    teamId\n      ? `/api/teams/${teamId}/billing/plan${withDiscount ? \"?withDiscount=true\" : \"\"}`\n      : null,\n    fetcher,\n  );\n\n  // Parse the plan using the parsing function\n  const parsedPlan = useMemo(() => {\n    if (!plan || !plan.plan) {\n      return { plan: null, trial: null, old: false };\n    }\n    return parsePlan(plan.plan);\n  }, [plan]);\n\n  return {\n    plan: parsedPlan.plan ?? \"free\",\n    planName: PLAN_NAME_MAP[parsedPlan.plan ?? \"free\"],\n    originalPlan: parsedPlan.plan + (parsedPlan.old ? \"+old\" : \"\"),\n    trial: parsedPlan.trial,\n    isTrial: !!parsedPlan.trial,\n    isOldAccount: parsedPlan.old,\n    isCustomer: plan?.isCustomer,\n    isAnnualPlan: plan?.subscriptionCycle === \"yearly\",\n    startsAt: plan?.startsAt,\n    endsAt: plan?.endsAt,\n    cancelledAt: plan?.cancelledAt,\n    pausedAt: plan?.pausedAt,\n    isPaused: plan?.isPaused ?? false,\n    isCancelled: !!plan?.cancelledAt,\n    pauseStartsAt: plan?.pauseStartsAt,\n    pauseEndsAt: plan?.pauseEndsAt,\n    discount: plan?.discount || null,\n    isFree: parsedPlan.plan === \"free\",\n    isStarter: parsedPlan.plan === \"starter\",\n    isPro: parsedPlan.plan === \"pro\",\n    isBusiness: parsedPlan.plan === \"business\",\n    isDatarooms:\n      parsedPlan.plan === \"datarooms\" || parsedPlan.plan === \"datarooms-plus\" || parsedPlan.plan === \"datarooms-premium\",\n    isDataroomsPlus: parsedPlan.plan === \"datarooms-plus\" || parsedPlan.plan === \"datarooms-premium\",\n    isDataroomsPremium: parsedPlan.plan === \"datarooms-premium\",\n    loading: !plan && !error && !!teamId, // Only show loading if we have a teamId but no data\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-brand.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useMemo } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { Brand, DataroomBrand } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useBrand() {\n  const teamInfo = useTeam();\n\n  const { data: brand, error } = useSWR<Brand>(\n    teamInfo?.currentTeam?.id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/branding`,\n    fetcher,\n    {\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    brand,\n    error,\n    loading: !brand && !error,\n  };\n}\n\nexport function useDataroomBrand({\n  dataroomId,\n}: {\n  dataroomId: string | undefined;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: brand, error } = useSWR<DataroomBrand>(\n    teamId &&\n      dataroomId &&\n      `/api/teams/${teamId}/datarooms/${dataroomId}/branding`,\n    fetcher,\n    {\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    brand,\n    error,\n    loading: !brand && !error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-dataroom-document-stats.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { TStatsData } from \"@/lib/swr/use-stats\";\nimport { fetcher } from \"@/lib/utils\";\n\ntype TDataroomDocumentStats = TStatsData & {\n  totalPagesMax: number;\n};\n\nexport function useDataroomDocumentStats(\n  documentId: string | null | undefined,\n) {\n  const { currentTeamId: teamId } = useTeam();\n  const router = useRouter();\n  const { id: dataroomId } = router.query as { id: string };\n\n  const { data: stats, error } = useSWR<TDataroomDocumentStats>(\n    documentId && teamId && dataroomId\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}/documents/${encodeURIComponent(documentId)}/stats`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    stats,\n    loading: documentId ? !error && !stats : false,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-dataroom-groups.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  Link,\n  Viewer,\n  ViewerGroup,\n  ViewerGroupAccessControls,\n  ViewerGroupMembership,\n  ViewerInvitation,\n} from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport { LinkWithViews } from \"../types\";\n\nexport default function useDataroomGroups({ documentId }: { documentId?: string } = {}) {\n  const teamInfo = useTeam();\n  const router = useRouter();\n\n  const isDataroom = router.pathname.includes(\"datarooms\");\n  const { id } = router.query as {\n    id: string;\n  };\n\n  type ViewerGroupWithCount = ViewerGroup & {\n    accessControls: ViewerGroupAccessControls[];\n    _count: {\n      members: number;\n      views: number;\n    };\n  };\n\n  const {\n    data: viewerGroups,\n    error,\n    mutate,\n  } = useSWR<ViewerGroupWithCount[]>(\n    teamInfo?.currentTeam?.id &&\n      id &&\n      isDataroom &&\n    `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${id}/groups${documentId ? `?documentId=${documentId}` : \"\"\n    }`,\n    fetcher,\n    { dedupingInterval: 30000 },\n  );\n\n  return {\n    viewerGroups,\n    loading: !viewerGroups && !error,\n    error,\n    mutate,\n  };\n}\n\nexport function useDataroomGroupLinks() {\n  const router = useRouter();\n\n  const { id, groupId } = router.query as {\n    id: string;\n    groupId: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: links, error } = useSWR<LinkWithViews[]>(\n    teamId &&\n      id &&\n      `/api/teams/${teamId}/datarooms/${id}/groups/${groupId}/links`,\n    fetcher,\n    { dedupingInterval: 10000 },\n  );\n\n  return {\n    links,\n    loading: !error && !links,\n    error,\n  };\n}\n\ntype ViewerGroupWithMembers = ViewerGroup & {\n  members: (ViewerGroupMembership & { viewer: Viewer & { invitations?: ViewerInvitation[] } })[];\n  accessControls: ViewerGroupAccessControls[];\n};\n\nexport function useDataroomGroup() {\n  const router = useRouter();\n  const { id, groupId } = router.query as {\n    id: string;\n    groupId: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: viewerGroup, error } = useSWR<ViewerGroupWithMembers>(\n    teamId &&\n      id &&\n      groupId &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${id}/groups/${groupId}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    viewerGroup,\n    viewerGroupMembers: viewerGroup?.members ?? [],\n    viewerGroupDomains: viewerGroup?.domains ?? [],\n    viewerGroupAllowAll: viewerGroup?.allowAll ?? false,\n    viewerGroupPermissions: viewerGroup?.accessControls ?? [],\n    loading: !viewerGroup && !error,\n    error,\n  };\n}\n\nexport function useDataroomGroupPermissions() {\n  const router = useRouter();\n  const { id, groupId } = router.query as {\n    id: string;\n    groupId: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: viewerGroupPermissions, error } = useSWR<\n    ViewerGroupAccessControls[]\n  >(\n    teamId &&\n      id &&\n      groupId &&\n      `/api/teams/${teamId}/datarooms/${id}/groups/${groupId}/permissions`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    viewerGroupPermissions,\n    loading: !viewerGroupPermissions && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-dataroom-permission-groups.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PermissionGroup, PermissionGroupAccessControls } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport default function useDataroomPermissionGroups() {\n  const teamInfo = useTeam();\n  const router = useRouter();\n\n  const isDataroom = router.pathname.includes(\"datarooms\");\n  const { id } = router.query as {\n    id: string;\n  };\n\n  type PermissionGroupWithCount = PermissionGroup & {\n    accessControls: PermissionGroupAccessControls[];\n    links: {\n      id: string;\n      name: string | null;\n    }[];\n    _count: {\n      links: number;\n      accessControls: number;\n    };\n  };\n\n  const {\n    data: permissionGroups,\n    error,\n    mutate,\n  } = useSWR<PermissionGroupWithCount[]>(\n    teamInfo?.currentTeam?.id &&\n    id &&\n    isDataroom &&\n    `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${id}/permission-groups`,\n    fetcher,\n    { dedupingInterval: 30000 },\n  );\n\n  return {\n    permissionGroups,\n    loading: !permissionGroups && !error,\n    error,\n    mutate,\n  };\n} "
  },
  {
    "path": "lib/swr/use-dataroom-stats.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { View } from \"@prisma/client\";\nimport useSWR from \"swr\";\nimport useSWRImmutable from \"swr/immutable\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport type TDataroomStatsData = {\n  dataroomViews: View[];\n  documentViews: View[];\n  duration: {\n    viewId: string;\n    sum_duration: number;\n  }[];\n  total_duration: number;\n};\n\nexport function useDataroomStats({\n  excludeTeamMembers,\n}: { excludeTeamMembers?: boolean } = {}) {\n  // this gets the data for a document's graph of all views\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const { data: stats, error } = useSWR<TDataroomStatsData>(\n    id &&\n      teamId &&\n      `/api/teams/${teamId}/datarooms/${id}/stats${excludeTeamMembers ? \"?excludeTeamMembers=true\" : \"\"}`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    stats,\n    loading: !error && !stats,\n    error,\n  };\n}\n\n// interface StatsViewData {\n//   views: View[];\n//   duration: {\n//     data: { pageNumber: string; sum_duration: number }[];\n//   };\n// }\n\n// export function useVisitorStats(viewId: string) {\n//   // this gets the data for a single visitor's graph\n//   const router = useRouter();\n//   const teamInfo = useTeam();\n\n//   const { id: documentId } = router.query as {\n//     id: string;\n//   };\n\n//   const { data: stats, error } = useSWR<StatsViewData>(\n//     documentId &&\n//       viewId &&\n//       `/api/teams/${teamInfo?.currentTeam?.id}/documents/${encodeURIComponent(\n//         documentId,\n//       )}/views/${encodeURIComponent(viewId)}/stats`,\n//     fetcher,\n//     {\n//       dedupingInterval: 10000,\n//     },\n//   );\n\n//   return {\n//     stats,\n//     loading: !error && !stats,\n//     error,\n//   };\n// }\n\nexport function useDataroomVisitorUserAgent(viewId: string) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { id: dataroomId } = router.query as {\n    id: string;\n  };\n\n  const { data: userAgent, error } = useSWRImmutable<{\n    country: string;\n    city: string;\n    os: string;\n    browser: string;\n    device: string;\n  }>(\n    dataroomId &&\n      viewId &&\n      `/api/teams/${teamId}/datarooms/${dataroomId}/views/${viewId}/user-agent`,\n    fetcher,\n  );\n\n  return {\n    userAgent,\n    loading: !error && !userAgent,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-dataroom-view-document-stats.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport type DocumentViewStats = {\n  viewId: string;\n  documentId: string;\n  totalDuration: number;\n  pagesViewed: number;\n  totalPages: number;\n  completionRate: number;\n};\n\nexport function useDataroomViewDocumentStats({\n  dataroomId,\n  dataroomViewId,\n  enabled = false,\n}: {\n  dataroomId: string;\n  dataroomViewId: string;\n  enabled?: boolean;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const canFetch = !!(enabled && teamId && dataroomId && dataroomViewId);\n\n  const { data, error } = useSWR<{ documentStats: DocumentViewStats[] }>(\n    canFetch\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}/views/${dataroomViewId}/document-stats`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 30000,\n      revalidateOnFocus: false,\n    },\n  );\n\n  return {\n    documentStats: data?.documentStats,\n    loading: canFetch && !error && !data,\n    error,\n  };\n}\n\ntype PageDurationData = {\n  pageNumber: string;\n  sum_duration: number;\n};\n\nexport function useDataroomDocumentPageStats({\n  dataroomId,\n  dataroomViewId,\n  documentViewId,\n  documentId,\n  enabled = false,\n}: {\n  dataroomId: string;\n  dataroomViewId: string;\n  documentViewId: string;\n  documentId: string;\n  enabled?: boolean;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const canFetch = !!(\n    enabled && teamId && dataroomId && dataroomViewId && documentViewId && documentId\n  );\n\n  const { data, error } = useSWR<{ duration: { data: PageDurationData[] } }>(\n    canFetch\n      ? `/api/teams/${teamId}/datarooms/${dataroomId}/views/${dataroomViewId}/document-stats?documentViewId=${documentViewId}&documentId=${documentId}`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 30000,\n      revalidateOnFocus: false,\n    },\n  );\n\n  return {\n    duration: data?.duration,\n    loading: canFetch && !error && !data,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-dataroom.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useMemo } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { Dataroom, DataroomDocument, DataroomFolder } from \"@prisma/client\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { LinkWithViews } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\nimport { sortByIndexThenName } from \"@/lib/utils/sort-items-by-index-name\";\n\nexport type DataroomFolderWithCount = DataroomFolder & {\n  _count: {\n    documents: number;\n    childFolders: number;\n  };\n};\n\nexport function useDataroom() {\n  const router = useRouter();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  // Only make the API call if we're on a dataroom page\n  const isDataroomPage = router.pathname.startsWith(\"/datarooms\");\n  const shouldFetch = teamId && id && isDataroomPage;\n\n  const { data: dataroom, error } = useSWR<\n    Dataroom & {\n      _count?: { viewerGroups: number; permissionGroups: number };\n      tags?: {\n        tag: {\n          id: string;\n          name: string;\n          color: string;\n          description: string | null;\n        };\n      }[];\n    }\n  >(shouldFetch ? `/api/teams/${teamId}/datarooms/${id}` : null, fetcher, {\n    dedupingInterval: 10000,\n    onError: (err) => {\n      if (err.status === 404) {\n        toast.error(\"Dataroom not found\", {\n          description:\n            \"The dataroom you're looking for doesn't exist or has been moved.\",\n        });\n        router.replace(\"/datarooms\");\n      }\n    },\n  });\n\n  return {\n    dataroom,\n    loading: !error && !dataroom,\n    error,\n  };\n}\n\nexport function useDataroomLinks() {\n  const router = useRouter();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: links, error } = useSWR<LinkWithViews[]>(\n    teamId && id && `/api/teams/${teamId}/datarooms/${id}/links`,\n    fetcher,\n    { dedupingInterval: 10000 },\n  );\n\n  return {\n    links,\n    loading: !error && !links,\n    error,\n  };\n}\n\nexport function useDataroomItems({\n  root,\n  name,\n}: {\n  root?: boolean;\n  name?: string[];\n}) {\n  const router = useRouter();\n  const { id } = router.query as {\n    id: string;\n  };\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: folderData, error: folderError } = useSWR<\n    DataroomFolderWithCount[]\n  >(\n    teamId &&\n      id &&\n      `/api/teams/${teamId}/datarooms/${id}/folders${root ? \"?root=true\" : name ? `/${name.join(\"/\")}` : \"\"}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  const { data: documentData, error: documentError } = useSWR<\n    DataroomFolderDocument[]\n  >(\n    teamId &&\n      id &&\n      `/api/teams/${teamId}/datarooms/${id}${name ? `/folders/documents/${name.join(\"/\")}` : \"/documents\"}`,\n\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  const isLoading =\n    !folderData && !documentData && !folderError && !documentError;\n  const error = folderError || documentError;\n\n  const combinedItems = useMemo(() => {\n    if (!folderData && !documentData) return [];\n\n    const allItems = [\n      ...(folderData || []).map((folder) => ({\n        ...folder,\n        itemType: \"folder\",\n      })),\n      ...(documentData || []).map((doc) => ({ ...doc, itemType: \"document\" })),\n    ];\n    return sortByIndexThenName(allItems);\n  }, [folderData, documentData]);\n\n  return {\n    items: combinedItems as (\n      | (DataroomFolderWithCount & { itemType: \"folder\" })\n      | (DataroomFolderDocument & { itemType: \"document\" })\n    )[],\n    folderCount: folderData?.length || 0,\n    documentCount: documentData?.length || 0,\n    isLoading,\n    error,\n  };\n}\n\nexport function useDataroomDocuments() {\n  const router = useRouter();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: documents, error } = useSWR<DataroomFolderDocument[]>(\n    teamId && id && `/api/teams/${teamId}/datarooms/${id}/documents`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    documents,\n    loading: !documents && !error,\n    error,\n  };\n}\n\nexport function useDataroomFolders({\n  root,\n  name,\n}: {\n  root?: boolean;\n  name?: string[];\n}) {\n  const router = useRouter();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: folders, error } = useSWR<DataroomFolderWithCount[]>(\n    teamId &&\n      id &&\n      `/api/teams/${teamId}/datarooms/${id}/folders${root ? \"?root=true\" : name ? `/${name.join(\"/\")}` : \"\"}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    folders,\n    loading: !folders && !error,\n    error,\n  };\n}\n\nexport type DataroomFolderWithDocuments = DataroomFolder & {\n  childFolders: DataroomFolderWithDocuments[];\n  documents: {\n    orderIndex: number | null;\n    id: string;\n    folderId: string;\n    hierarchicalIndex: string | null;\n    document: {\n      id: string;\n      name: string;\n      type: string;\n    };\n  }[];\n};\n\nexport function useDataroomFoldersTree({\n  dataroomId,\n  include_documents,\n}: {\n  dataroomId: string;\n  include_documents?: boolean;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: folders, error } = useSWR<DataroomFolderWithDocuments[]>(\n    teamId &&\n      `/api/teams/${teamId}/datarooms/${dataroomId}/folders${include_documents ? \"?include_documents=true\" : \"\"}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    folders,\n    loading: !folders && !error,\n    error,\n  };\n}\n\nexport function useDataroomFolderWithParents({\n  name,\n  dataroomId,\n}: {\n  name: string[];\n  dataroomId: string;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: folders, error } = useSWR<{ name: string; path: string }[]>(\n    teamId &&\n      name &&\n      !!name.length &&\n      `/api/teams/${teamId}/datarooms/${dataroomId}/folders/parents/${name.join(\"/\")}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    folders,\n    loading: !folders && !error,\n    error,\n  };\n}\n\nexport type DataroomFolderDocument = DataroomDocument & {\n  document: {\n    id: string;\n    name: string;\n    type: string;\n    advancedExcelEnabled?: boolean;\n    versions?: { id: string; hasPages: boolean }[];\n    isExternalUpload?: boolean;\n    _count: {\n      views: number;\n      versions: number;\n    };\n  };\n};\n\nexport function useDataroomFolderDocuments({ name }: { name: string[] }) {\n  const router = useRouter();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: documents, error } = useSWR<DataroomFolderDocument[]>(\n    teamId &&\n      id &&\n      name &&\n      `/api/teams/${teamId}/datarooms/${id}/folders/documents/${name.join(\"/\")}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    documents,\n    loading: !documents && !error,\n    error,\n  };\n}\n\nexport function useDataroomViewers({ dataroomId }: { dataroomId: string }) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: viewers, error } = useSWR<any[]>(\n    teamId &&\n      dataroomId &&\n      `/api/teams/${teamId}/datarooms/${dataroomId}/viewers`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    viewers,\n    loading: !error && !viewers,\n    error,\n  };\n}\n\ntype DataroomVisitsResponse = {\n  views: any[];\n  hiddenFromPause: number;\n};\n\nexport function useDataroomVisits({\n  dataroomId,\n  groupId,\n}: {\n  dataroomId: string;\n  groupId?: string;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data, error } = useSWR<DataroomVisitsResponse>(\n    teamId &&\n      dataroomId &&\n      `/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : \"\"}/views`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    views: data?.views,\n    hiddenFromPause: data?.hiddenFromPause ?? 0,\n    loading: !error && !data,\n    error,\n  };\n}\n\ntype DataroomDocumentViewHistory = {\n  id: string;\n  downloadedAt: string;\n  viewedAt: string;\n  downloadType?: \"SINGLE\" | \"BULK\" | \"FOLDER\";\n  downloadMetadata?: {\n    folderName?: string;\n    folderPath?: string;\n    dataroomName?: string;\n    documentCount?: number;\n    documents?: {\n      id: string;\n      name: string;\n    }[];\n  };\n  document: {\n    id: string;\n    name: string;\n  };\n};\n\ntype DataroomDocumentUploadViewHistory = {\n  uploadedAt: string;\n  documentId: string;\n  originalFilename: string;\n};\n\nexport function useDataroomVisitHistory({\n  viewId,\n  dataroomId,\n}: {\n  viewId: string;\n  dataroomId: string;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data, error } = useSWR<{\n    documentViews: DataroomDocumentViewHistory[];\n    uploadedDocumentViews: DataroomDocumentUploadViewHistory[];\n  }>(\n    teamId &&\n      dataroomId &&\n      `/api/teams/${teamId}/datarooms/${dataroomId}/views/${viewId}/history`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    documentViews: data?.documentViews,\n    uploadedDocumentViews: data?.uploadedDocumentViews,\n    loading: !error && !data,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-datarooms-simple.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { Dataroom } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport type DataroomSimple = Pick<Dataroom, \"id\" | \"name\" | \"internalName\" | \"createdAt\">;\n\ntype DataroomsSimpleResponse = {\n  datarooms: DataroomSimple[];\n};\n\n/**\n * A lightweight hook for fetching datarooms without filtering or extended data.\n * Use this for simple use cases like dropdowns, sidebars, or modals that only need\n * basic dataroom info (id, name, createdAt).\n *\n * For full features (search, tags, counts), use `useDatarooms` instead.\n */\nexport default function useDataroomsSimple() {\n  const teamInfo = useTeam();\n\n  const { data, error, mutate } = useSWR<DataroomsSimpleResponse>(\n    teamInfo?.currentTeam?.id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    datarooms: data?.datarooms,\n    loading: !data && !error,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-datarooms.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { Dataroom } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport type DataroomWithCount = Pick<Dataroom, \"id\" | \"name\" | \"createdAt\"> & {\n  _count: {\n    documents: number;\n    views: number;\n  };\n  activeLinkCount: number;\n  lastViewedAt: Date | null;\n  tags?: {\n    tag: {\n      id: string;\n      name: string;\n      color: string;\n      description: string | null;\n    };\n  }[];\n};\n\nexport type DataroomsResponse = {\n  datarooms: DataroomWithCount[];\n  totalCount: number;\n};\n\nexport default function useDatarooms() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n\n  const queryParams = router.query;\n  const searchQuery = queryParams[\"search\"];\n  const statusQuery = queryParams[\"status\"];\n  const tagsQuery = queryParams[\"tags\"];\n\n  const queryParts = [];\n  if (searchQuery) queryParts.push(`search=${searchQuery}`);\n  if (statusQuery) queryParts.push(`status=${statusQuery}`);\n  if (tagsQuery) queryParts.push(`tags=${tagsQuery}`);\n  const queryString = queryParts.length > 0 ? `?${queryParts.join(\"&\")}` : \"\";\n\n  const { data, error, mutate } = useSWR<DataroomsResponse>(\n    teamInfo?.currentTeam?.id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/datarooms${queryString}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    datarooms: data?.datarooms,\n    totalCount: data?.totalCount,\n    loading: !data && !error,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-document-overview.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { DocumentWithVersion } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\ninterface DocumentOverview {\n  document: DocumentWithVersion & {\n    hasPageLinks: boolean;\n    isEmpty: boolean;\n    primaryVersion: any;\n  };\n  limits: {\n    canAddLinks: boolean;\n    canAddDocuments: boolean;\n    canAddUsers: boolean;\n  };\n  featureFlags: {\n    annotations: boolean;\n  };\n  team: {\n    plan: string;\n    isTrial: boolean;\n  };\n  counts: {\n    links: number;\n    views: number;\n  };\n}\n\nexport function useDocumentOverview() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const { data, error, mutate } = useSWR<DocumentOverview>(\n    teamInfo?.currentTeam?.id &&\n      id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/documents/${encodeURIComponent(\n        id,\n      )}/overview`,\n    fetcher,\n    {\n      // Aggressive caching for fast loads\n      dedupingInterval: 30000,\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n      revalidateIfStale: false,\n      // Enable fast refresh for critical data\n      refreshInterval: 0,\n      onError: (err) => {\n        if (err.status === 404) {\n          router.replace(\"/documents\");\n        }\n      },\n    },\n  );\n\n  return {\n    data,\n    document: data?.document,\n    primaryVersion: data?.document?.primaryVersion,\n    limits: data?.limits,\n    featureFlags: data?.featureFlags,\n    team: data?.team,\n    counts: data?.counts,\n    isEmpty: data?.document?.isEmpty || false,\n    loading: !error && !data,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-document-preview.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWRImmutable from \"swr/immutable\";\n\nimport { DocumentPreviewData } from \"@/lib/types/document-preview\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useDocumentPreview(documentId: string, isOpen: boolean) {\n  const { currentTeamId } = useTeam();\n\n  const {\n    data: document,\n    error,\n    mutate,\n  } = useSWRImmutable<DocumentPreviewData>(\n    isOpen && currentTeamId && documentId\n      ? `/api/teams/${currentTeamId}/documents/${documentId}/preview-data`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n    },\n  );\n\n  return {\n    document,\n    loading: !error && !document && isOpen,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-document-stats.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { View } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { TStatsData } from \"@/lib/swr/use-stats\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useDocumentStats(documentId: string | null | undefined) {\n  const { currentTeamId: teamId } = useTeam();\n\n  const { data: stats, error } = useSWR<TStatsData>(\n    documentId && teamId\n      ? `/api/teams/${teamId}/documents/${encodeURIComponent(documentId)}/stats`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    stats,\n    loading: documentId ? !error && !stats : false,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-document.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { View } from \"@prisma/client\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { DocumentWithVersion, LinkWithViews } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useDocument() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const {\n    data: document,\n    error,\n    mutate,\n  } = useSWR<DocumentWithVersion>(\n    teamInfo?.currentTeam?.id &&\n      id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/documents/${encodeURIComponent(\n        id,\n      )}`,\n    fetcher,\n    {\n      // Reduce background-driven revalidation to avoid excessive API traffic\n      dedupingInterval: 30000,\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n      revalidateIfStale: false,\n      onError: (err) => {\n        if (err.status === 404) {\n          toast.error(\"Document not found\", {\n            description:\n              \"The document you're looking for doesn't exist or has been moved.\",\n          });\n          router.replace(\"/documents\");\n        }\n      },\n    },\n  );\n\n  return {\n    document,\n    primaryVersion: document?.versions[0],\n    loading: !error && !document,\n    error,\n    mutate,\n  };\n}\n\nexport function useDocumentLinks() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const {\n    data: links,\n    error,\n    mutate,\n  } = useSWR<LinkWithViews[]>(\n    teamInfo?.currentTeam?.id &&\n      id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/documents/${encodeURIComponent(\n        id,\n      )}/links`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    links,\n    loading: !error && !links,\n    error,\n    mutate,\n  };\n}\n\ninterface ViewWithDuration extends View {\n  internal: boolean;\n  duration: {\n    data: { pageNumber: string; sum_duration: number }[];\n  };\n  totalDuration: number;\n  completionRate: number;\n  link: {\n    name: string | null;\n  };\n  feedbackResponse: {\n    id: string;\n    data: {\n      question: string;\n      answer: string;\n    };\n  } | null;\n  agreementResponse: {\n    id: string;\n    agreementId: string;\n    agreement: {\n      name: string;\n    };\n  } | null;\n  versionNumber: number;\n  versionNumPages: number;\n}\n\ntype TStatsData = {\n  hiddenViewCount: number;\n  viewsWithDuration: ViewWithDuration[];\n  totalViews: number;\n  hiddenFromPause: number;\n};\n\nexport function useDocumentVisits(page: number, limit: number) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const cacheKey =\n    teamId && id\n      ? `/api/teams/${teamId}/documents/${id}/views?page=${page}&limit=${limit}`\n      : null;\n\n  const {\n    data: views,\n    error,\n    mutate,\n  } = useSWR<TStatsData>(cacheKey, fetcher, {\n    dedupingInterval: 20000,\n  });\n\n  return {\n    views,\n    loading: !error && !views,\n    error,\n    mutate,\n  };\n}\n\ninterface DocumentProcessingStatus {\n  currentPageCount: number;\n  totalPages: number;\n  hasPages: boolean;\n}\n\nexport function useDocumentProcessingStatus(documentVersionId: string) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: status, error } = useSWR<DocumentProcessingStatus>(\n    teamId &&\n      `/api/teams/${teamId}/documents/document-processing-status?documentVersionId=${documentVersionId}`,\n    fetcher,\n    {\n      refreshInterval: 3000, // refresh every 3 seconds\n    },\n  );\n\n  return {\n    status: status,\n    loading: !error && !status,\n    error: error,\n  };\n}\n\nexport function useDocumentThumbnail(\n  pageNumber: number,\n  documentId: string,\n  versionNumber?: number,\n) {\n  const { data, error } = useSWR<{ imageUrl: string }>(\n    pageNumber === 0\n      ? null\n      : `/api/jobs/get-thumbnail?documentId=${documentId}&pageNumber=${pageNumber}&versionNumber=${versionNumber}`,\n    fetcher,\n    {\n      dedupingInterval: 1200000,\n      revalidateOnFocus: false,\n      // revalidateOnMount: false,\n      revalidateIfStale: false,\n      refreshInterval: 0,\n    },\n  );\n\n  if (pageNumber === 0) {\n    return {\n      data: null,\n      loading: false,\n      error: null,\n    };\n  }\n\n  return {\n    data,\n    loading: !error && !data,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-documents.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { Folder } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { DocumentWithLinksAndLinkCountAndViewCount } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport default function useDocuments() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const queryParams = router.query;\n  const searchQuery = queryParams[\"search\"];\n  const sortQuery = queryParams[\"sort\"];\n  const page = Number(queryParams[\"page\"]) || 1;\n  const pageSize = Number(queryParams[\"limit\"]) || 10;\n\n  const paginationParams =\n    searchQuery || sortQuery ? `&page=${page}&limit=${pageSize}` : \"\";\n\n  const queryParts = [];\n  if (searchQuery) queryParts.push(`query=${searchQuery}`);\n  if (sortQuery) queryParts.push(`sort=${sortQuery}`);\n  if (paginationParams) queryParts.push(paginationParams.substring(1));\n  const queryString = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';\n\n  const { data, isValidating, error } = useSWR<{\n    documents: DocumentWithLinksAndLinkCountAndViewCount[];\n    folders?: FolderWithCountAndPath[];\n    pagination?: {\n      total: number;\n      pages: number;\n      currentPage: number;\n      pageSize: number;\n    };\n  }>(\n    teamId && `/api/teams/${teamId}/documents${queryString}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n      keepPreviousData: true,\n    },\n  );\n\n  return {\n    documents: data?.documents || [],\n    searchFolders: data?.folders,\n    pagination: data?.pagination,\n    isValidating,\n    loading: !data && !error,\n    isFiltered: !!searchQuery || !!sortQuery,\n    error,\n  };\n}\n\nexport function useFolderDocuments({ name }: { name: string[] }) {\n  const teamInfo = useTeam();\n\n  const { data: documents, error } = useSWR<\n    DocumentWithLinksAndLinkCountAndViewCount[]\n  >(\n    teamInfo?.currentTeam?.id &&\n    name.length > 0 &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/folders/documents/${name.join(\"/\")}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    documents,\n    loading: !documents && !error,\n    error,\n  };\n}\n\nexport type FolderWithCount = Folder & {\n  _count: {\n    documents: number;\n    childFolders: number;\n  };\n};\n\nexport type FolderWithCountAndPath = FolderWithCount & {\n  folderList: string[];\n};\n\nexport function useFolder({ name }: { name: string[] }) {\n  const teamInfo = useTeam();\n  const router = useRouter();\n\n  const { data: folders, error } = useSWR<FolderWithCount[]>(\n    teamInfo?.currentTeam?.id &&\n    name.length > 0 &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/folders/${name.join(\"/\")}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n      onError: (err) => {\n        if (err.status === 404) {\n          router.replace(\"/documents\");\n        }\n      },\n    },\n  );\n\n  return {\n    folders,\n    loading: !folders && !error,\n    error,\n  };\n}\n\nexport type FolderWithDocuments = Folder & {\n  childFolders: FolderWithDocuments[];\n  documents: {\n    id: string;\n    name: string;\n    folderId: string;\n  }[];\n};\n\nexport function useFolders() {\n  const teamInfo = useTeam();\n\n  const { data: folders, error } = useSWR<FolderWithDocuments[]>(\n    teamInfo?.currentTeam?.id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/folders`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    folders,\n    loading: !folders && !error,\n    error,\n  };\n}\n\nexport function useRootFolders() {\n  const teamInfo = useTeam();\n\n  const { data: folders, error } = useSWR<FolderWithCount[]>(\n    teamInfo?.currentTeam?.id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/folders?root=true`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    folders,\n    loading: !folders && !error,\n    error,\n  };\n}\n\nexport function useHiddenDocuments() {\n  const teamInfo = useTeam();\n\n  const { data, error, mutate } = useSWR<{\n    folders: FolderWithCount[];\n    documents: DocumentWithLinksAndLinkCountAndViewCount[];\n  }>(\n    teamInfo?.currentTeam?.id &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/documents/hidden`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    folders: data?.folders,\n    documents: data?.documents,\n    loading: !data && !error,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-domains.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { Domain } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useDomains({ enabled = false }: { enabled?: boolean } = {}) {\n  const teamInfo = useTeam();\n\n  const { data: domains, error } = useSWR<Domain[]>(\n    enabled && teamInfo?.currentTeam?.id\n      ? `/api/teams/${teamInfo.currentTeam.id}/domains`\n      : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n    },\n  );\n\n  return {\n    domains,\n    loading: !domains && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-folders.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"../utils\";\n\ntype FolderWithParents = {\n  id: string;\n  name: string;\n  parentId: string | null;\n  teamId: string;\n  _count: {\n    documents: number;\n    childFolders: number;\n  };\n  parent: FolderWithParents | null;\n};\n\nexport function useFolderWithParents({ name }: { name: string[] }) {\n  const teamInfo = useTeam();\n\n  const { data: folders, error } = useSWR<{ name: string; path: string }[]>(\n    teamInfo?.currentTeam?.id &&\n    name && !!name.length &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/folders/parents/${name.join(\"/\")}`,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n    },\n  );\n\n  return {\n    folders,\n    loading: !folders && !error,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-invitations.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { Invitation } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useInvitations() {\n  const teamInfo = useTeam();\n\n  // only fetch data once when linkId is present\n  const { data: invitations, error } = useSWR<Invitation[]>(\n    teamInfo?.currentTeam &&\n      `/api/teams/${teamInfo.currentTeam.id}/invitations`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    invitations,\n    loading: !error && !invitations,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-invoices.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport interface Invoice {\n  id: string;\n  number: string | null;\n  status: string | null;\n  amount: number;\n  currency: string;\n  created: number;\n  invoicePdf: string | null;\n  hostedInvoiceUrl: string | null;\n  periodStart: number;\n  periodEnd: number;\n  description: string;\n}\n\nexport function useInvoices() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data, error, isLoading } = useSWR<{ invoices: Invoice[] }>(\n    teamId ? `/api/teams/${teamId}/billing/invoices` : null,\n    fetcher,\n    {\n      dedupingInterval: 60000,\n      revalidateOnFocus: false,\n    },\n  );\n\n  return {\n    invoices: data?.invoices || [],\n    loading: isLoading,\n    error,\n  };\n}\n\n"
  },
  {
    "path": "lib/swr/use-limits.ts",
    "content": "import { useLimits } from \"@/ee/limits/swr-handler\";\n\nexport default useLimits;\n"
  },
  {
    "path": "lib/swr/use-link.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { View } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport { LinkWithDocument } from \"../types\";\n\nexport function useLink() {\n  const router = useRouter();\n\n  const { linkId } = router.query as {\n    linkId: string;\n  };\n\n  // only fetch data once when linkId is present\n  const { data: link, error } = useSWR<LinkWithDocument>(\n    linkId && `/api/links/${encodeURIComponent(linkId)}`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    link,\n    loading: !error && !link,\n    error,\n  };\n}\n\nexport function useDomainLink() {\n  const router = useRouter();\n\n  const { domain, slug } = router.query as {\n    domain: string;\n    slug: string;\n  };\n\n  const { data: link, error } = useSWR<LinkWithDocument>(\n    domain &&\n      slug &&\n      `/api/links/domains/${encodeURIComponent(domain)}/${encodeURIComponent(\n        slug,\n      )}`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    link,\n    loading: !error && !link,\n    error,\n  };\n}\n\ninterface ViewWithDuration extends View {\n  duration: {\n    data: { pageNumber: string; sum_duration: number }[];\n  };\n  totalDuration: number;\n  completionRate: number;\n}\n\ninterface LinkVisitsResponse {\n  views: ViewWithDuration[];\n  hiddenFromPause: number;\n}\n\nexport function useLinkVisits(linkId: string) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { data, error } = useSWR<LinkVisitsResponse>(\n    linkId &&\n      teamId &&\n      `/api/teams/${teamId}/links/${encodeURIComponent(linkId)}/visits`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    views: data?.views,\n    hiddenFromPause: data?.hiddenFromPause ?? 0,\n    loading: !error && !data,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-passkeys.ts",
    "content": "import useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ninterface PasskeyCredential {\n  id: string;\n  name: string;\n  created_at: string;\n  last_used_at: string;\n  transports: string[];\n  backup_eligible: boolean;\n  backup_state: boolean;\n  is_mfa: boolean;\n}\n\nexport function usePasskeys() {\n  const { data, error, mutate, isValidating } = useSWR<{\n    passkeys: PasskeyCredential[];\n  }>(\"/api/account/passkeys\", fetcher, {\n    revalidateOnFocus: false,\n    dedupingInterval: 30000,\n  });\n\n  return {\n    passkeys: data?.passkeys || [],\n    loading: !data && !error,\n    error,\n    mutate,\n    isValidating,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-saml.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport type { SAMLSSORecord } from \"@boxyhq/saml-jackson\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport default function useSAML() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data, isLoading, mutate } = useSWR<{\n    connections: SAMLSSORecord[];\n    issuer: string;\n    acs: string;\n    ssoEmailDomain: string | null;\n    ssoEnforcedAt: string | null;\n    slug: string | null;\n  }>(teamId ? `/api/teams/${teamId}/saml` : null, fetcher, {\n    keepPreviousData: true,\n    revalidateOnFocus: false,\n    revalidateOnReconnect: false,\n    dedupingInterval: 60_000,\n  });\n\n  const configured = !!(data?.connections && data.connections.length > 0);\n\n  return {\n    saml: data,\n    connections: data?.connections ?? [],\n    issuer: data?.issuer ?? \"\",\n    acs: data?.acs ?? \"\",\n    ssoEmailDomain: data?.ssoEmailDomain ?? null,\n    ssoEnforcedAt: data?.ssoEnforcedAt ?? null,\n    slug: data?.slug ?? null,\n    provider: configured\n      ? data!.connections[0].idpMetadata?.provider ?? null\n      : null,\n    configured,\n    loading: isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-scim.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport type { Directory } from \"@boxyhq/saml-jackson\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport default function useSCIM() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data, isLoading, mutate } = useSWR<{ directories: Directory[] }>(\n    teamId ? `/api/teams/${teamId}/directory-sync` : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n      dedupingInterval: 60_000,\n    },\n  );\n\n  const configured = !!(data?.directories && data.directories.length > 0);\n\n  return {\n    scim: data,\n    directories: data?.directories ?? [],\n    provider: configured ? data!.directories[0].type : null,\n    configured,\n    loading: isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-slack-channels.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { SlackChannel } from \"@/lib/integrations/slack/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useSlackChannels({ enabled = true }: { enabled?: boolean }) {\n  const { currentTeamId: teamId } = useTeam();\n  const { data, error, isLoading, mutate } = useSWR<{\n    channels: SlackChannel[];\n  }>(\n    enabled && teamId\n      ? `/api/teams/${teamId}/integrations/slack/channels`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n      dedupingInterval: 30000,\n      revalidateIfStale: false,\n      errorRetryCount: 2,\n      errorRetryInterval: 5000,\n    },\n  );\n\n  return {\n    channels: data?.channels || [],\n    error,\n    loading: isLoading && !data,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-slack-integration.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\nimport useSWRImmutable from \"swr/immutable\";\n\nimport { SlackIntegration } from \"@/lib/integrations/slack/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useSlackIntegration({ enabled = true }: { enabled?: boolean }) {\n  const { currentTeamId: teamId } = useTeam();\n  const { data, error, isLoading, mutate } = useSWRImmutable<SlackIntegration>(\n    enabled && teamId ? `/api/teams/${teamId}/integrations/slack` : null,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n      dedupingInterval: 30000,\n      revalidateIfStale: false,\n      errorRetryCount: 2,\n      errorRetryInterval: 5000,\n    },\n  );\n\n  return {\n    integration: data || null,\n    error,\n    loading: isLoading && !data,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-stats.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { View } from \"@prisma/client\";\nimport useSWR from \"swr\";\nimport useSWRImmutable from \"swr/immutable\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport type TStatsData = {\n  views: View[];\n  avgCompletionRate: number;\n  duration: {\n    data: { versionNumber: number; pageNumber: string; avg_duration: number }[];\n  };\n  total_duration: number;\n  totalViews: number;\n};\n\nexport function useStats({\n  excludeTeamMembers,\n}: { excludeTeamMembers?: boolean } = {}) {\n  // this gets the data for a document's graph of all views\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { id } = router.query as {\n    id: string;\n  };\n\n  const { data: stats, error } = useSWR<TStatsData>(\n    id &&\n      teamId &&\n      `/api/teams/${teamId}/documents/${encodeURIComponent(id)}/stats${excludeTeamMembers ? \"?excludeTeamMembers=true\" : \"\"}`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    stats,\n    loading: !error && !stats,\n    error,\n  };\n}\n\ninterface StatsViewData {\n  views: View[];\n  duration: {\n    data: { pageNumber: string; sum_duration: number }[];\n  };\n}\n\nexport function useVisitorStats(viewId: string) {\n  // this gets the data for a single visitor's graph\n  const router = useRouter();\n  const teamInfo = useTeam();\n\n  const { id: documentId } = router.query as {\n    id: string;\n  };\n\n  const { data: stats, error } = useSWR<StatsViewData>(\n    documentId &&\n      viewId &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/documents/${encodeURIComponent(\n        documentId,\n      )}/views/${encodeURIComponent(viewId)}/stats`,\n    fetcher,\n    {\n      dedupingInterval: 10000,\n    },\n  );\n\n  return {\n    stats,\n    loading: !error && !stats,\n    error,\n  };\n}\n\nexport function useVisitorUserAgent(viewId: string) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n\n  const { id: documentId } = router.query as {\n    id: string;\n  };\n\n  const { data: userAgent, error } = useSWRImmutable<{\n    country: string;\n    city: string;\n    os: string;\n    browser: string;\n    device: string;\n  }>(\n    documentId &&\n      viewId &&\n      `/api/teams/${teamInfo?.currentTeam?.id}/documents/${documentId}/views/${viewId}/user-agent`,\n    fetcher,\n  );\n\n  return {\n    userAgent,\n    loading: !error && !userAgent,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-tags.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\nimport { z } from \"zod\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nimport { TagsWithTotalCount } from \"../types\";\n\nexport const getTagsQuerySchema = z.object({\n  sortBy: z\n    .enum([\"name\", \"createdAt\"])\n    .optional()\n    .default(\"name\")\n    .describe(\"The field to sort the tags by.\"),\n  sortOrder: z\n    .enum([\"asc\", \"desc\"])\n    .optional()\n    .default(\"asc\")\n    .describe(\"The order to sort the tags by.\"),\n  search: z\n    .string()\n    .optional()\n    .describe(\"The search term to filter the tags by.\"),\n  page: z\n    .preprocess((val) => parseInt(val as string, 10), z.number().min(1))\n    .optional(),\n  pageSize: z\n    .preprocess(\n      (val) => parseInt(val as string, 10),\n      z.number().min(1).max(100),\n    )\n    .optional(),\n});\n\nconst partialQuerySchema = getTagsQuerySchema.partial();\n\nexport function useTags({\n  query,\n  enabled = true,\n  includeLinksCount = false,\n}: {\n  query?: z.infer<typeof partialQuerySchema>;\n  enabled?: boolean;\n  includeLinksCount?: boolean;\n}) {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const {\n    data: tags,\n    isValidating,\n    error,\n    isLoading,\n    mutate,\n  } = useSWR<TagsWithTotalCount>(\n    teamId &&\n      enabled &&\n      `/api/teams/${teamId}/tags?${new URLSearchParams({\n        ...query,\n        includeLinksCount,\n      } as Record<string, any>).toString()}`,\n    fetcher,\n    {\n      dedupingInterval: 100000,\n    },\n  );\n\n  return {\n    tags: tags?.tags,\n    tagCount: tags?.totalCount,\n    loading: tags ? false : true,\n    isValidating,\n    error,\n    isLoading,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-team-ai.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ninterface TeamAISettings {\n  agentsEnabled: boolean;\n  vectorStoreId: string | null;\n  isAdmin: boolean;\n  isAIFeatureEnabled: boolean;\n}\n\n/**\n * Hook to check team AI settings and user permissions\n * Returns whether AI is feature-flagged for the team,\n * whether it's enabled, and if the current user is an admin\n */\nexport function useTeamAI() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data, isLoading, mutate } = useSWR<TeamAISettings>(\n    teamId ? `/api/teams/${teamId}/ai-settings` : null,\n    fetcher,\n    {\n      dedupingInterval: 30000,\n      revalidateOnFocus: false,\n      revalidateOnReconnect: false,\n    },\n  );\n\n  return {\n    // Feature flag - is AI available for this team?\n    isAIFeatureEnabled: data?.isAIFeatureEnabled ?? false,\n    // Team setting - is AI enabled for this team?\n    isAIEnabled: data?.agentsEnabled ?? false,\n    // Is the current user an admin?\n    isAdmin: data?.isAdmin ?? false,\n    // Can the user manage AI settings? (admin + feature enabled)\n    canManageAI: (data?.isAdmin && data?.isAIFeatureEnabled) ?? false,\n    // Is the feature ready to use? (feature enabled + team enabled)\n    canUseAI: (data?.isAIFeatureEnabled && data?.agentsEnabled) ?? false,\n    // Vector store ID\n    vectorStoreId: data?.vectorStoreId ?? null,\n    // Loading state\n    isLoading,\n    // Mutate function to refresh AI settings\n    mutateAISettings: mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-team-settings.ts",
    "content": "import useSWR, { mutate } from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ninterface TeamSettings {\n  replicateDataroomFolders: boolean;\n  enableExcelAdvancedMode: boolean;\n  timezone: string;\n}\n\n/**\n * Hook to fetch fresh team settings with proper revalidation.\n * Useful when you need to ensure settings are up-to-date across tabs.\n */\nexport function useTeamSettings(teamId: string | undefined | null) {\n  const { data, error, isValidating } = useSWR<TeamSettings>(\n    teamId ? `/api/teams/${teamId}/settings` : null,\n    fetcher,\n    {\n      revalidateOnFocus: true,\n      revalidateOnReconnect: true,\n      dedupingInterval: 5000, // Short deduping for settings\n    },\n  );\n\n  const updateTimezone = async (timezone: string) => {\n    if (!teamId) return;\n\n    const response = await fetch(`/api/teams/${teamId}/settings`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({ timezone }),\n    });\n\n    if (!response.ok) {\n      throw new Error(\"Failed to update timezone\");\n    }\n\n    // Revalidate the settings\n    mutate(`/api/teams/${teamId}/settings`);\n\n    return response.json();\n  };\n\n  return {\n    settings: data,\n    isLoading: !data && !error,\n    isError: error,\n    isValidating,\n    updateTimezone,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-team.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\n\nimport { TeamDetail } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useGetTeam() {\n  const { currentTeamId } = useTeam();\n\n  const { data: team, error } = useSWR<TeamDetail>(\n    currentTeamId && `/api/teams/${currentTeamId}`,\n    fetcher,\n    {\n      dedupingInterval: 20000,\n    },\n  );\n\n  return {\n    team,\n    loading: team ? false : true,\n    error,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-teams.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useSession } from \"next-auth/react\";\nimport useSWR from \"swr\";\n\nimport { Team } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nexport function useTeams() {\n  const router = useRouter();\n  const { data: session } = useSession();\n\n  const { data: teams, isValidating } = useSWR<Team[]>(\n    router.isReady && session ? \"/api/teams\" : null,\n    fetcher,\n    {\n      dedupingInterval: 20000,\n      revalidateOnFocus: true,\n      revalidateOnReconnect: true,\n    },\n  );\n\n  return {\n    teams,\n    loading: !teams && router.isReady && !!session,\n    isValidating,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-viewer.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport useSWR from \"swr\";\nimport { useMemo } from \"react\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ntype ViewerWithViews = {\n  id: string;\n  email: string;\n  createdAt: Date;\n  updatedAt: Date;\n  views: {\n    documentId: string;\n    viewCount: number;\n    lastViewed: Date;\n    document: {\n      id: string;\n      name: string | null;\n      type: string | null;\n      contentType: string | null;\n    };\n    totalDuration: number;\n  }[];\n  pagination?: {\n    currentPage: number;\n    pageSize: number;\n    totalItems: number;\n    totalPages: number;\n    hasNext: boolean;\n    hasPrev: boolean;\n  };\n  sorting?: {\n    sortBy: string;\n    sortOrder: string;\n  };\n};\n\nexport default function useViewer(\n  page: number = 1,\n  pageSize: number = 10,\n  sortBy: string = \"lastViewed\",\n  sortOrder: string = \"desc\"\n) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { id } = router.query;\n\n  const queryParams = new URLSearchParams();\n  queryParams.append('page', page.toString());\n  queryParams.append('pageSize', pageSize.toString());\n  queryParams.append('sortBy', sortBy);\n  queryParams.append('sortOrder', sortOrder);\n  const queryString = queryParams.toString();\n\n  const { data: viewer, error } = useSWR<ViewerWithViews>(\n    teamId && id ? `/api/teams/${teamId}/viewers/${id}?${queryString}` : null,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 30000,\n      revalidateIfStale: false,\n    }\n  );\n\n  const shouldFetchDurations = (viewer?.views?.length ?? 0) > 0;\n\n  const { data: durationsResponse, isLoading: loadingDurations, error: durationsError } = useSWR<{ durations: Record<string, number> }>(\n    shouldFetchDurations\n      ? `/api/teams/${teamId}/viewers/${id}?${queryString}&withDuration=true`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      dedupingInterval: 60000,\n      errorRetryCount: 1,\n      errorRetryInterval: 5000,\n      revalidateIfStale: false,\n    }\n  );\n\n  const durations = useMemo(() => {\n    return durationsResponse?.durations || {};\n  }, [durationsResponse]);\n\n  const isMainLoading = !viewer && !error;\n  const isDurationsLoading = shouldFetchDurations && loadingDurations && !durationsError;\n\n  return {\n    viewer, // Always use the main viewer data\n    durations,\n    loadingDurations: isDurationsLoading,\n    loading: isMainLoading,\n    error: error || durationsError,\n    hasData: Boolean(viewer),\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-viewers.ts",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { Viewer } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\ntype ViewerWithStats = {\n  id: string;\n  email: string;\n  createdAt: Date;\n  updatedAt: Date;\n  totalVisits: number;\n  lastViewed: Date | null;\n};\n\ntype ViewersResponse = {\n  viewers: ViewerWithStats[];\n  pagination: {\n    currentPage: number;\n    pageSize: number;\n    totalItems: number;\n    totalPages: number;\n    hasNext: boolean;\n    hasPrev: boolean;\n  };\n  sorting: {\n    sortBy: string;\n    sortOrder: string;\n  };\n};\n\nexport default function useViewers(\n  page: number = 1,\n  pageSize: number = 10,\n  sortBy: string = \"lastViewed\",\n  sortOrder: string = \"desc\"\n) {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const routerQuery = router.query;\n  const searchQuery = routerQuery[\"search\"];\n\n  const queryParams = new URLSearchParams();\n  queryParams.append('page', page.toString());\n  queryParams.append('pageSize', pageSize.toString());\n  queryParams.append('sortBy', sortBy);\n  queryParams.append('sortOrder', sortOrder);\n\n  if (searchQuery && typeof searchQuery === 'string') {\n    queryParams.append('query', searchQuery);\n  }\n\n  const queryString = queryParams.toString();\n\n  const {\n    data: response,\n    isValidating,\n    error,\n    mutate,\n  } = useSWR<ViewersResponse>(\n    teamId\n      ? `/api/teams/${teamId}/viewers?${queryString}`\n      : null,\n    fetcher,\n    {\n      revalidateOnFocus: false,\n      revalidateIfStale: false,\n      revalidateOnReconnect: false,\n      dedupingInterval: 30000,\n      keepPreviousData: true,\n      refreshInterval: 0,\n      errorRetryCount: 2,\n      errorRetryInterval: 5000,\n    },\n  );\n\n  return {\n    viewers: response?.viewers,\n    pagination: response?.pagination,\n    sorting: response?.sorting,\n    isValidating,\n    loading: !response && !error,\n    isFiltered: !!searchQuery,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/swr/use-visitor-groups.ts",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { VisitorGroup } from \"@prisma/client\";\nimport useSWR from \"swr\";\n\nimport { fetcher } from \"@/lib/utils\";\n\nexport type VisitorGroupWithCount = VisitorGroup & {\n  _count: {\n    links: number;\n  };\n};\n\nexport default function useVisitorGroups() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const {\n    data: visitorGroups,\n    error,\n    mutate,\n  } = useSWR<VisitorGroupWithCount[]>(\n    teamId ? `/api/teams/${teamId}/visitor-groups` : null,\n    fetcher,\n    { dedupingInterval: 30000 },\n  );\n\n  return {\n    visitorGroups,\n    loading: !visitorGroups && !error,\n    error,\n    mutate,\n  };\n}\n"
  },
  {
    "path": "lib/team/helper.ts",
    "content": "import { Document, DocumentVersion, Link, View } from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\nimport { decryptEncrpytedPassword } from \"@/lib/utils\";\n\nimport { DocumentError, TeamError } from \"../errorHandler\";\n\ninterface ITeamUserAndDocument {\n  teamId: string;\n  userId: string;\n  docId?: string;\n  checkOwner?: boolean;\n  options?: {};\n}\n\ninterface IDocumentWithLink {\n  docId: string;\n  userId: string;\n  options?: {};\n}\n\nexport async function getTeamWithUsersAndDocument({\n  teamId,\n  userId,\n  docId,\n  checkOwner,\n  options,\n}: ITeamUserAndDocument) {\n  const team = await prisma.team.findUnique({\n    where: {\n      id: teamId,\n    },\n    include: {\n      users: {\n        select: {\n          userId: true,\n        },\n      },\n      documents: {\n        ...options,\n      },\n    },\n  });\n\n  // check if the team exists\n  if (!team) {\n    throw new TeamError(\"Team doesn't exists\");\n  }\n\n  // check if the user is part the team\n  const teamHasUser = team?.users.some((user) => user.userId === userId);\n  if (!teamHasUser) {\n    throw new TeamError(\"You are not a member of the team\");\n  }\n\n  // check if the document exists in the team\n  let document:\n    | (Document & {\n        views?: View[];\n        versions?: DocumentVersion[];\n        links?: Link[];\n      })\n    | undefined;\n  if (docId) {\n    document = team.documents.find((doc) => doc.id === docId);\n    if (!document) {\n      throw new TeamError(\"Document doesn't exists in the team\");\n    }\n  }\n  if (document && document?.links) {\n    document?.links?.forEach((res: Link) => {\n      if (res?.password != null) {\n        let decryptedPassword: string = decryptEncrpytedPassword(res?.password);\n        res[\"password\"] = decryptedPassword;\n      }\n    });\n  }\n  // Check that the user is owner of the document, otherwise return 401\n  // if (checkOwner) {\n  //   const isUserOwnerOfDocument = document?.ownerId === userId;\n  //   if (!isUserOwnerOfDocument) {\n  //     throw new TeamError(\"Unauthorized access to the document\");\n  //   }\n  // }\n\n  return { team, document };\n}\n\nexport async function getDocumentWithTeamAndUser({\n  docId,\n  userId,\n  options,\n}: IDocumentWithLink) {\n  const document = (await prisma.document.findUnique({\n    where: {\n      id: docId,\n    },\n    include: {\n      ...options,\n    },\n  })) as Document & { team: { users: { userId: string }[] } };\n\n  if (!document) {\n    throw new DocumentError(\"Document doesn't exists\");\n  }\n\n  const teamHasUser = document.team?.users.some(\n    (user) => user.userId === userId,\n  );\n  if (!teamHasUser) {\n    throw new TeamError(\"You are not a member of the team\");\n  }\n\n  return { document };\n}\n"
  },
  {
    "path": "lib/tinybird/README.md",
    "content": "## Add new pipes to Tinybird\n\nSo you added a new pipe to Tinybird and want to push that to the server.\n\n```sh\ntb push lib/tinybird/endpoints/<PIPENAME>.pipe\n```\n\n## Danger Zone\n\n### Delete a data from datasource\n\n```sh\ntb datasource delete page_views__v3 --dry-run --sql-condition \"viewId='VIEWID' and CAST(pageNumber AS UInt8) = PAGENUMBER\" --wait\n```\n"
  },
  {
    "path": "lib/tinybird/datasources/click_events.datasource",
    "content": "VERSION 1\n\nDESCRIPTION >\n    Click events track when a user clicks a link within a document\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp`,\n    `event_id` String `json:$.event_id`,\n    `session_id` String `json:$.session_id`,\n    `link_id` String `json:$.link_id`,\n    `document_id` String `json:$.document_id`,\n    `dataroom_id` Nullable(String) `json:$.dataroom_id`,\n    `view_id` String `json:$.view_id`,\n    `page_number` LowCardinality(String) `json:$.page_number`,\n    `version_number` UInt16 `json:$.version_number`,\n    `href` String `json:$.href`\n\nENGINE \"MergeTree\"\nENGINE_SORTING_KEY \"document_id,view_id,link_id,timestamp\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\" "
  },
  {
    "path": "lib/tinybird/datasources/page_views.datasource",
    "content": "VERSION 3\n\nDESCRIPTION >\n  Page views are events when a user views a document\n\nSCHEMA >\n  `id` String `json:$.id`,\n  `linkId` String `json:$.linkId`,\n  `documentId` String `json:$.documentId`,\n  `viewId` String `json:$.viewId`,\n  `dataroomId` Nullable(String) `json:$.dataroomId`,\n  `versionNumber` UInt16 `json:$.versionNumber`,\n  # Unix timestamp\n  `time` Int64 `json:$.time`,\n  `duration` UInt32 `json:$.duration`,\n  # The page number\n  `pageNumber` LowCardinality(String) `json:$.pageNumber`,\n  `country` String `json:$.country`,\n  `city` String `json:$.city`,\n  `region` String `json:$.region`,\n  `latitude` String `json:$.latitude`,\n  `longitude` String `json:$.longitude`,\n  `ua` String `json:$.ua`,\n  `browser` String `json:$.browser`,\n  `browser_version` String `json:$.browser_version`,\n  `engine` String `json:$.engine`,\n  `engine_version` String `json:$.engine_version`,\n  `os` String `json:$.os`,\n  `os_version` String `json:$.os_version`,\n  `device` String `json:$.device`,\n  `device_vendor` String `json:$.device_vendor`,\n  `device_model` String `json:$.device_model`,\n  `cpu_architecture` String `json:$.cpu_architecture`,\n  `bot` UInt8 `json:$.bot`,\n  `referer` String `json:$.referer`,\n  `referer_url` String `json:$.referer_url`\n\n\nENGINE \"MergeTree\"\nENGINE_SORTING_KEY \"linkId,documentId,viewId,versionNumber,pageNumber,time,id\"\n\n"
  },
  {
    "path": "lib/tinybird/datasources/pm_click_events.datasource",
    "content": "VERSION 1\n\nDESCRIPTION >\n    Event track when a visitor clicks a link\n\nSCHEMA >\n    `timestamp` DateTime64(3) `json:$.timestamp`,\n    `click_id` String `json:$.click_id`,\n    `view_id` String `json:$.view_id`,\n    `link_id` String `json:$.link_id`,\n    `document_id` Nullable(String) `json:$.document_id`,\n    `dataroom_id` Nullable(String) `json:$.dataroom_id`,\n    `continent` LowCardinality(String) `json:$.continent`,\n    `country` LowCardinality(String) `json:$.country`,\n    `city` String `json:$.city`,\n    `region` String `json:$.region`,\n    `latitude` String `json:$.latitude`,\n    `longitude` String `json:$.longitude`,\n    `device` LowCardinality(String) `json:$.device`,\n    `device_model` LowCardinality(String) `json:$.device_model`,\n    `device_vendor` LowCardinality(String) `json:$.device_vendor`,\n    `browser` LowCardinality(String) `json:$.browser`,\n    `browser_version` String `json:$.browser_version`,\n    `os` LowCardinality(String) `json:$.os`,\n    `os_version` String `json:$.os_version`,\n    `engine` LowCardinality(String) `json:$.engine`,\n    `engine_version` String `json:$.engine_version`,\n    `cpu_architecture` LowCardinality(String) `json:$.cpu_architecture`,\n    `ua` String `json:$.ua`,\n    `bot` UInt8 `json:$.bot`,\n    `referer` String `json:$.referer`,\n    `referer_url` String `json:$.referer_url`,\n    `ip_address` Nullable(String) `json:$.ip_address`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, view_id, link_id, click_id\"\n"
  },
  {
    "path": "lib/tinybird/datasources/video_views.datasource",
    "content": "VERSION 1\n\nDESCRIPTION >\n  Video views are events when a user views a video\n\nSCHEMA >\n  `timestamp` DateTime64(3) `json:$.timestamp`,\n  `id` String `json:$.id`,\n  `link_id` String `json:$.link_id`,\n  `document_id` String `json:$.document_id`,\n  `view_id` String `json:$.view_id`,\n  `dataroom_id` Nullable(String) `json:$.dataroom_id`,\n  `version_number` UInt16 `json:$.version_number`,\n  `event_type` LowCardinality(String) `json:$.event_type`,\n  # Video specific fields\n  `start_time` UInt32 `json:$.start_time`,\n  `end_time` UInt32 `json:$.end_time`,\n  # Store as 100, 150, 200 instead of 1.0, 1.5, 2.0\n  `playback_rate` UInt16 `json:$.playback_rate`,  \n  # Store as 0-100 instead of 0.0-1.0\n  `volume` UInt8 `json:$.volume`,                 \n  `is_muted` UInt8 `json:$.is_muted`,\n  `is_focused` UInt8 `json:$.is_focused`,\n  `is_fullscreen` UInt8 `json:$.is_fullscreen`,\n  # User agent fields\n  `country` LowCardinality(String) `json:$.country`,\n  `city` String `json:$.city`,\n  `region` String `json:$.region`,\n  `latitude` String `json:$.latitude`,\n  `longitude` String `json:$.longitude`,\n  `ua` String `json:$.ua`,\n  `browser` LowCardinality(String) `json:$.browser`,\n  `browser_version` String `json:$.browser_version`,\n  `engine` LowCardinality(String) `json:$.engine`,\n  `engine_version` String `json:$.engine_version`,\n  `os` LowCardinality(String) `json:$.os`,\n  `os_version` String `json:$.os_version`,\n  `device` LowCardinality(String) `json:$.device`,\n  `device_vendor` LowCardinality(String) `json:$.device_vendor`,\n  `device_model` LowCardinality(String) `json:$.device_model`,\n  `cpu_architecture` LowCardinality(String) `json:$.cpu_architecture`,\n  `bot` UInt8 `json:$.bot`,\n  `referer` String `json:$.referer`,\n  `referer_url` String `json:$.referer_url`,\n  `ip_address` Nullable(String) `json:$.ip_address`\n\nENGINE \"MergeTree\"\nENGINE_SORTING_KEY \"timestamp,link_id,document_id,view_id,version_number,event_type\"\n\n"
  },
  {
    "path": "lib/tinybird/datasources/webhook_events.datasource",
    "content": "VERSION 1\n\nDESCRIPTION >\n  Webhook events are events when a webhook is triggered\n\nSCHEMA >\n  `timestamp` DateTime64(3) `json:$.timestamp` DEFAULT now(),\n  `event_id` String `json:$.event_id`,\n  `webhook_id` String `json:$.webhook_id`,\n  `url` String `json:$.url`,\n  `event` LowCardinality(String) `json:$.event`,\n  `http_status` UInt16 `json:$.http_status`,\n  `request_body` String `json:$.request_body`,\n  `response_body` String `json:$.response_body`,\n  `message_id` String `json:$.message_id`\n\nENGINE \"MergeTree\"\nENGINE_PARTITION_KEY \"toYYYYMM(timestamp)\"\nENGINE_SORTING_KEY \"timestamp, webhook_id, event_id\"\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_click_events_by_view.pipe",
    "content": "VERSION 1\n\nDESCRIPTION >\n    Get all click events for a specific view\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        timestamp,\n        document_id,\n        dataroom_id,\n        view_id,\n        page_number,\n        version_number,\n        href\n    FROM click_events\n    WHERE document_id = {{ String(document_id, required=True) }}\n        AND view_id = {{ String(view_id, required=True) }}\n    ORDER BY timestamp ASC\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_dataroom_view_document_stats.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        viewId,\n        documentId,\n        SUM(duration) AS sum_duration,\n        COUNT(DISTINCT pageNumber) AS pages_viewed\n    FROM\n        page_views__v3\n    WHERE\n        viewId IN splitByChar(',', {{ String(viewIds, required=True) }})\n    GROUP BY\n        viewId, documentId\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_document_duration_per_viewer.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        SUM(duration) AS sum_duration\n    FROM\n        page_views__v3\n    WHERE\n        documentId = {{ String(documentId, required=True)}}\n        AND viewId IN splitByChar(',', {{ String(viewIds, required=True) }})\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_page_duration_per_view.pipe",
    "content": "VERSION 4\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        pageNumber,\n        SUM(duration) AS sum_duration\n    FROM\n        page_views__v3\n    WHERE\n        documentId = {{ String(documentId, required=True) }}\n        AND viewId = {{ String(viewId, required=True) }}\n        AND time >= {{ Int64(since, required=True) }}\n    GROUP BY\n        pageNumber\n    ORDER BY\n        pageNumber ASC\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_total_average_page_duration.pipe",
    "content": "VERSION 5\n\nNODE endpoint\nSQL >\n    %\n    WITH\n        DistinctDurations AS (\n            SELECT versionNumber, pageNumber, viewId, SUM(duration) AS distinct_duration\n            FROM page_views__v3\n            WHERE\n                documentId = {{ String(documentId, required=true) }}\n                AND time >= {{ Int64(since, required=true) }}\n                AND linkId NOT IN splitByChar(',', {{ String(excludedLinkIds, required=True) }})\n                AND viewId NOT IN splitByChar(',', {{ String(excludedViewIds, required=True) }})\n            GROUP BY versionNumber, pageNumber, viewId\n        )\n    SELECT versionNumber, pageNumber, AVG(distinct_duration) AS avg_duration\n    FROM DistinctDurations\n    GROUP BY versionNumber, pageNumber\n    ORDER BY versionNumber ASC, pageNumber ASC\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_total_dataroom_duration.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        viewId,\n        SUM(duration) AS sum_duration\n    FROM\n        page_views__v3\n    WHERE\n        dataroomId = {{ String(dataroomId, required=true) }}\n        AND time >= {{ Int64(since, required=true) }}\n        AND linkId NOT IN {{ Array(excludedLinkIds, String) }}\n        AND viewId NOT IN {{ Array(excludedViewIds, String) }}\n    GROUP BY\n        viewId\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_total_document_duration.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT SUM(duration) AS sum_duration\n    FROM page_views__v3\n    WHERE\n        documentId = {{ String(documentId, required=true) }}\n        AND time >= {{ Int64(since, required=true) }}\n        AND linkId NOT IN splitByChar(',', {{ String(excludedLinkIds, required=True) }})\n        AND viewId NOT IN splitByChar(',', {{ String(excludedViewIds, required=True) }})\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_total_link_duration.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT SUM(duration) AS sum_duration, COUNT(distinct viewId) as view_count\n    FROM page_views__v3\n    WHERE\n        linkId = {{ String(linkId, required=true) }}\n        AND time >= {{ Int64(since, required=true) }}\n        AND documentId = {{ String(documentId, required=true) }}\n        AND viewId NOT IN splitByChar(',', {{ String(excludedViewIds, required=true) }})\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_total_team_duration.pipe",
    "content": "VERSION 1\n\nDESCRIPTION >\n    Get total duration and unique countries for all documents in a team\n\nNODE get_duration\nSQL >\n    %\n    SELECT\n        sum(duration) as total_duration\n    FROM page_views\n    WHERE documentId IN splitByString(',', {{String(documentIds, '')}})\n    AND time >= {{Int64(since, 0)}}\n    AND time < {{Int64(until, 9999999999999)}}\n\nNODE get_countries\nSQL >\n    %\n    SELECT\n        groupArray(DISTINCT country) as unique_countries\n    FROM pm_click_events\n    WHERE document_id IN splitByString(',', {{String(documentIds, '')}})\n    AND toUnixTimestamp64Milli(timestamp) >= {{Int64(since, 0)}}\n    AND toUnixTimestamp64Milli(timestamp) < {{Int64(until, 9999999999999)}}\n    AND country != 'Unknown'\n    AND country != ''\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        (SELECT total_duration FROM get_duration) as total_duration,\n        (SELECT unique_countries FROM get_countries) as unique_countries\n\n\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_total_viewer_duration.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        SUM(duration) AS sum_duration\n    FROM\n        page_views__v3\n    WHERE\n        viewId IN splitByChar(',', {{ String(viewIds, required=true) }})\n        AND time >= {{ Int64(since, required=true) }} \n"
  },
  {
    "path": "lib/tinybird/endpoints/get_useragent_per_view.pipe",
    "content": "VERSION 3\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        country,\n        city,\n        browser,\n        os,\n        device\n    FROM\n        pm_click_events__v1\n    WHERE\n        view_id = {{ String(viewId, required=True) }}\n    LIMIT 1\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_video_events_by_document.pipe",
    "content": "VERSION 1\n\nDESCRIPTION >\n    Get all video views for a specific document\n\nNODE get_document_video_views\nSQL >\n    %\n    SELECT\n        timestamp,\n        view_id,\n        event_type,\n        start_time,\n        end_time,\n        playback_rate,\n        volume,\n        is_muted,\n        is_focused,\n        is_fullscreen\n    FROM video_views\n    WHERE document_id = {{String(document_id, required=True)}}\n    ORDER BY timestamp ASC\n\n\n\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_video_events_by_view.pipe",
    "content": "VERSION 1\n\nDESCRIPTION >\n    Get all video events for a specific view \n\nNODE get_view_video_events\nSQL >\n    %\n    SELECT\n        timestamp,\n        event_type,\n        start_time,\n        end_time,\n        playback_rate,\n        volume,\n        is_muted,\n        is_focused,\n        is_fullscreen\n    FROM video_views\n    WHERE document_id = {{String(document_id, required=True)}}\n    AND view_id = {{String(view_id, required=True)}}\n    ORDER BY timestamp ASC\n\n\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_view_completion_stats.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        viewId,\n        versionNumber,\n        COUNT(DISTINCT pageNumber) as pages_viewed\n    FROM page_views__v3\n    WHERE\n        documentId = {{ String(documentId, required=True) }}\n        AND viewId NOT IN splitByChar(',', {{ String(excludedViewIds, required=True) }})\n        AND time >= {{ Int64(since, required=True) }}\n    GROUP BY viewId, versionNumber\n"
  },
  {
    "path": "lib/tinybird/endpoints/get_webhook_events.pipe",
    "content": "VERSION 1\n\nNODE endpoint\nSQL >\n    %\n    SELECT\n        *\n    FROM\n        webhook_events__v1\n    WHERE\n        webhook_id = {{ String(webhookId, required=True) }}\n    ORDER BY\n        timestamp DESC\n    LIMIT 100\n"
  },
  {
    "path": "lib/tinybird/index.ts",
    "content": "export * from \"./pipes\";\nexport * from \"./publish\";\n"
  },
  {
    "path": "lib/tinybird/pipes.ts",
    "content": "import { Tinybird } from \"@chronark/zod-bird\";\nimport { z } from \"zod\";\n\nimport { VIDEO_EVENT_TYPES } from \"../constants\";\nimport { WEBHOOK_TRIGGERS } from \"../webhook/constants\";\n\nconst tb = new Tinybird({ token: process.env.TINYBIRD_TOKEN! });\n\nexport const getTotalAvgPageDuration = tb.buildPipe({\n  pipe: \"get_total_average_page_duration__v5\",\n  parameters: z.object({\n    documentId: z.string(),\n    excludedLinkIds: z.string().describe(\"Comma separated linkIds\"),\n    excludedViewIds: z.string().describe(\"Comma separated viewIds\"),\n    since: z.number(),\n  }),\n  data: z.object({\n    versionNumber: z.number().int(),\n    pageNumber: z.string(),\n    avg_duration: z.number(),\n  }),\n});\n\nexport const getViewPageDuration = tb.buildPipe({\n  pipe: \"get_page_duration_per_view__v5\",\n  parameters: z.object({\n    documentId: z.string(),\n    viewId: z.string(),\n    since: z.number(),\n    until: z.number().optional(),\n  }),\n  data: z.object({\n    pageNumber: z.string(),\n    sum_duration: z.number(),\n  }),\n});\n\nexport const getViewCompletionStats = tb.buildPipe({\n  pipe: \"get_view_completion_stats__v1\",\n  parameters: z.object({\n    documentId: z.string(),\n    excludedViewIds: z.string().describe(\"Comma separated viewIds\"),\n    since: z.number(),\n  }),\n  data: z.object({\n    viewId: z.string(),\n    versionNumber: z.number().int(),\n    pages_viewed: z.number(),\n  }),\n});\n\nexport const getTotalDocumentDuration = tb.buildPipe({\n  pipe: \"get_total_document_duration__v1\",\n  parameters: z.object({\n    documentId: z.string(),\n    excludedLinkIds: z.string().describe(\"Comma separated linkIds\"),\n    excludedViewIds: z.string().describe(\"Comma separated viewIds\"),\n    since: z.number(),\n    until: z.number().optional(),\n  }),\n  data: z.object({\n    sum_duration: z.number(),\n  }),\n});\n\nexport const getTotalLinkDuration = tb.buildPipe({\n  pipe: \"get_total_link_duration__v1\",\n  parameters: z.object({\n    linkId: z.string(),\n    documentId: z.string(),\n    excludedViewIds: z.string().describe(\"Comma separated viewIds\"),\n    since: z.number(),\n    until: z.number().optional(),\n  }),\n  data: z.object({\n    sum_duration: z.number(),\n    view_count: z.number(),\n  }),\n});\n\nexport const getTotalViewerDuration = tb.buildPipe({\n  pipe: \"get_total_viewer_duration__v1\",\n  parameters: z.object({\n    viewIds: z.string().describe(\"Comma separated viewIds\"),\n    since: z.number(),\n    until: z.number().optional(),\n  }),\n  data: z.object({\n    sum_duration: z.number(),\n  }),\n});\n\nexport const getViewUserAgent_v2 = tb.buildPipe({\n  pipe: \"get_useragent_per_view__v2\",\n  parameters: z.object({\n    documentId: z.string(),\n    viewId: z.string(),\n    since: z.number(),\n  }),\n  data: z.object({\n    country: z.string(),\n    city: z.string(),\n    browser: z.string(),\n    os: z.string(),\n    device: z.string(),\n  }),\n});\n\nexport const getViewUserAgent = tb.buildPipe({\n  pipe: \"get_useragent_per_view__v3\",\n  parameters: z.object({\n    viewId: z.string(),\n  }),\n  data: z.object({\n    country: z.string(),\n    city: z.string(),\n    browser: z.string(),\n    os: z.string(),\n    device: z.string(),\n  }),\n});\n\nexport const getTotalDataroomDuration = tb.buildPipe({\n  pipe: \"get_total_dataroom_duration__v1\",\n  parameters: z.object({\n    dataroomId: z.string(),\n    excludedLinkIds: z.array(z.string()),\n    excludedViewIds: z.array(z.string()),\n    since: z.number(),\n  }),\n  data: z.object({\n    viewId: z.string(),\n    sum_duration: z.number(),\n  }),\n});\n\nexport const getDocumentDurationPerViewer = tb.buildPipe({\n  pipe: \"get_document_duration_per_viewer__v1\",\n  parameters: z.object({\n    documentId: z.string(),\n    viewIds: z.string().describe(\"Comma separated viewIds\"),\n  }),\n  data: z.object({\n    sum_duration: z.number(),\n  }),\n});\n\nexport const getWebhookEvents = tb.buildPipe({\n  pipe: \"get_webhook_events__v1\",\n  parameters: z.object({\n    webhookId: z.string(),\n  }),\n  data: z.object({\n    event_id: z.string(),\n    webhook_id: z.string(),\n    message_id: z.string(), // QStash message ID\n    event: z.enum(WEBHOOK_TRIGGERS),\n    url: z.string(),\n    http_status: z.number(),\n    request_body: z.string(),\n    response_body: z.string(),\n    timestamp: z.string(),\n  }),\n});\n\nexport const getVideoEventsByDocument = tb.buildPipe({\n  pipe: \"get_video_events_by_document__v1\",\n  parameters: z.object({\n    document_id: z.string(),\n  }),\n  data: z.object({\n    timestamp: z.string(),\n    view_id: z.string(),\n    event_type: z.enum(VIDEO_EVENT_TYPES),\n    start_time: z.number(),\n    end_time: z.number(),\n    playback_rate: z.number(),\n    volume: z.number(),\n    is_muted: z.number(),\n    is_focused: z.number(),\n    is_fullscreen: z.number(),\n  }),\n});\n\nexport const getVideoEventsByView = tb.buildPipe({\n  pipe: \"get_video_events_by_view__v1\",\n  parameters: z.object({\n    document_id: z.string(),\n    view_id: z.string(),\n  }),\n  data: z.object({\n    timestamp: z.string(),\n    event_type: z.string(),\n    start_time: z.number(),\n    end_time: z.number(),\n  }),\n});\n\nexport const getClickEventsByView = tb.buildPipe({\n  pipe: \"get_click_events_by_view__v1\",\n  parameters: z.object({\n    document_id: z.string(),\n    view_id: z.string(),\n  }),\n  data: z.object({\n    timestamp: z.string(),\n    document_id: z.string(),\n    dataroom_id: z.string().nullable(),\n    view_id: z.string(),\n    page_number: z.string(),\n    version_number: z.number(),\n    href: z.string(),\n  }),\n});\n\nexport const getDataroomViewDocumentStats = tb.buildPipe({\n  pipe: \"get_dataroom_view_document_stats__v1\",\n  parameters: z.object({\n    viewIds: z.string().describe(\"Comma separated viewIds\"),\n  }),\n  data: z.object({\n    viewId: z.string(),\n    documentId: z.string(),\n    sum_duration: z.number(),\n    pages_viewed: z.number(),\n  }),\n});\n\nexport const getTotalTeamDuration = tb.buildPipe({\n  pipe: \"get_total_team_duration__v1\",\n  parameters: z.object({\n    documentIds: z.string().describe(\"Comma separated documentIds\"),\n    since: z.number(),\n    until: z.number(),\n  }),\n  data: z.object({\n    total_duration: z.number(),\n    unique_countries: z.array(z.string()),\n  }),\n});\n"
  },
  {
    "path": "lib/tinybird/publish.ts",
    "content": "import { Tinybird } from \"@chronark/zod-bird\";\nimport { z } from \"zod\";\n\nimport { VIDEO_EVENT_TYPES } from \"../constants\";\nimport { WEBHOOK_TRIGGERS } from \"../webhook/constants\";\n\nconst tb = new Tinybird({ token: process.env.TINYBIRD_TOKEN! });\n\nexport const publishPageView = tb.buildIngestEndpoint({\n  datasource: \"page_views__v3\",\n  event: z.object({\n    id: z.string(),\n    linkId: z.string(),\n    documentId: z.string(),\n    viewId: z.string(),\n    dataroomId: z.string().nullable().optional(),\n    versionNumber: z.number().int().min(1).max(65535).optional().default(1),\n    time: z.number().int(),\n    duration: z.number().int(),\n    pageNumber: z.string(),\n    country: z.string().optional().default(\"Unknown\"),\n    city: z.string().optional().default(\"Unknown\"),\n    region: z.string().optional().default(\"Unknown\"),\n    latitude: z.string().optional().default(\"Unknown\"),\n    longitude: z.string().optional().default(\"Unknown\"),\n    ua: z.string().optional().default(\"Unknown\"),\n    browser: z.string().optional().default(\"Unknown\"),\n    browser_version: z.string().optional().default(\"Unknown\"),\n    engine: z.string().optional().default(\"Unknown\"),\n    engine_version: z.string().optional().default(\"Unknown\"),\n    os: z.string().optional().default(\"Unknown\"),\n    os_version: z.string().optional().default(\"Unknown\"),\n    device: z.string().optional().default(\"Desktop\"),\n    device_vendor: z.string().optional().default(\"Unknown\"),\n    device_model: z.string().optional().default(\"Unknown\"),\n    cpu_architecture: z.string().optional().default(\"Unknown\"),\n    bot: z.boolean().optional(),\n    referer: z.string().optional().default(\"(direct)\"),\n    referer_url: z.string().optional().default(\"(direct)\"),\n  }),\n});\n\nexport const recordWebhookEvent = tb.buildIngestEndpoint({\n  datasource: \"webhook_events__v1\",\n  event: z.object({\n    event_id: z.string(),\n    webhook_id: z.string(),\n    message_id: z.string(), // QStash message ID\n    event: z.enum(WEBHOOK_TRIGGERS),\n    url: z.string(),\n    http_status: z.number(),\n    request_body: z.string(),\n    response_body: z.string(),\n  }),\n});\n\nexport const recordVideoView = tb.buildIngestEndpoint({\n  datasource: \"video_views__v1\",\n  event: z.object({\n    timestamp: z.string(),\n    id: z.string(),\n    link_id: z.string(),\n    document_id: z.string(),\n    view_id: z.string(),\n    dataroom_id: z.string().nullable(),\n    version_number: z.number(),\n    event_type: z.enum(VIDEO_EVENT_TYPES),\n    start_time: z.number(),\n    end_time: z.number().optional(),\n    playback_rate: z.number(),\n    volume: z.number(),\n    is_muted: z.number(),\n    is_focused: z.number(),\n    is_fullscreen: z.number(),\n    country: z.string().optional().default(\"Unknown\"),\n    city: z.string().optional().default(\"Unknown\"),\n    region: z.string().optional().default(\"Unknown\"),\n    latitude: z.string().optional().default(\"Unknown\"),\n    longitude: z.string().optional().default(\"Unknown\"),\n    ua: z.string().optional().default(\"Unknown\"),\n    browser: z.string().optional().default(\"Unknown\"),\n    browser_version: z.string().optional().default(\"Unknown\"),\n    engine: z.string().optional().default(\"Unknown\"),\n    engine_version: z.string().optional().default(\"Unknown\"),\n    os: z.string().optional().default(\"Unknown\"),\n    os_version: z.string().optional().default(\"Unknown\"),\n    device: z.string().optional().default(\"Desktop\"),\n    device_vendor: z.string().optional().default(\"Unknown\"),\n    device_model: z.string().optional().default(\"Unknown\"),\n    cpu_architecture: z.string().optional().default(\"Unknown\"),\n    bot: z.boolean().optional(),\n    referer: z.string().optional().default(\"(direct)\"),\n    referer_url: z.string().optional().default(\"(direct)\"),\n    ip_address: z.string().nullable(),\n  }),\n});\n\n// Click event tracking when user clicks a link within a document\nexport const recordClickEvent = tb.buildIngestEndpoint({\n  datasource: \"click_events__v1\",\n  event: z.object({\n    timestamp: z.string(),\n    event_id: z.string(),\n    session_id: z.string(),\n    link_id: z.string(),\n    document_id: z.string(),\n    view_id: z.string(),\n    page_number: z.string(),\n    href: z.string(),\n    version_number: z.number(),\n    dataroom_id: z.string().nullable(),\n  }),\n});\n\n// Event track when a visitor opens a link\nexport const recordLinkViewTB = tb.buildIngestEndpoint({\n  datasource: \"pm_click_events__v1\",\n  event: z.object({\n    timestamp: z.string(),\n    click_id: z.string(),\n    view_id: z.string(),\n    link_id: z.string(),\n    document_id: z.string().nullable(),\n    dataroom_id: z.string().nullable(),\n    continent: z.string().optional().default(\"Unknown\"),\n    country: z.string().optional().default(\"Unknown\"),\n    city: z.string().optional().default(\"Unknown\"),\n    region: z.string().optional().default(\"Unknown\"),\n    latitude: z.string().optional().default(\"Unknown\"),\n    longitude: z.string().optional().default(\"Unknown\"),\n    device: z.string().optional().default(\"Desktop\"),\n    device_model: z.string().optional().default(\"Unknown\"),\n    device_vendor: z.string().optional().default(\"Unknown\"),\n    browser: z.string().optional().default(\"Unknown\"),\n    browser_version: z.string().optional().default(\"Unknown\"),\n    os: z.string().optional().default(\"Unknown\"),\n    os_version: z.string().optional().default(\"Unknown\"),\n    engine: z.string().optional().default(\"Unknown\"),\n    engine_version: z.string().optional().default(\"Unknown\"),\n    cpu_architecture: z.string().optional().default(\"Unknown\"),\n    ua: z.string().optional().default(\"Unknown\"),\n    bot: z.boolean().optional(),\n    referer: z.string().optional().default(\"(direct)\"),\n    referer_url: z.string().optional().default(\"(direct)\"),\n    ip_address: z.string().nullable(),\n  }),\n});\n"
  },
  {
    "path": "lib/tracking/record-link-view.ts",
    "content": "import { NextRequest, userAgent } from \"next/server\";\n\nimport { geolocation, ipAddress } from \"@vercel/functions\";\n\nimport { recordLinkViewTB } from \"@/lib/tinybird\";\nimport { isBot } from \"@/lib/utils/user-agent\";\n\nimport sendNotification from \"../api/notification-helper\";\nimport { sendLinkViewWebhook } from \"../api/views/send-webhook-event\";\nimport { EU_COUNTRY_CODES } from \"../constants\";\nimport { capitalize, getDomainWithoutWWW } from \"../utils\";\nimport { LOCALHOST_GEO_DATA, LOCALHOST_IP } from \"../utils/geo\";\n\nexport async function recordLinkView({\n  req,\n  clickId,\n  viewId,\n  linkId,\n  teamId,\n  documentId,\n  dataroomId,\n  enableNotification,\n  isPaused,\n}: {\n  req: NextRequest;\n  clickId: string;\n  viewId: string;\n  linkId: string;\n  teamId: string;\n  documentId?: string;\n  dataroomId?: string;\n  enableNotification: boolean | null;\n  isPaused: boolean;\n}) {\n  const ua = userAgent(req);\n  const bot = isBot(ua.ua);\n\n  // don't track clicks from bots\n  if (bot) {\n    return null;\n  }\n\n  const ip = process.env.VERCEL === \"1\" ? ipAddress(req) : LOCALHOST_IP;\n\n  // get continent, region & geolocation data\n  // interesting, geolocation().region is Vercel's edge region – NOT the actual region\n  // so we use the x-vercel-ip-country-region or geolocation().countryRegion to get the actual region\n  const { continent, region } =\n    process.env.VERCEL === \"1\"\n      ? {\n          continent: req.headers.get(\"x-vercel-ip-continent\"),\n          region: geolocation(req).countryRegion,\n        }\n      : LOCALHOST_GEO_DATA;\n\n  const geo =\n    process.env.VERCEL === \"1\" ? geolocation(req) : LOCALHOST_GEO_DATA;\n\n  const isEuCountry = geo.country && EU_COUNTRY_CODES.includes(geo.country);\n\n  const referer = req.headers.get(\"referer\");\n  const refererDomain = referer ? getDomainWithoutWWW(referer) : \"(direct)\";\n\n  const clickData = {\n    timestamp: new Date(Date.now()).toISOString(),\n    click_id: clickId,\n    view_id: viewId,\n    link_id: linkId,\n    document_id: documentId || null,\n    dataroom_id: dataroomId || null,\n    continent: continent || \"\",\n    country: geo.country || \"Unknown\",\n    region: region || \"Unknown\",\n    city: geo.city || \"Unknown\",\n    latitude: geo.latitude || \"Unknown\",\n    longitude: geo.longitude || \"Unknown\",\n    device: ua.device.type ? capitalize(ua.device.type) : \"Desktop\",\n    device_vendor: ua.device.vendor || \"Unknown\",\n    device_model: ua.device.model || \"Unknown\",\n    browser: ua.browser.name || \"Unknown\",\n    browser_version: ua.browser.version || \"Unknown\",\n    engine: ua.engine.name || \"Unknown\",\n    engine_version: ua.engine.version || \"Unknown\",\n    os: ua.os.name || \"Unknown\",\n    os_version: ua.os.version || \"Unknown\",\n    cpu_architecture: ua.cpu?.architecture || \"Unknown\",\n    ua: ua.ua || \"Unknown\",\n    bot: ua.isBot,\n    referer: refererDomain,\n    referer_url: referer || \"(direct)\",\n    ip_address:\n      // only record IP if it's a valid IP and not from a EU country\n      typeof ip === \"string\" && ip.trim().length > 0 && !isEuCountry\n        ? ip\n        : null,\n  };\n\n  const locationData = {\n    continent,\n    country: geo.country || \"Unknown\",\n    region: region || \"Unknown\",\n    city: geo.city || \"Unknown\",\n  };\n\n  const [, ,] = await Promise.all([\n    // record link view in Tinybird\n    recordLinkViewTB(clickData),\n\n    // send email notification\n    enableNotification ? sendNotification({ viewId, locationData }) : null,\n\n    // send webhook event\n    !isPaused\n      ? sendLinkViewWebhook({\n          teamId,\n          clickData,\n        })\n      : null,\n  ]);\n\n  return clickData;\n}\n"
  },
  {
    "path": "lib/tracking/safe-page-view-tracker.ts",
    "content": "import { useRef, useEffect, useCallback, useState } from \"react\";\nimport { trackPageViewReliably } from \"../utils/reliable-tracking\";\n\ninterface TrackingData {\n    linkId: string;\n    documentId: string;\n    viewId?: string;\n    duration: number;\n    pageNumber: number;\n    versionNumber: number;\n    dataroomId?: string;\n    isPreview?: boolean;\n    setViewedPages?: React.Dispatch<\n        React.SetStateAction<{ pageNumber: number; duration: number }[]>\n    >;\n}\n\ninterface TrackingOptions {\n    intervalTracking?: boolean;\n    intervalDuration?: number; // in milliseconds, default 10 seconds\n    activityTracking?: boolean;\n    inactivityThreshold?: number; // in milliseconds, default 1 minute\n    enableActivityDetection?: boolean;\n    externalStartTimeRef?: React.MutableRefObject<number>; // Optional external start time ref\n}\n\nexport function useSafePageViewTracker(options: TrackingOptions = {}) {\n    const {\n        intervalTracking = true,\n        intervalDuration = 10000, // 10 seconds\n        activityTracking = true,\n        inactivityThreshold = 60000, // 1 minute default\n        enableActivityDetection = true,\n        externalStartTimeRef,\n    } = options;\n\n    const hasTrackedUnloadRef = useRef<boolean>(false);\n    const isTrackingRef = useRef<boolean>(false);\n\n    // Activity tracking refs\n    const lastActivityTimeRef = useRef<number>(Date.now());\n    const isActiveRef = useRef<boolean>(true);\n    const intervalIdRef = useRef<NodeJS.Timeout | null>(null);\n    const inactivityTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n    const accumulatedActiveTimeRef = useRef<number>(0);\n\n    // Add state for inactive UI display\n    const [isInactive, setIsInactive] = useState<boolean>(false);\n\n    // Use external start time ref if provided, otherwise create internal one\n    const internalStartTimeRef = useRef<number>(Date.now());\n    const startTimeRef = externalStartTimeRef || internalStartTimeRef;\n    const lastIntervalTimeRef = useRef<number>(startTimeRef.current);\n\n    // Activity detection\n    const updateActivity = useCallback(() => {\n        const now = Date.now();\n\n        if (!isActiveRef.current) {\n            // User became active again, reset tracking\n            isActiveRef.current = true;\n            lastIntervalTimeRef.current = now;\n            setIsInactive(false); // Clear inactive state\n        }\n\n        lastActivityTimeRef.current = now;\n\n        // Clear existing inactivity timeout\n        if (inactivityTimeoutRef.current) {\n            clearTimeout(inactivityTimeoutRef.current);\n        }\n\n        // Set new inactivity timeout\n        if (activityTracking) {\n            inactivityTimeoutRef.current = setTimeout(() => {\n                if (isActiveRef.current) {\n                    // User became inactive, accumulate the active time\n                    const activeTime = Date.now() - lastIntervalTimeRef.current;\n                    accumulatedActiveTimeRef.current += activeTime;\n                    isActiveRef.current = false;\n                    setIsInactive(true); // Set inactive state for UI\n                }\n            }, inactivityThreshold);\n        }\n    }, [activityTracking, inactivityThreshold]);\n\n    // Start initial inactivity timeout\n    const startInactivityTimeout = useCallback(() => {\n        if (!activityTracking) return;\n\n        // Clear any existing timeout\n        if (inactivityTimeoutRef.current) {\n            clearTimeout(inactivityTimeoutRef.current);\n        }\n\n        // Set initial inactivity timeout\n        inactivityTimeoutRef.current = setTimeout(() => {\n            if (isActiveRef.current) {\n                // User became inactive, accumulate the active time\n                const activeTime = Date.now() - lastIntervalTimeRef.current;\n                accumulatedActiveTimeRef.current += activeTime;\n                isActiveRef.current = false;\n                setIsInactive(true); // Set inactive state for UI\n            }\n        }, inactivityThreshold);\n    }, [activityTracking, inactivityThreshold]);\n\n    // Setup activity listeners\n    useEffect(() => {\n        if (!enableActivityDetection) return;\n\n        const events = ['mousedown', 'mousemove', 'keydown', 'keyup', 'scroll', 'touchstart', 'click'];\n\n        events.forEach(event => {\n            document.addEventListener(event, updateActivity, true);\n        });\n\n        // Start the initial inactivity timeout when activity detection is enabled\n        startInactivityTimeout();\n\n        return () => {\n            events.forEach(event => {\n                document.removeEventListener(event, updateActivity, true);\n            });\n        };\n    }, [updateActivity, enableActivityDetection, startInactivityTimeout]);\n\n    const trackPageViewSafely = useCallback(async (\n        data: TrackingData,\n        useBeacon: boolean = false,\n    ): Promise<void> => {\n        // Prevent concurrent tracking calls\n        if (isTrackingRef.current) return;\n\n        // Prevent duplicate unload tracking\n        if (useBeacon && hasTrackedUnloadRef.current) return;\n\n        isTrackingRef.current = true;\n\n        if (useBeacon) {\n            hasTrackedUnloadRef.current = true;\n        }\n\n        try {\n            // Update viewed pages if setter is provided\n            if (data.setViewedPages) {\n                data.setViewedPages((prevViewedPages) =>\n                    prevViewedPages.map((page) =>\n                        page.pageNumber === data.pageNumber\n                            ? { ...page, duration: page.duration + data.duration }\n                            : page,\n                    ),\n                );\n            }\n\n            // Track the page view\n            await trackPageViewReliably(\n                {\n                    linkId: data.linkId,\n                    documentId: data.documentId,\n                    viewId: data.viewId,\n                    duration: data.duration,\n                    pageNumber: data.pageNumber,\n                    versionNumber: data.versionNumber,\n                    dataroomId: data.dataroomId,\n                    isPreview: data.isPreview,\n                },\n                useBeacon,\n            );\n        } finally {\n            isTrackingRef.current = false;\n        }\n    }, []);\n\n    const getActiveDuration = useCallback(() => {\n\n        if (!activityTracking) {\n            return Date.now() - startTimeRef.current;\n        }\n\n        let totalActiveTime = accumulatedActiveTimeRef.current;\n\n        if (isActiveRef.current) {\n            // Add current active session time\n            totalActiveTime += Date.now() - lastIntervalTimeRef.current;\n        }\n\n        return totalActiveTime;\n    }, [activityTracking]);\n\n    const startIntervalTracking = useCallback((trackingData: Omit<TrackingData, 'duration'>) => {\n        if (!intervalTracking) return;\n\n        // Clear any existing interval\n        if (intervalIdRef.current) {\n            clearInterval(intervalIdRef.current);\n        }\n\n        // Reset timing references\n        const now = Date.now();\n        startTimeRef.current = now;\n        lastIntervalTimeRef.current = now;\n        accumulatedActiveTimeRef.current = 0;\n        isActiveRef.current = true;\n\n        // Start the inactivity timeout for activity tracking\n        startInactivityTimeout();\n\n        intervalIdRef.current = setInterval(() => {\n            const activeDuration = getActiveDuration();\n\n            // Only track if there's meaningful active time (at least 1 second)\n            if (activeDuration >= 1000) {\n                trackPageViewSafely({\n                    ...trackingData,\n                    duration: activeDuration,\n                }, false);\n\n                // Reset counters for next interval\n                accumulatedActiveTimeRef.current = 0;\n                lastIntervalTimeRef.current = Date.now();\n                startTimeRef.current = Date.now();\n            }\n        }, intervalDuration);\n    }, [intervalTracking, intervalDuration, getActiveDuration, trackPageViewSafely, startInactivityTimeout]);\n\n    const stopIntervalTracking = useCallback(() => {\n        if (intervalIdRef.current) {\n            clearInterval(intervalIdRef.current);\n            intervalIdRef.current = null;\n        }\n        if (inactivityTimeoutRef.current) {\n            clearTimeout(inactivityTimeoutRef.current);\n            inactivityTimeoutRef.current = null;\n        }\n    }, []);\n\n    const resetTrackingState = useCallback(() => {\n        hasTrackedUnloadRef.current = false;\n        isTrackingRef.current = false;\n        lastActivityTimeRef.current = Date.now();\n        isActiveRef.current = true;\n        accumulatedActiveTimeRef.current = 0;\n        setIsInactive(false); // Clear inactive state\n\n        const now = Date.now();\n        startTimeRef.current = now;\n        lastIntervalTimeRef.current = now;\n\n        // Clear timeouts\n        if (inactivityTimeoutRef.current) {\n            clearTimeout(inactivityTimeoutRef.current);\n            inactivityTimeoutRef.current = null;\n        }\n\n        // Restart inactivity timeout if activity tracking is enabled\n        startInactivityTimeout();\n    }, [startInactivityTimeout]);\n\n    // TODO: for debugging\n    // const getTrackingState = () => ({\n    //     hasTrackedUnload: hasTrackedUnloadRef.current,\n    //     isTracking: isTrackingRef.current,\n    //     isActive: isActiveRef.current,\n    //     lastActivityTime: lastActivityTimeRef.current,\n    //     accumulatedActiveTime: accumulatedActiveTimeRef.current,\n    // });\n\n    // Cleanup on unmount\n    useEffect(() => {\n        return () => {\n            stopIntervalTracking();\n        };\n    }, [stopIntervalTracking]);\n\n    return {\n        trackPageViewSafely,\n        resetTrackingState,\n        // getTrackingState,\n        startIntervalTracking,\n        stopIntervalTracking,\n        getActiveDuration,\n        updateActivity,\n        isInactive,\n    };\n} "
  },
  {
    "path": "lib/tracking/tracking-config.ts",
    "content": "export const TRACKING_CONFIG = {\n  // Interval tracking settings\n  INTERVAL_TRACKING_ENABLED: true,\n  INTERVAL_DURATION: 10000, // 10 seconds (in milliseconds)\n\n  // Activity tracking settings\n  ACTIVITY_TRACKING_ENABLED: true,\n  INACTIVITY_THRESHOLD: 5 * 60000, // 5 minutes (in milliseconds)\n\n  // Activity detection settings\n  ACTIVITY_DETECTION_ENABLED: true,\n\n  // Minimum duration to track (prevents very short sessions)\n  MIN_TRACKING_DURATION: 1000, // 1 second (in milliseconds)\n} as const;\n\nexport function getTrackingOptions(\n  overrides: Partial<typeof TRACKING_CONFIG> = {},\n) {\n  return {\n    intervalTracking:\n      overrides.INTERVAL_TRACKING_ENABLED ??\n      TRACKING_CONFIG.INTERVAL_TRACKING_ENABLED,\n    intervalDuration:\n      overrides.INTERVAL_DURATION ?? TRACKING_CONFIG.INTERVAL_DURATION,\n    activityTracking:\n      overrides.ACTIVITY_TRACKING_ENABLED ??\n      TRACKING_CONFIG.ACTIVITY_TRACKING_ENABLED,\n    inactivityThreshold:\n      overrides.INACTIVITY_THRESHOLD ?? TRACKING_CONFIG.INACTIVITY_THRESHOLD,\n    enableActivityDetection:\n      overrides.ACTIVITY_DETECTION_ENABLED ??\n      TRACKING_CONFIG.ACTIVITY_DETECTION_ENABLED,\n  };\n}\n"
  },
  {
    "path": "lib/tracking/video-tracking.ts",
    "content": "import { VIDEO_EVENT_TYPES } from \"@/lib/constants\";\n\ntype VideoTrackingEvent = {\n  linkId: string;\n  documentId: string;\n  viewId?: string;\n  dataroomId?: string;\n  versionNumber: number;\n  startTime: number;\n  endTime?: number;\n  playbackRate: number;\n  volume: number;\n  isMuted: boolean;\n  isFocused: boolean;\n  isFullscreen: boolean;\n  eventType: (typeof VIDEO_EVENT_TYPES)[number];\n  isPreview?: boolean;\n};\n\ntype EventType = (typeof VIDEO_EVENT_TYPES)[number];\n\n// Simple debounce implementation with immediate option\nfunction debounce<T extends (...args: any[]) => any>(\n  func: T,\n  wait: number,\n  immediate = false,\n): (...args: Parameters<T>) => void {\n  let timeout: NodeJS.Timeout | null = null;\n  let lastArgs: Parameters<T> | null = null;\n\n  return (...args: Parameters<T>) => {\n    lastArgs = args;\n\n    if (timeout) {\n      clearTimeout(timeout);\n    }\n\n    if (immediate && !timeout) {\n      func(...args);\n    }\n\n    timeout = setTimeout(() => {\n      if (!immediate && lastArgs) {\n        func(...lastArgs);\n      }\n      timeout = null;\n      lastArgs = null;\n    }, wait);\n  };\n}\n\nclass VideoTracker {\n  private videoElement: HTMLVideoElement;\n  private lastTrackingTime: number = 0;\n  private isPlaying: boolean = false;\n  private lastTimeUpdate: number = Date.now();\n  private trackingConfig: Omit<\n    VideoTrackingEvent,\n    \"startTime\" | \"endTime\" | \"eventType\"\n  >;\n  private lastVolume: number = 1;\n  private hasLoaded: boolean = false;\n  private hasTrackedUnload: boolean = false;\n  private debouncedTrackEvent: Record<\n    EventType,\n    (eventType: EventType, endTime?: number) => void\n  >;\n  private boundEventListeners: { [key: string]: EventListener } = {};\n\n  constructor(\n    videoElement: HTMLVideoElement,\n    config: Omit<VideoTrackingEvent, \"startTime\" | \"endTime\" | \"eventType\">,\n  ) {\n    this.videoElement = videoElement;\n    this.trackingConfig = config;\n    this.lastVolume = videoElement.volume;\n\n    // Create a base debounced function\n    const createDebouncedHandler = (wait: number, immediate = false) =>\n      debounce(\n        (eventType: EventType, endTime?: number) => {\n          console.log(\n            `[VideoTracker] Tracking event: ${eventType} - ${endTime}`,\n          );\n          return this.trackEvent(eventType, endTime);\n        },\n        wait,\n        immediate,\n      );\n\n    // Initialize debounced handlers with different wait times\n    this.debouncedTrackEvent = {\n      loaded: createDebouncedHandler(1000, true), // Immediate for load events\n      played: createDebouncedHandler(1000),\n      seeked: createDebouncedHandler(500),\n      rate_changed: createDebouncedHandler(500),\n      volume_up: createDebouncedHandler(500),\n      volume_down: createDebouncedHandler(500),\n      muted: createDebouncedHandler(500, true),\n      unmuted: createDebouncedHandler(500, true),\n      focus: createDebouncedHandler(500, true),\n      blur: createDebouncedHandler(500, true),\n      enterfullscreen: createDebouncedHandler(500, true),\n      exitfullscreen: createDebouncedHandler(500, true),\n    };\n\n    this.setupEventListeners();\n  }\n\n  private addEventListenerWithCleanup(\n    target: EventTarget,\n    type: string,\n    listener: EventListener,\n  ) {\n    // Store the bound listener for cleanup\n    this.boundEventListeners[type] = listener;\n    target.addEventListener(type, listener);\n  }\n\n  private async trackEvent(eventType: EventType, endTime?: number, useBeacon: boolean = false) {\n    if (this.trackingConfig.isPreview) return;\n\n    const currentTime = this.videoElement.currentTime;\n    const payload = JSON.stringify({\n      timestamp: new Date().toISOString(),\n      linkId: this.trackingConfig.linkId,\n      documentId: this.trackingConfig.documentId,\n      viewId: this.trackingConfig.viewId,\n      dataroomId: this.trackingConfig.dataroomId,\n      versionNumber: this.trackingConfig.versionNumber,\n      startTime: Math.round(this.lastTrackingTime),\n      endTime: endTime ? Math.round(endTime) : Math.round(currentTime),\n      playbackRate: this.videoElement.playbackRate,\n      volume: this.videoElement.volume,\n      isMuted: this.trackingConfig.isMuted,\n      isFocused: this.trackingConfig.isFocused,\n      isFullscreen: this.trackingConfig.isFullscreen,\n      eventType,\n    });\n\n    const url = \"/api/record_video_view\";\n\n    // Use sendBeacon for maximum reliability during page unload\n    if (useBeacon && navigator.sendBeacon) {\n      try {\n        const blob = new Blob([payload], { type: \"application/json\" });\n        const success = navigator.sendBeacon(url, blob);\n        if (success) {\n          this.lastTrackingTime = currentTime;\n          return;\n        }\n      } catch (error) {\n        console.warn(\"sendBeacon failed:\", error);\n      }\n    }\n\n    // Use fetch with keepalive for better reliability\n    try {\n      const response = await fetch(url, {\n        method: \"POST\",\n        body: payload,\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        keepalive: true, // Critical for page unload scenarios\n      });\n\n      if (response.ok) {\n        this.lastTrackingTime = currentTime;\n        return;\n      }\n    } catch (error) {\n      console.warn(\"Fetch with keepalive failed:\", error);\n    }\n\n    // Fallback to sendBeacon if fetch failed\n    if (!useBeacon && navigator.sendBeacon) {\n      try {\n        const blob = new Blob([payload], { type: \"application/json\" });\n        const success = navigator.sendBeacon(url, blob);\n        if (success) {\n          this.lastTrackingTime = currentTime;\n          return;\n        }\n      } catch (error) {\n        console.warn(\"Fallback sendBeacon failed:\", error);\n      }\n    }\n\n    this.lastTrackingTime = currentTime;\n  }\n\n  private setupEventListeners() {\n    // Loading events - only track the first load event\n    const handleLoad = () => {\n      if (!this.hasLoaded) {\n        this.hasLoaded = true;\n        this.debouncedTrackEvent.loaded(\"loaded\");\n      }\n    };\n\n    this.addEventListenerWithCleanup(\n      this.videoElement,\n      \"loadedmetadata\",\n      handleLoad,\n    );\n    this.addEventListenerWithCleanup(this.videoElement, \"canplay\", handleLoad);\n\n    let lastPlayTime = 0;\n\n    // Playback events\n    this.addEventListenerWithCleanup(this.videoElement, \"play\", () => {\n      const now = Date.now();\n      if (this.isPlaying || now - lastPlayTime < 1000) return;\n\n      this.isPlaying = true;\n      this.lastTrackingTime = this.videoElement.currentTime;\n      lastPlayTime = now;\n      // Track play event with a slight delay to ensure we have the correct state\n      setTimeout(() => {\n        this.debouncedTrackEvent.played(\"played\");\n      }, 100);\n      this.startPeriodicTracking();\n    });\n\n    let lastPauseTime = 0;\n\n    this.addEventListenerWithCleanup(this.videoElement, \"pause\", () => {\n      const now = Date.now();\n      if (!this.isPlaying || now - lastPauseTime < 1000) return;\n\n      this.isPlaying = false;\n      lastPauseTime = now;\n      this.stopPeriodicTracking();\n      // Track pause event with a slight delay to ensure we have the correct state\n      setTimeout(() => {\n        this.debouncedTrackEvent.played(\"played\");\n      }, 100);\n    });\n\n    let seekStartTime: number | null = null;\n    let isSeeking = false;\n\n    this.addEventListenerWithCleanup(this.videoElement, \"seeking\", () => {\n      if (isSeeking) return;\n      isSeeking = true;\n      seekStartTime = this.videoElement.currentTime;\n    });\n\n    this.addEventListenerWithCleanup(this.videoElement, \"seeked\", () => {\n      if (!isSeeking) return;\n      if (\n        seekStartTime !== null &&\n        Math.abs(seekStartTime - this.videoElement.currentTime) > 1\n      ) {\n        this.debouncedTrackEvent.seeked(\"seeked\");\n      }\n      seekStartTime = null;\n      isSeeking = false;\n    });\n\n    // Continuous playback tracking using timeupdate\n    let lastTimeupdateTime = 0;\n    this.addEventListenerWithCleanup(this.videoElement, \"timeupdate\", () => {\n      const now = Date.now();\n      if (this.isPlaying && now - lastTimeupdateTime >= 10000) {\n        // For periodic updates, use a non-debounced direct call to ensure timing\n        this.trackEvent(\"played\");\n        lastTimeupdateTime = now;\n      }\n    });\n\n    // Speed events\n    let lastPlaybackRate = this.videoElement.playbackRate;\n    this.addEventListenerWithCleanup(this.videoElement, \"ratechange\", () => {\n      const newRate = this.videoElement.playbackRate;\n      if (Math.abs(newRate - lastPlaybackRate) < 0.01) return; // Only ignore if truly unchanged (floating point comparison)\n\n      this.debouncedTrackEvent.rate_changed(\"rate_changed\");\n      lastPlaybackRate = newRate;\n    });\n\n    // Volume events\n    this.addEventListenerWithCleanup(this.videoElement, \"volumechange\", () => {\n      const prevMuted = this.trackingConfig.isMuted;\n      const prevVolume = this.lastVolume;\n\n      this.trackingConfig.isMuted = this.videoElement.muted;\n      this.lastVolume = this.videoElement.volume;\n\n      if (prevMuted !== this.videoElement.muted) {\n        const eventType = this.videoElement.muted ? \"muted\" : \"unmuted\";\n        this.debouncedTrackEvent[eventType](eventType);\n      } else if (\n        !this.videoElement.muted &&\n        Math.abs(prevVolume - this.videoElement.volume) > 0.05\n      ) {\n        const eventType =\n          this.videoElement.volume > prevVolume ? \"volume_up\" : \"volume_down\";\n        this.debouncedTrackEvent[eventType](eventType);\n      }\n    });\n\n    // Fullscreen events\n    let isInFullscreen = false;\n    this.addEventListenerWithCleanup(document, \"fullscreenchange\", () => {\n      const newIsFullscreen = !!document.fullscreenElement;\n      if (isInFullscreen === newIsFullscreen) return;\n\n      isInFullscreen = newIsFullscreen;\n      this.trackingConfig.isFullscreen = isInFullscreen;\n      const eventType = isInFullscreen ? \"enterfullscreen\" : \"exitfullscreen\";\n      this.debouncedTrackEvent[eventType](eventType);\n    });\n\n    // Handle video end\n    this.addEventListenerWithCleanup(this.videoElement, \"ended\", () => {\n      if (!this.isPlaying) return;\n      this.isPlaying = false;\n      this.debouncedTrackEvent.played(\"played\");\n    });\n  }\n\n  private startPeriodicTracking() {\n    // Reset the timeupdate counter when starting tracking\n    this.lastTimeUpdate = Date.now();\n  }\n\n  private stopPeriodicTracking() {\n    // No need for cleanup as we're using the timeupdate event\n  }\n\n  public updateConfig(\n    config: Partial<\n      Omit<VideoTrackingEvent, \"startTime\" | \"endTime\" | \"eventType\">\n    >,\n  ) {\n    this.trackingConfig = { ...this.trackingConfig, ...config };\n  }\n\n  public trackVisibilityChange(isVisible: boolean) {\n    if (!isVisible && !this.hasTrackedUnload) {\n      this.hasTrackedUnload = true;\n      if (this.isPlaying) {\n        this.trackEvent(\"played\", undefined, true);\n      }\n      this.trackEvent(\"blur\", undefined, true);\n    } else if (isVisible) {\n      this.hasTrackedUnload = false;\n      this.trackEvent(\"focus\");\n    }\n  }\n\n  public cleanup() {\n    // Remove all event listeners\n    Object.entries(this.boundEventListeners).forEach(([type, listener]) => {\n      const target = type === \"fullscreenchange\" ? document : this.videoElement;\n      target.removeEventListener(type, listener);\n    });\n\n    if (this.isPlaying && !this.hasTrackedUnload) {\n      this.hasTrackedUnload = true;\n      this.trackEvent(\"played\", undefined, true);\n    }\n  }\n}\n\nexport const createVideoTracker = (\n  videoElement: HTMLVideoElement,\n  config: Omit<VideoTrackingEvent, \"startTime\" | \"endTime\" | \"eventType\">,\n) => {\n  return new VideoTracker(videoElement, config);\n};\n"
  },
  {
    "path": "lib/trigger/automatic-unpause.ts",
    "content": "import { automaticUnpauseTask } from \"@/ee/features/billing/cancellation/lib/trigger/unpause-task\";\n\nexport { automaticUnpauseTask };\n"
  },
  {
    "path": "lib/trigger/bulk-download.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\n\nimport { sendDownloadReadyEmail } from \"@/lib/emails/send-download-ready-email\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\nimport { constructLinkUrl } from \"@/lib/utils/link-url\";\n\n// Maximum files per batch (Lambda payload limit)\nconst MAX_FILES_PER_BATCH = 500;\n// Maximum size for a single ZIP file (500MB to stay within Vercel's 5min timeout)\n// Lambda needs time to: read from S3 + create ZIP + upload to S3\n// Conservative estimate: ~500MB can be processed in ~2-3 minutes\nconst MAX_ZIP_SIZE_BYTES = 500 * 1024 * 1024;\n\n/**\n * Generate a UTC timestamp in the format: \"20260202T131428Z\"\n * @returns Formatted UTC timestamp\n */\nfunction generateTimestamp(): string {\n  return new Date()\n    .toISOString()\n    .replace(/[-:]/g, \"\")\n    .replace(/\\.\\d{3}/, \"\");\n}\n\n/**\n * Generate a zip filename.\n * Full dataroom: \"Dataroom Name-20260202T131428Z[-001]\"\n * Folder download: \"Dataroom Name-FolderName-20260202T131428Z[-001]\"\n */\nfunction generateZipFileName(\n  dataroomName: string,\n  timestamp: string,\n  partNumber?: number,\n  folderName?: string,\n): string {\n  const paddedPart = partNumber?.toString().padStart(3, \"0\") ?? \"\";\n  const base = folderName\n    ? `${dataroomName}-${folderName}-${timestamp}`\n    : `${dataroomName}-${timestamp}`;\n\n  return `${base}${paddedPart ? `-${paddedPart}` : \"\"}`;\n}\n\nexport type BulkDownloadPayload = {\n  jobId: string;\n  dataroomId: string;\n  dataroomName: string;\n  teamId: string;\n  folderStructure: {\n    [key: string]: {\n      name: string;\n      path: string;\n      files: {\n        name: string;\n        key: string;\n        type?: string;\n        numPages?: number;\n        needsWatermark?: boolean;\n        size?: number; // File size in bytes\n      }[];\n    };\n  };\n  fileKeys: string[];\n  sourceBucket: string;\n  watermarkConfig?: {\n    enabled: boolean;\n    config?: any;\n    viewerData?: {\n      email?: string | null;\n      date?: string;\n      time?: string;\n      link?: string | null;\n      ipAddress?: string;\n    };\n  };\n  viewId?: string;\n  viewerId?: string;\n  viewerEmail?: string;\n  linkId?: string;\n  userId?: string;\n  emailNotification?: boolean;\n  emailAddress?: string;\n  folderName?: string;\n};\n\nexport const bulkDownloadTask = task({\n  id: \"bulk-download\",\n  retry: { maxAttempts: 2 },\n  machine: { preset: \"large-1x\" }, // 4 vCPU, 8GB RAM for orchestration\n  run: async (payload: BulkDownloadPayload) => {\n    const {\n      jobId,\n      dataroomId,\n      dataroomName,\n      teamId,\n      folderStructure,\n      fileKeys,\n      sourceBucket,\n      watermarkConfig,\n      viewId,\n      viewerEmail,\n      emailNotification,\n      emailAddress,\n      folderName,\n    } = payload;\n\n    logger.info(\"Starting bulk download task\", {\n      jobId,\n      dataroomId,\n      dataroomName,\n      totalFiles: fileKeys.length,\n    });\n\n    // Generate timestamp once for all parts of this download\n    const downloadTimestamp = generateTimestamp();\n\n    try {\n      // Update job status to processing\n      await downloadJobStore.updateJob(jobId, {\n        status: \"PROCESSING\",\n        processedFiles: 0,\n        progress: 0,\n      });\n\n      // For small datarooms, process in a single batch\n      if (fileKeys.length <= MAX_FILES_PER_BATCH) {\n        logger.info(\"Processing as single batch\", {\n          jobId,\n          fileCount: fileKeys.length,\n        });\n\n        const result = await processDownloadBatch({\n          teamId,\n          folderStructure,\n          fileKeys,\n          sourceBucket,\n          watermarkConfig,\n          dataroomName,\n          zipPartNumber: 1,\n          totalParts: 1,\n          zipFileName: generateZipFileName(\n            dataroomName,\n            downloadTimestamp,\n            undefined,\n            folderName,\n          ),\n        });\n\n        // Update job with completed status\n        const completedJob = await downloadJobStore.updateJob(jobId, {\n          status: \"COMPLETED\",\n          processedFiles: fileKeys.length,\n          progress: 100,\n          downloadUrls: [result.downloadUrl],\n          downloadS3Keys: result.s3KeyInfo ? [result.s3KeyInfo] : undefined,\n          completedAt: new Date().toISOString(),\n          expiresAt: new Date(\n            Date.now() + 3 * 24 * 60 * 60 * 1000,\n          ).toISOString(), // 3 days\n        });\n\n        if (emailNotification && emailAddress && completedJob) {\n          await sendEmailNotification({\n            emailAddress,\n            dataroomName,\n            jobId,\n            teamId,\n            dataroomId,\n            expiresAt: completedJob.expiresAt,\n            linkId: payload.linkId,\n          });\n        }\n\n        logger.info(\"Bulk download task completed successfully\", {\n          jobId,\n          downloadUrls: [result.downloadUrl],\n        });\n\n        return {\n          success: true,\n          jobId,\n          downloadUrls: [result.downloadUrl],\n        };\n      }\n\n      // For large datarooms, split into batches\n      logger.info(\"Processing as multiple batches\", {\n        jobId,\n        fileCount: fileKeys.length,\n        maxFilesPerBatch: MAX_FILES_PER_BATCH,\n        maxSizePerBatch: `${MAX_ZIP_SIZE_BYTES / (1024 * 1024)}MB`,\n      });\n\n      // Split files into batches\n      const batches = splitFilesIntoBatches(folderStructure, fileKeys);\n      const totalBatches = batches.length;\n      const downloadUrls: string[] = [];\n      const downloadS3Keys: { bucket: string; key: string; region: string }[] =\n        [];\n\n      logger.info(\"Created file batches\", {\n        jobId,\n        totalBatches,\n        batchDetails: batches.map((b, i) => ({\n          batch: i + 1,\n          files: b.fileKeys.length,\n          sizeBytes: b.totalSize,\n          sizeMB: Math.round(b.totalSize / (1024 * 1024)),\n        })),\n      });\n\n      // Process each batch sequentially (to avoid Lambda concurrency issues)\n      for (let i = 0; i < batches.length; i++) {\n        const batch = batches[i];\n        const batchNumber = i + 1;\n\n        logger.info(`Processing batch ${batchNumber}/${totalBatches}`, {\n          jobId,\n          batchNumber,\n          fileCount: batch.fileKeys.length,\n        });\n\n        try {\n          const result = await processDownloadBatch({\n            teamId,\n            folderStructure: batch.folderStructure,\n            fileKeys: batch.fileKeys,\n            sourceBucket,\n            watermarkConfig,\n            dataroomName,\n            zipPartNumber: batchNumber,\n            totalParts: totalBatches,\n            zipFileName: generateZipFileName(\n              dataroomName,\n              downloadTimestamp,\n              batchNumber,\n              folderName,\n            ),\n          });\n\n          downloadUrls.push(result.downloadUrl);\n          if (result.s3KeyInfo) {\n            downloadS3Keys.push(result.s3KeyInfo);\n          }\n\n          // Calculate progress\n          const processedFiles = batches\n            .slice(0, batchNumber)\n            .reduce((sum, b) => sum + b.fileKeys.length, 0);\n          const progress = Math.round((batchNumber / totalBatches) * 100);\n\n          // Update job progress\n          await downloadJobStore.updateJob(jobId, {\n            processedFiles,\n            progress,\n          });\n\n          logger.info(`Batch ${batchNumber} completed`, {\n            jobId,\n            batchNumber,\n            downloadUrl: result.downloadUrl,\n            progress,\n          });\n        } catch (batchError) {\n          logger.error(`Batch ${batchNumber} failed`, {\n            jobId,\n            batchNumber,\n            error:\n              batchError instanceof Error\n                ? batchError.message\n                : String(batchError),\n          });\n          throw batchError;\n        }\n      }\n\n      // Update job with completed status\n      const completedJob = await downloadJobStore.updateJob(jobId, {\n        status: \"COMPLETED\",\n        processedFiles: fileKeys.length,\n        progress: 100,\n        downloadUrls,\n        downloadS3Keys: downloadS3Keys.length > 0 ? downloadS3Keys : undefined,\n        completedAt: new Date().toISOString(),\n        expiresAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days\n      });\n\n      if (emailNotification && emailAddress && completedJob) {\n        await sendEmailNotification({\n          emailAddress,\n          dataroomName,\n          jobId,\n          teamId,\n          dataroomId,\n          expiresAt: completedJob.expiresAt,\n          linkId: payload.linkId,\n        });\n      }\n\n      logger.info(\"Bulk download task completed successfully\", {\n        jobId,\n        totalBatches,\n        downloadUrls,\n      });\n\n      return {\n        success: true,\n        jobId,\n        downloadUrls,\n      };\n    } catch (error) {\n      logger.error(\"Bulk download task failed\", {\n        jobId,\n        error: error instanceof Error ? error.message : String(error),\n      });\n\n      // Update job status to failed\n      await downloadJobStore.updateJob(jobId, {\n        status: \"FAILED\",\n        error: error instanceof Error ? error.message : String(error),\n      });\n\n      throw error;\n    }\n  },\n});\n\ninterface ProcessDownloadBatchParams {\n  teamId: string;\n  folderStructure: BulkDownloadPayload[\"folderStructure\"];\n  fileKeys: string[];\n  sourceBucket: string;\n  watermarkConfig?: BulkDownloadPayload[\"watermarkConfig\"];\n  dataroomName: string;\n  zipPartNumber: number;\n  totalParts: number;\n  zipFileName: string;\n  expirationHours?: number;\n}\n\ninterface ProcessDownloadBatchResult {\n  downloadUrl: string;\n  s3KeyInfo?: { bucket: string; key: string; region: string };\n}\n\nasync function processDownloadBatch({\n  teamId,\n  folderStructure,\n  fileKeys,\n  sourceBucket,\n  watermarkConfig,\n  dataroomName,\n  zipPartNumber,\n  totalParts,\n  zipFileName,\n  expirationHours = 72,\n}: ProcessDownloadBatchParams): Promise<ProcessDownloadBatchResult> {\n  const baseUrl = process.env.NEXTAUTH_URL || \"https://app.papermark.com\";\n  const internalApiKey = process.env.INTERNAL_API_KEY;\n\n  if (!internalApiKey) {\n    throw new Error(\"INTERNAL_API_KEY is not configured\");\n  }\n\n  const response = await fetch(`${baseUrl}/api/jobs/process-download-batch`, {\n    method: \"POST\",\n    headers: {\n      \"Content-Type\": \"application/json\",\n      Authorization: `Bearer ${internalApiKey}`,\n    },\n    body: JSON.stringify({\n      teamId,\n      sourceBucket,\n      fileKeys,\n      folderStructure,\n      watermarkConfig: watermarkConfig || { enabled: false },\n      zipPartNumber,\n      totalParts,\n      dataroomName,\n      zipFileName,\n      expirationHours,\n    }),\n  });\n\n  if (!response.ok) {\n    const errorData = await response.json().catch(() => ({}));\n    throw new Error(`API error: ${errorData.error || response.statusText}`);\n  }\n\n  const data = await response.json();\n  return { downloadUrl: data.downloadUrl, s3KeyInfo: data.s3KeyInfo };\n}\n\ninterface FileBatch {\n  folderStructure: BulkDownloadPayload[\"folderStructure\"];\n  fileKeys: string[];\n  totalSize: number;\n}\n\ninterface FileInfo {\n  key: string;\n  folderPath: string;\n  size: number;\n  file: BulkDownloadPayload[\"folderStructure\"][string][\"files\"][number];\n}\n\nfunction splitFilesIntoBatches(\n  folderStructure: BulkDownloadPayload[\"folderStructure\"],\n  fileKeys: string[],\n): FileBatch[] {\n  const batches: FileBatch[] = [];\n\n  // Build a list of files with their info\n  const filesWithInfo: FileInfo[] = [];\n  for (const [path, folder] of Object.entries(folderStructure)) {\n    for (const file of folder.files) {\n      if (file.key && fileKeys.includes(file.key)) {\n        filesWithInfo.push({\n          key: file.key,\n          folderPath: path,\n          size: file.size || 0, // Default to 0 if size unknown\n          file,\n        });\n      }\n    }\n  }\n\n  // Check if we have size information for most files\n  const filesWithSize = filesWithInfo.filter((f) => f.size > 0);\n  const hasSizeInfo = filesWithSize.length > filesWithInfo.length * 0.5; // At least 50% have size\n\n  if (hasSizeInfo) {\n    // Size-based batching\n    let currentBatch: FileInfo[] = [];\n    let currentBatchSize = 0;\n\n    for (const fileInfo of filesWithInfo) {\n      const fileSize = fileInfo.size || 10 * 1024 * 1024; // Estimate 10MB for unknown sizes\n\n      // If adding this file would exceed limit, start a new batch\n      // Also enforce max file count per batch to avoid Lambda payload limits\n      if (\n        currentBatch.length > 0 &&\n        (currentBatchSize + fileSize > MAX_ZIP_SIZE_BYTES ||\n          currentBatch.length >= MAX_FILES_PER_BATCH)\n      ) {\n        batches.push(buildBatchFromFiles(currentBatch, folderStructure));\n        currentBatch = [];\n        currentBatchSize = 0;\n      }\n\n      currentBatch.push(fileInfo);\n      currentBatchSize += fileSize;\n    }\n\n    // Don't forget the last batch\n    if (currentBatch.length > 0) {\n      batches.push(buildBatchFromFiles(currentBatch, folderStructure));\n    }\n  } else {\n    // Fallback to count-based batching if no size info\n    for (let i = 0; i < filesWithInfo.length; i += MAX_FILES_PER_BATCH) {\n      const batchFiles = filesWithInfo.slice(i, i + MAX_FILES_PER_BATCH);\n      batches.push(buildBatchFromFiles(batchFiles, folderStructure));\n    }\n  }\n\n  return batches;\n}\n\nfunction buildBatchFromFiles(\n  files: FileInfo[],\n  folderStructure: BulkDownloadPayload[\"folderStructure\"],\n): FileBatch {\n  const batchFolderStructure: BulkDownloadPayload[\"folderStructure\"] = {};\n  const batchFileKeys: string[] = [];\n  let totalSize = 0;\n\n  for (const fileInfo of files) {\n    batchFileKeys.push(fileInfo.key);\n    totalSize += fileInfo.size || 0;\n\n    // Initialize folder if not exists in batch\n    if (!batchFolderStructure[fileInfo.folderPath]) {\n      batchFolderStructure[fileInfo.folderPath] = {\n        name: folderStructure[fileInfo.folderPath].name,\n        path: folderStructure[fileInfo.folderPath].path,\n        files: [],\n      };\n    }\n\n    batchFolderStructure[fileInfo.folderPath].files.push(fileInfo.file);\n  }\n\n  // Ensure all parent folders are included\n  for (const path of Object.keys(batchFolderStructure)) {\n    const pathParts = path.split(\"/\").filter(Boolean);\n    let currentPath = \"\";\n\n    for (const part of pathParts) {\n      currentPath += \"/\" + part;\n      if (!batchFolderStructure[currentPath] && folderStructure[currentPath]) {\n        batchFolderStructure[currentPath] = {\n          name: folderStructure[currentPath].name,\n          path: folderStructure[currentPath].path,\n          files: [], // Empty files array for intermediate folders\n        };\n      }\n    }\n  }\n\n  return {\n    folderStructure: batchFolderStructure,\n    fileKeys: batchFileKeys,\n    totalSize,\n  };\n}\n\nasync function sendEmailNotification({\n  emailAddress,\n  dataroomName,\n  jobId,\n  teamId,\n  dataroomId,\n  expiresAt,\n  linkId,\n}: {\n  emailAddress: string;\n  dataroomName: string;\n  jobId: string;\n  teamId: string;\n  dataroomId: string;\n  expiresAt?: string;\n  linkId?: string;\n}): Promise<void> {\n  try {\n    let downloadUrl: string;\n    let isViewer = false;\n\n    if (linkId) {\n      const link = await prisma.link.findUnique({\n        where: { id: linkId },\n        select: { id: true, domainId: true, domainSlug: true, slug: true },\n      });\n      downloadUrl = link\n        ? `${constructLinkUrl(link)}/downloads`\n        : `${process.env.NEXT_PUBLIC_MARKETING_URL || \"https://www.papermark.com\"}/view/${linkId}/downloads`;\n      isViewer = true;\n    } else {\n      const baseUrl = process.env.NEXTAUTH_URL || \"https://app.papermark.com\";\n      downloadUrl = `${baseUrl}/datarooms/${dataroomId}/documents`;\n    }\n\n    await sendDownloadReadyEmail({\n      to: emailAddress,\n      dataroomName,\n      downloadUrl,\n      expiresAt,\n      isViewer,\n    });\n    logger.info(\"Download ready email sent\", {\n      jobId,\n      emailAddress,\n      downloadUrl,\n    });\n  } catch (error) {\n    logger.error(\"Failed to send download ready email\", {\n      jobId,\n      error: error instanceof Error ? error.message : String(error),\n    });\n  }\n}\n"
  },
  {
    "path": "lib/trigger/cleanup-expired-exports.ts",
    "content": "import { logger, schedules } from \"@trigger.dev/sdk/v3\";\nimport { del } from \"@vercel/blob\";\n\nimport { jobStore } from \"@/lib/redis-job-store\";\n\nexport const cleanupExpiredExports = schedules.task({\n  id: \"cleanup-expired-exports\",\n  // Run daily at 2 AM UTC\n  cron: \"0 2 * * *\",\n  run: async (payload) => {\n    logger.info(\"Starting cleanup of expired export blobs\", {\n      timestamp: payload.timestamp,\n    });\n\n    try {\n      // Get all blob URLs that are due for cleanup\n      const blobsToCleanup = await jobStore.getBlobsForCleanup();\n\n      if (blobsToCleanup.length === 0) {\n        logger.info(\"No blobs due for cleanup\");\n        return { deletedCount: 0 };\n      }\n\n      logger.info(`Found ${blobsToCleanup.length} blobs to delete`);\n\n      // Delete blobs from Vercel Blob\n      const deletionResults = await Promise.allSettled(\n        blobsToCleanup.map(async (blob) => {\n          try {\n            await del(blob.blobUrl);\n\n            // Remove from cleanup queue after successful deletion\n            await jobStore.removeBlobFromCleanupQueue(blob.blobUrl, blob.jobId);\n\n            logger.info(\"Successfully deleted blob\", {\n              blobUrl: blob.blobUrl,\n              jobId: blob.jobId,\n            });\n\n            return { blob, success: true };\n          } catch (error) {\n            logger.error(\"Failed to delete blob\", {\n              blobUrl: blob.blobUrl,\n              jobId: blob.jobId,\n              error: error instanceof Error ? error.message : String(error),\n            });\n            return { blob, success: false, error };\n          }\n        }),\n      );\n\n      const successCount = deletionResults.filter(\n        (result) => result.status === \"fulfilled\" && result.value.success,\n      ).length;\n\n      const failureCount = deletionResults.length - successCount;\n\n      logger.info(\"Cleanup completed\", {\n        totalBlobs: blobsToCleanup.length,\n        successCount,\n        failureCount,\n      });\n\n      return {\n        deletedCount: successCount,\n        failureCount,\n        totalProcessed: blobsToCleanup.length,\n      };\n    } catch (error) {\n      logger.error(\"Cleanup task failed\", {\n        error: error instanceof Error ? error.message : String(error),\n      });\n      throw error;\n    }\n  },\n});\n"
  },
  {
    "path": "lib/trigger/conversation-message-notification.ts",
    "content": "import {\n  sendConversationMessageNotificationTask,\n  sendConversationTeamMemberNotificationTask,\n} from \"@/ee/features/conversations/lib/trigger/conversation-message-notification\";\n\nexport {\n  sendConversationMessageNotificationTask,\n  sendConversationTeamMemberNotificationTask,\n};\n"
  },
  {
    "path": "lib/trigger/convert-files.ts",
    "content": "import { python } from \"@trigger.dev/python\";\nimport { logger, retry, task } from \"@trigger.dev/sdk/v3\";\nimport { writeFile, readFile, unlink } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport { putFileServer } from \"@/lib/files/put-file-server\";\nimport prisma from \"@/lib/prisma\";\n\nimport { updateStatus } from \"../utils/generate-trigger-status\";\nimport { getExtensionFromContentType } from \"../utils/get-content-type\";\nimport { convertPdfToImageRoute } from \"./pdf-to-image-route\";\n\nexport type ConvertPayload = {\n  documentId: string;\n  documentVersionId: string;\n  teamId: string;\n};\n\nexport const convertFilesToPdfTask = task({\n  id: \"convert-files-to-pdf\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 10,\n  },\n  run: async (payload: ConvertPayload) => {\n    updateStatus({ progress: 0, text: \"Initializing...\" });\n\n    const team = await prisma.team.findUnique({\n      where: {\n        id: payload.teamId,\n      },\n    });\n\n    if (!team) {\n      logger.error(\"Team not found\", { teamId: payload.teamId });\n      return;\n    }\n\n    const document = await prisma.document.findUnique({\n      where: {\n        id: payload.documentId,\n        teamId: payload.teamId,\n      },\n      select: {\n        name: true,\n        versions: {\n          where: {\n            id: payload.documentVersionId,\n          },\n          select: {\n            file: true,\n            originalFile: true,\n            contentType: true,\n            storageType: true,\n          },\n        },\n      },\n    });\n\n    if (\n      !document ||\n      !document.versions[0] ||\n      !document.versions[0].originalFile ||\n      !document.versions[0].contentType\n    ) {\n      updateStatus({ progress: 0, text: \"Document not found\" });\n\n      logger.error(\"Document not found\", {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n      });\n      return;\n    }\n\n    updateStatus({ progress: 10, text: \"Retrieving file...\" });\n\n    const fileUrl = await getFile({\n      data: document.versions[0].originalFile,\n      type: document.versions[0].storageType,\n    });\n\n    const CONVERSION_TIMEOUT_MS = 3 * 60 * 1000; // 3 min client-side timeout\n\n    updateStatus({ progress: 20, text: \"Converting document…\" });\n\n    let conversionResponse: Response;\n    try {\n      const fd = new FormData();\n      fd.append(\"downloadFrom\", JSON.stringify([{ url: fileUrl }]));\n      fd.append(\"quality\", \"75\");\n\n      conversionResponse = await fetch(\n        `${process.env.NEXT_PRIVATE_CONVERSION_BASE_URL}/forms/libreoffice/convert`,\n        {\n          method: \"POST\",\n          body: fd,\n          headers: {\n            Authorization: `Basic ${process.env.NEXT_PRIVATE_INTERNAL_AUTH_TOKEN}`,\n          },\n          signal: AbortSignal.timeout(CONVERSION_TIMEOUT_MS),\n        },\n      );\n    } catch (err) {\n      logger.warn(\"Gotenberg conversion failed, will attempt sanitization\", {\n        error: String(err),\n      });\n      conversionResponse = new Response(null, {\n        status: 504,\n        statusText: \"Conversion timed out or unreachable\",\n      });\n    }\n\n    let conversionBuffer!: Buffer;\n\n    if (!conversionResponse.ok) {\n      const contentType = document.versions[0].contentType;\n      const isDocx =\n        contentType ===\n        \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\";\n\n      if (isDocx) {\n        const inputPath = join(tmpdir(), `input-${Date.now()}.docx`);\n        const outputPath = join(tmpdir(), `output-${Date.now()}.docx`);\n        try {\n          let docxResponse: Response;\n          try {\n            docxResponse = await fetch(fileUrl);\n          } catch (err) {\n            updateStatus({ progress: 0, text: \"Failed to retrieve DOCX file\" });\n            throw new Error(\n              `Failed to fetch DOCX from signed URL for sanitization: ${String(err)}`,\n            );\n          }\n\n          if (!docxResponse.ok) {\n            updateStatus({ progress: 0, text: \"Failed to retrieve DOCX file\" });\n            throw new Error(\n              `Failed to fetch DOCX from signed URL for sanitization: HTTP ${docxResponse.status} ${docxResponse.statusText}`,\n            );\n          }\n\n          const responseContentType = docxResponse.headers.get(\"content-type\");\n          if (\n            responseContentType &&\n            !responseContentType.includes(\n              \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n            ) &&\n            !responseContentType.includes(\"application/octet-stream\")\n          ) {\n            updateStatus({ progress: 0, text: \"Invalid DOCX download response\" });\n            throw new Error(\n              `Signed URL returned unexpected content-type for DOCX sanitization: ${responseContentType}`,\n            );\n          }\n\n          const docxBuffer = Buffer.from(await docxResponse.arrayBuffer());\n          await writeFile(inputPath, docxBuffer);\n\n          const attemptConversion = async (\n            sanitizedPath: string,\n          ): Promise<Response> => {\n            const buf = await readFile(sanitizedPath);\n            const fd = new FormData();\n            fd.append(\n              \"files\",\n              new Blob([new Uint8Array(buf)], { type: contentType }),\n              document.name,\n            );\n            fd.append(\"quality\", \"75\");\n            return fetch(\n              `${process.env.NEXT_PRIVATE_CONVERSION_BASE_URL}/forms/libreoffice/convert`,\n              {\n                method: \"POST\",\n                body: fd,\n                headers: {\n                  Authorization: `Basic ${process.env.NEXT_PRIVATE_INTERNAL_AUTH_TOKEN}`,\n                },\n                signal: AbortSignal.timeout(CONVERSION_TIMEOUT_MS),\n              },\n            );\n          };\n\n          logger.warn(\"DOCX conversion failed, sanitizing document…\", {\n            status: conversionResponse.status,\n          });\n\n          updateStatus({ progress: 25, text: \"Sanitizing document…\" });\n\n          const result = await python.runScript(\n            \"./ee/features/conversions/python/docx-sanitizer.py\",\n            [\"-v\", \"--mode\", \"all\", inputPath, outputPath],\n          );\n          logger.info(\"Sanitizer output\", { stderr: result.stderr });\n\n          let retryResponse: Response;\n          try {\n            retryResponse = await attemptConversion(outputPath);\n          } catch (err) {\n            updateStatus({ progress: 0, text: \"Conversion timed out\" });\n            throw new Error(\n              `Conversion timed out after sanitization — LibreOffice could not process this file within ${CONVERSION_TIMEOUT_MS / 1000}s`,\n            );\n          }\n\n          if (!retryResponse.ok) {\n            updateStatus({ progress: 0, text: \"Conversion failed\" });\n            let message: string;\n            try {\n              const body = await retryResponse.json();\n              message = body.message ?? retryResponse.statusText;\n            } catch {\n              message = retryResponse.statusText || \"Unknown error\";\n            }\n            throw new Error(\n              `Conversion failed after sanitization: ${message} (${retryResponse.status})`,\n            );\n          }\n\n          conversionBuffer = Buffer.from(await retryResponse.arrayBuffer());\n        } finally {\n          await Promise.allSettled([unlink(inputPath), unlink(outputPath)]);\n        }\n      } else {\n        updateStatus({ progress: 0, text: \"Conversion failed\" });\n        let message: string;\n        try {\n          const body = await conversionResponse.json();\n          message = body.message ?? conversionResponse.statusText;\n        } catch {\n          message = conversionResponse.statusText || \"Unknown error\";\n        }\n        throw new Error(\n          `Conversion failed: ${message} (${conversionResponse.status})`,\n        );\n      }\n    } else {\n      conversionBuffer = Buffer.from(await conversionResponse.arrayBuffer());\n    }\n\n    console.log(\"conversionBuffer\", conversionBuffer);\n\n    // get docId from url with starts with \"doc_\" with regex\n    const match = document.versions[0].originalFile.match(/(doc_[^\\/]+)\\//);\n    const docId = match ? match[1] : undefined;\n\n    updateStatus({ progress: 30, text: \"Saving converted file...\" });\n\n    // Save the converted file to the database\n    const { type: storageType, data } = await putFileServer({\n      file: {\n        name: `${document.name}.pdf`,\n        type: \"application/pdf\",\n        buffer: conversionBuffer,\n      },\n      teamId: payload.teamId,\n      docId: docId,\n    });\n\n    if (!data || !storageType) {\n      updateStatus({ progress: 0, text: \"Failed to save converted file\" });\n\n      logger.error(\"Failed to save converted file to database\", {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n        docId: docId,\n      });\n      return;\n    }\n\n    console.log(\"data from conversion\", data);\n    console.log(\"storageType from conversion\", storageType);\n\n    const { versionNumber } = await prisma.documentVersion.update({\n      where: { id: payload.documentVersionId },\n      data: {\n        file: data,\n        type: \"pdf\",\n        storageType: storageType,\n      },\n      select: {\n        versionNumber: true,\n      },\n    });\n\n    updateStatus({ progress: 40, text: \"Initiating document processing...\" });\n\n    await convertPdfToImageRoute.trigger(\n      {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n        versionNumber: versionNumber,\n      },\n      {\n        idempotencyKey: `${payload.teamId}-${payload.documentVersionId}`,\n        tags: [\n          `team_${payload.teamId}`,\n          `document_${payload.documentId}`,\n          `version:${payload.documentVersionId}`,\n        ],\n      },\n    );\n\n    logger.info(\"Document converted\", {\n      documentId: payload.documentId,\n      documentVersionId: payload.documentVersionId,\n      teamId: payload.teamId,\n      docId: docId,\n    });\n    return;\n  },\n});\n\n// convert cad file to pdf\nexport const convertCadToPdfTask = task({\n  id: \"convert-cad-to-pdf\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 2,\n  },\n  run: async (payload: ConvertPayload) => {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: payload.teamId,\n      },\n    });\n\n    if (!team) {\n      logger.error(\"Team not found\", { teamId: payload.teamId });\n      return;\n    }\n\n    const document = await prisma.document.findUnique({\n      where: {\n        id: payload.documentId,\n        teamId: payload.teamId,\n      },\n      select: {\n        name: true,\n        versions: {\n          where: {\n            id: payload.documentVersionId,\n          },\n          select: {\n            file: true,\n            originalFile: true,\n            contentType: true,\n            storageType: true,\n          },\n        },\n      },\n    });\n\n    if (\n      !document ||\n      !document.versions[0] ||\n      !document.versions[0].originalFile ||\n      !document.versions[0].contentType\n    ) {\n      logger.error(\"Document not found\", {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n      });\n      return;\n    }\n\n    const fileUrl = await getFile({\n      data: document.versions[0].originalFile,\n      type: document.versions[0].storageType,\n    });\n\n    // create payload for cad to pdf conversion\n    const tasksPayload = {\n      tasks: {\n        \"import-file-v1\": {\n          operation: \"import/url\",\n          url: fileUrl,\n          filename: document.name,\n        },\n        \"convert-file-v1\": {\n          operation: \"convert\",\n          input: [\"import-file-v1\"],\n          input_format: getExtensionFromContentType(\n            document.versions[0].contentType,\n          ),\n          output_format: \"pdf\",\n          engine: \"cadconverter\",\n          all_layouts: true,\n          auto_zoom: false,\n        },\n        \"export-file-v1\": {\n          operation: \"export/url\",\n          input: [\"convert-file-v1\"],\n          inline: false,\n          archive_multiple_files: false,\n        },\n      },\n      redirect: true,\n    };\n\n    // Make the conversion request\n    const conversionResponse = await retry.fetch(\n      `${process.env.NEXT_PRIVATE_CONVERT_API_URL}`,\n      {\n        method: \"POST\",\n        body: JSON.stringify(tasksPayload),\n        headers: {\n          Authorization: `Bearer ${process.env.NEXT_PRIVATE_CONVERT_API_KEY}`,\n          \"Content-Type\": \"application/json\",\n        },\n        retry: {\n          byStatus: {\n            \"500-599\": {\n              strategy: \"backoff\",\n              maxAttempts: 3,\n              factor: 2,\n              minTimeoutInMs: 1_000,\n              maxTimeoutInMs: 30_000,\n              randomize: false,\n            },\n          },\n        },\n      },\n    );\n\n    if (!conversionResponse.ok) {\n      const body = await conversionResponse.json();\n      throw new Error(\n        `Conversion failed: ${body.message} ${conversionResponse.status}`,\n      );\n    }\n\n    const conversionBuffer = Buffer.from(\n      await conversionResponse.arrayBuffer(),\n    );\n\n    // get docId from url with starts with \"doc_\" with regex\n    const match = document.versions[0].originalFile.match(/(doc_[^\\/]+)\\//);\n    const docId = match ? match[1] : undefined;\n\n    // Save the converted file to the database\n    const { type: storageType, data } = await putFileServer({\n      file: {\n        name: `${document.name}.pdf`,\n        type: \"application/pdf\",\n        buffer: conversionBuffer,\n      },\n      teamId: payload.teamId,\n      docId: docId,\n    });\n\n    if (!data || !storageType) {\n      logger.error(\"Failed to save converted file to database\", {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n        docId: docId,\n      });\n      return;\n    }\n\n    console.log(\"data from conversion\", data);\n    console.log(\"storageType from conversion\", storageType);\n\n    await prisma.documentVersion.update({\n      where: { id: payload.documentVersionId },\n      data: {\n        file: data,\n        type: \"pdf\",\n        storageType: storageType,\n      },\n    });\n\n    await convertPdfToImageRoute.trigger(\n      {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n      },\n      {\n        idempotencyKey: `${payload.teamId}-${payload.documentVersionId}`,\n        tags: [\n          `team_${payload.teamId}`,\n          `document_${payload.documentId}`,\n          `version:${payload.documentVersionId}`,\n        ],\n      },\n    );\n\n    logger.info(\"Document converted\", {\n      documentId: payload.documentId,\n      documentVersionId: payload.documentVersionId,\n      teamId: payload.teamId,\n      docId: docId,\n    });\n    return;\n  },\n});\n\n// convert keynote file to pdf\nexport const convertKeynoteToPdfTask = task({\n  id: \"convert-keynote-to-pdf\",\n  retry: { maxAttempts: 3 },\n  queue: {\n    concurrencyLimit: 2,\n  },\n  run: async (payload: ConvertPayload) => {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: payload.teamId,\n      },\n    });\n\n    if (!team) {\n      logger.error(\"Team not found\", { teamId: payload.teamId });\n      return;\n    }\n\n    const document = await prisma.document.findUnique({\n      where: {\n        id: payload.documentId,\n        teamId: payload.teamId,\n      },\n      select: {\n        name: true,\n        versions: {\n          where: {\n            id: payload.documentVersionId,\n          },\n          select: {\n            file: true,\n            originalFile: true,\n            contentType: true,\n            storageType: true,\n          },\n        },\n      },\n    });\n\n    if (\n      !document ||\n      !document.versions[0] ||\n      !document.versions[0].originalFile ||\n      !document.versions[0].contentType\n    ) {\n      logger.error(\"Document not found\", {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n      });\n      return;\n    }\n\n    const fileUrl = await getFile({\n      data: document.versions[0].originalFile,\n      type: document.versions[0].storageType,\n    });\n\n    // create payload for keynote to pdf conversion\n    const tasksPayload = {\n      tasks: {\n        \"import-file-v1\": {\n          operation: \"import/url\",\n          url: fileUrl,\n          filename: document.name,\n        },\n        \"convert-file-v1\": {\n          operation: \"convert\",\n          input: [\"import-file-v1\"],\n          input_format: getExtensionFromContentType(\n            document.versions[0].contentType,\n          ),\n          output_format: \"pdf\",\n          engine: \"iwork\",\n        },\n        \"export-file-v1\": {\n          operation: \"export/url\",\n          input: [\"convert-file-v1\"],\n          inline: false,\n          archive_multiple_files: false,\n        },\n      },\n      redirect: true,\n    };\n\n    // Make the conversion request\n    const conversionResponse = await retry.fetch(\n      `${process.env.NEXT_PRIVATE_CONVERT_API_URL}`,\n      {\n        method: \"POST\",\n        body: JSON.stringify(tasksPayload),\n        headers: {\n          Authorization: `Bearer ${process.env.NEXT_PRIVATE_CONVERT_API_KEY}`,\n          \"Content-Type\": \"application/json\",\n        },\n        retry: {\n          byStatus: {\n            \"500-599\": {\n              strategy: \"backoff\",\n              maxAttempts: 3,\n              factor: 2,\n              minTimeoutInMs: 1_000,\n              maxTimeoutInMs: 30_000,\n              randomize: false,\n            },\n          },\n        },\n      },\n    );\n\n    if (!conversionResponse.ok) {\n      const body = await conversionResponse.json();\n      throw new Error(\n        `Conversion failed: ${body.message} ${conversionResponse.status}`,\n      );\n    }\n\n    const conversionBuffer = Buffer.from(\n      await conversionResponse.arrayBuffer(),\n    );\n\n    // get docId from url with starts with \"doc_\" with regex\n    const match = document.versions[0].originalFile.match(/(doc_[^\\/]+)\\//);\n    const docId = match ? match[1] : undefined;\n\n    // Save the converted file to the database\n    const { type: storageType, data } = await putFileServer({\n      file: {\n        name: `${document.name}.pdf`,\n        type: \"application/pdf\",\n        buffer: conversionBuffer,\n      },\n      teamId: payload.teamId,\n      docId: docId,\n    });\n\n    if (!data || !storageType) {\n      logger.error(\"Failed to save converted file to database\", {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n        docId: docId,\n      });\n      return;\n    }\n\n    console.log(\"data from conversion\", data);\n    console.log(\"storageType from conversion\", storageType);\n\n    await prisma.documentVersion.update({\n      where: { id: payload.documentVersionId },\n      data: {\n        file: data,\n        type: \"pdf\",\n        storageType: storageType,\n      },\n    });\n\n    await convertPdfToImageRoute.trigger(\n      {\n        documentId: payload.documentId,\n        documentVersionId: payload.documentVersionId,\n        teamId: payload.teamId,\n      },\n      {\n        idempotencyKey: `${payload.teamId}-${payload.documentVersionId}`,\n        tags: [\n          `team_${payload.teamId}`,\n          `document_${payload.documentId}`,\n          `version:${payload.documentVersionId}`,\n        ],\n      },\n    );\n\n    logger.info(\"Keynote document converted\", {\n      documentId: payload.documentId,\n      documentVersionId: payload.documentVersionId,\n      teamId: payload.teamId,\n      docId: docId,\n    });\n    return;\n  },\n});\n"
  },
  {
    "path": "lib/trigger/dataroom-change-notification.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\n\nimport prisma from \"@/lib/prisma\";\nimport { queueNotification } from \"@/lib/redis/dataroom-notification-queue\";\nimport { ZViewerNotificationPreferencesSchema } from \"@/lib/zod/schemas/notifications\";\n\ntype NotificationPayload = {\n  dataroomId: string;\n  dataroomDocumentId: string;\n  senderUserId: string;\n  teamId: string;\n};\n\nexport const sendDataroomChangeNotificationTask = task({\n  id: \"send-dataroom-change-notification\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: NotificationPayload) => {\n    const viewers = await prisma.viewer.findMany({\n      where: {\n        teamId: payload.teamId,\n        views: {\n          some: {\n            dataroomId: payload.dataroomId,\n            viewType: \"DATAROOM_VIEW\",\n            verified: true,\n          },\n        },\n      },\n      select: {\n        id: true,\n        notificationPreferences: true,\n        views: {\n          where: {\n            dataroomId: payload.dataroomId,\n            viewType: \"DATAROOM_VIEW\",\n            verified: true,\n          },\n          orderBy: {\n            viewedAt: \"desc\",\n          },\n          take: 1,\n          include: {\n            link: {\n              select: {\n                id: true,\n                slug: true,\n                domainSlug: true,\n                domainId: true,\n                isArchived: true,\n                expiresAt: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!viewers || viewers.length === 0) {\n      logger.info(\"No verified viewers found for this dataroom\", {\n        dataroomId: payload.dataroomId,\n      });\n      return;\n    }\n\n    const viewersWithLinks = viewers\n      .map((viewer) => {\n        const view = viewer.views[0];\n        const link = view?.link;\n\n        if (\n          !link ||\n          link.isArchived ||\n          (link.expiresAt && new Date(link.expiresAt) < new Date())\n        ) {\n          return null;\n        }\n\n        const parsedPreferences =\n          ZViewerNotificationPreferencesSchema.safeParse(\n            viewer.notificationPreferences,\n          );\n\n        if (\n          parsedPreferences.success &&\n          parsedPreferences.data.dataroom[payload.dataroomId]?.enabled === false\n        ) {\n          return null;\n        }\n\n        const frequency =\n          parsedPreferences.success\n            ? (parsedPreferences.data.dataroom[payload.dataroomId]?.frequency ??\n              \"instant\")\n            : \"instant\";\n\n        let linkUrl = \"\";\n        if (link.domainId && link.domainSlug && link.slug) {\n          linkUrl = `https://${link.domainSlug}/${link.slug}`;\n        } else {\n          linkUrl = `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;\n        }\n\n        return {\n          id: viewer.id,\n          linkUrl,\n          frequency,\n        };\n      })\n      .filter(\n        (\n          viewer,\n        ): viewer is {\n          id: string;\n          linkUrl: string;\n          frequency: \"instant\" | \"daily\" | \"weekly\";\n        } => viewer !== null,\n      );\n\n    logger.info(\"Processed viewer links\", {\n      viewerCount: viewersWithLinks.length,\n    });\n\n    for (const viewer of viewersWithLinks) {\n      try {\n        if (viewer.frequency === \"daily\" || viewer.frequency === \"weekly\") {\n          await queueNotification({\n            frequency: viewer.frequency,\n            viewerId: viewer.id,\n            dataroomId: payload.dataroomId,\n            teamId: payload.teamId,\n            dataroomDocumentId: payload.dataroomDocumentId,\n            senderUserId: payload.senderUserId,\n          });\n\n          logger.info(\"Queued notification for digest\", {\n            viewerId: viewer.id,\n            frequency: viewer.frequency,\n          });\n          continue;\n        }\n\n        const response = await fetch(\n          `${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-dataroom-new-document-notification`,\n          {\n            method: \"POST\",\n            body: JSON.stringify({\n              dataroomId: payload.dataroomId,\n              linkUrl: viewer.linkUrl,\n              dataroomDocumentId: payload.dataroomDocumentId,\n              viewerId: viewer.id,\n              senderUserId: payload.senderUserId,\n              teamId: payload.teamId,\n            }),\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n            },\n          },\n        );\n\n        if (!response.ok) {\n          logger.error(\"Failed to send dataroom notification\", {\n            viewerId: viewer.id,\n            dataroomId: payload.dataroomId,\n            error: await response.text(),\n          });\n          continue;\n        }\n\n        const { message } = (await response.json()) as { message: string };\n        logger.info(\"Notification sent successfully\", {\n          viewerId: viewer.id,\n          message,\n        });\n      } catch (error) {\n        logger.error(\"Error sending notification\", {\n          viewerId: viewer.id,\n          error,\n        });\n      }\n    }\n\n    logger.info(\"Completed sending notifications\", {\n      dataroomId: payload.dataroomId,\n      viewerCount: viewers.length,\n    });\n    return;\n  },\n});\n"
  },
  {
    "path": "lib/trigger/dataroom-upload-notification.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\n\nimport prisma from \"@/lib/prisma\";\n\ntype UploadNotificationPayload = {\n  dataroomId: string;\n  linkId: string;\n  viewerId: string;\n  teamId: string;\n};\n\nexport const sendDataroomUploadNotificationTask = task({\n  id: \"send-dataroom-upload-notification\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: UploadNotificationPayload) => {\n    const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);\n\n    // Get all recent uploads for this dataroom via this link by this viewer\n    const recentUploads = await prisma.documentUpload.findMany({\n      where: {\n        dataroomId: payload.dataroomId,\n        linkId: payload.linkId,\n        viewerId: payload.viewerId,\n        uploadedAt: {\n          gte: tenMinutesAgo,\n        },\n      },\n      select: {\n        originalFilename: true,\n        viewer: {\n          select: {\n            email: true,\n          },\n        },\n      },\n      orderBy: {\n        uploadedAt: \"desc\",\n      },\n    });\n\n    if (!recentUploads || recentUploads.length === 0) {\n      logger.info(\"No recent uploads found for this dataroom link\", {\n        dataroomId: payload.dataroomId,\n        linkId: payload.linkId,\n      });\n      return;\n    }\n\n    // Get dataroom and link info\n    const [dataroom, link] = await Promise.all([\n      prisma.dataroom.findUnique({\n        where: { id: payload.dataroomId },\n        select: { name: true, teamId: true },\n      }),\n      prisma.link.findUnique({\n        where: { id: payload.linkId },\n        select: { name: true, ownerId: true },\n      }),\n    ]);\n\n    if (!dataroom) {\n      logger.error(\"Dataroom not found\", {\n        dataroomId: payload.dataroomId,\n      });\n      return;\n    }\n\n    // Get all active team members who are admins or managers\n    const users = await prisma.userTeam.findMany({\n      where: {\n        role: { in: [\"ADMIN\", \"MANAGER\"] },\n        status: \"ACTIVE\",\n        teamId: payload.teamId,\n      },\n      select: {\n        role: true,\n        user: {\n          select: {\n            email: true,\n          },\n        },\n      },\n    });\n\n    const adminEmail = users.find((user) => user.role === \"ADMIN\")?.user.email;\n\n    if (!adminEmail) {\n      logger.error(\"No admin email found for team\", {\n        teamId: payload.teamId,\n      });\n      return;\n    }\n\n    // Build team members list (excluding admin to avoid duplicate)\n    const teamMembers = users\n      .map((user) => user.user.email!)\n      .filter((email) => email !== adminEmail);\n\n    // Add link owner to team members if they exist and aren't already included\n    if (link?.ownerId) {\n      const linkOwner = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: link.ownerId,\n            teamId: payload.teamId,\n          },\n          status: \"ACTIVE\",\n        },\n        select: {\n          user: {\n            select: {\n              email: true,\n            },\n          },\n        },\n      });\n\n      if (\n        linkOwner?.user.email &&\n        linkOwner.user.email !== adminEmail &&\n        !teamMembers.includes(linkOwner.user.email)\n      ) {\n        teamMembers.push(linkOwner.user.email);\n      }\n    }\n\n    const documentNames = recentUploads.map(\n      (upload) => upload.originalFilename || \"Untitled document\",\n    );\n\n    // Use the viewer's email from the uploads\n    const uploaderEmail = recentUploads[0]?.viewer?.email || null;\n\n    const linkName = link?.name || `Link #${payload.linkId.slice(-5)}`;\n\n    try {\n      const response = await fetch(\n        `${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-dataroom-upload-notification`,\n        {\n          method: \"POST\",\n          body: JSON.stringify({\n            dataroomId: payload.dataroomId,\n            dataroomName: dataroom.name,\n            uploaderEmail,\n            documentNames,\n            linkName,\n            ownerEmail: adminEmail,\n            teamMembers,\n            teamId: payload.teamId,\n          }),\n          headers: {\n            \"Content-Type\": \"application/json\",\n            Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n          },\n        },\n      );\n\n      if (!response.ok) {\n        logger.error(\"Failed to send dataroom upload notification\", {\n          dataroomId: payload.dataroomId,\n          linkId: payload.linkId,\n          error: await response.text(),\n        });\n        return;\n      }\n\n      const { message } = (await response.json()) as { message: string };\n      logger.info(\"Upload notification sent successfully\", {\n        dataroomId: payload.dataroomId,\n        linkId: payload.linkId,\n        message,\n        uploadCount: recentUploads.length,\n      });\n    } catch (error) {\n      logger.error(\"Error sending upload notification\", {\n        dataroomId: payload.dataroomId,\n        linkId: payload.linkId,\n        error,\n      });\n    }\n  },\n});\n"
  },
  {
    "path": "lib/trigger/export-visits.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\nimport { put } from \"@vercel/blob\";\nimport Bottleneck from \"bottleneck\";\n\nimport { sendExportReadyEmail } from \"@/lib/emails/send-export-ready-email\";\nimport prisma from \"@/lib/prisma\";\nimport { jobStore } from \"@/lib/redis-job-store\";\nimport {\n  getViewPageDuration,\n  getViewUserAgent,\n  getViewUserAgent_v2,\n} from \"@/lib/tinybird\";\n\n// Helper function to properly escape CSV fields\nfunction escapeCsvField(field: string | number | null | undefined): string {\n  if (field === null || field === undefined) {\n    return \"NaN\";\n  }\n\n  const stringField = String(field);\n\n  // If the field contains comma, newline, or quote, wrap in quotes and escape quotes\n  if (\n    stringField.includes(\",\") ||\n    stringField.includes(\"\\n\") ||\n    stringField.includes(\"\\r\") ||\n    stringField.includes('\"')\n  ) {\n    return `\"${stringField.replace(/\"/g, '\"\"')}\"`;\n  }\n\n  return stringField;\n}\n\n// Helper function to convert array of fields to properly escaped CSV row\nfunction createCsvRow(fields: (string | number | null | undefined)[]): string {\n  return fields.map(escapeCsvField).join(\",\");\n}\n\n// Create a bottleneck instance to limit tinybird API calls\nconst tinybirdLimiter = new Bottleneck({\n  maxConcurrent: 5, // Maximum 5 concurrent requests\n  minTime: 200, // Minimum 200ms between requests\n});\n\nexport type ExportVisitsPayload = {\n  type: \"document\" | \"dataroom\" | \"dataroom-group\";\n  teamId: string;\n  resourceId: string; // document ID or dataroom ID\n  groupId?: string; // for dataroom groups\n  userId: string;\n  exportId: string; // unique identifier for this export job\n};\n\nexport const exportVisitsTask = task({\n  id: \"export-visits\",\n  retry: { maxAttempts: 2 },\n  run: async (payload: ExportVisitsPayload) => {\n    const { type, teamId, resourceId, groupId, userId, exportId } = payload;\n\n    logger.info(\"Starting export visits task\", { payload });\n\n    try {\n      // Update job status to processing\n      await jobStore.updateJob(exportId, { status: \"PROCESSING\" });\n\n      // Verify team access\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: { plan: true },\n      });\n\n      if (!team) {\n        throw new Error(\"Team not found or access denied\");\n      }\n\n      if (team.plan === \"free\") {\n        throw new Error(\"This feature is not available for your plan\");\n      }\n\n      let csvData: string;\n      let resourceName: string;\n\n      if (type === \"document\") {\n        ({ csvData, resourceName } = await exportDocumentVisits(\n          resourceId,\n          teamId,\n        ));\n      } else if (type === \"dataroom\") {\n        ({ csvData, resourceName } = await exportDataroomVisits(\n          resourceId,\n          teamId,\n          groupId,\n        ));\n      } else {\n        throw new Error(\"Invalid export type\");\n      }\n\n      // Create timestamp for filename\n      const currentTime = new Date().toISOString().split(\"T\")[0];\n\n      // Upload CSV to Vercel Blob\n      const filename = `visits-${resourceName.replace(/[^a-zA-Z0-9]/g, \"_\")}-${currentTime}.csv`;\n      const blob = await put(filename, csvData, {\n        access: \"public\",\n        addRandomSuffix: true,\n        contentType: \"text/csv\",\n      });\n\n      logger.info(\"CSV uploaded to Vercel Blob\", {\n        filename,\n        url: blob.downloadUrl,\n        size: csvData.length,\n      });\n\n      // Store the blob URL in Redis\n      const updatedJob = await jobStore.updateJob(exportId, {\n        status: \"COMPLETED\",\n        result: blob.downloadUrl,\n        resourceName,\n        completedAt: new Date().toISOString(),\n      });\n\n      // Send email notification if requested\n      if (updatedJob?.emailNotification && updatedJob.emailAddress) {\n        try {\n          await sendExportReadyEmail({\n            to: updatedJob.emailAddress,\n            resourceName: resourceName,\n            downloadUrl: `${process.env.NEXTAUTH_URL}/api/teams/${teamId}/export-jobs/${exportId}?download=true`,\n          });\n          logger.info(\"Export ready email sent\", {\n            exportId,\n            emailAddress: updatedJob.emailAddress,\n          });\n        } catch (error) {\n          logger.error(\"Failed to send export ready email\", {\n            exportId,\n            error: error instanceof Error ? error.message : String(error),\n          });\n        }\n      }\n\n      logger.info(\"Export visits task completed successfully\", {\n        exportId,\n        type,\n        resourceId,\n        csvSize: csvData.length,\n        blobUrl: blob.downloadUrl,\n      });\n\n      return {\n        success: true,\n        exportId,\n        resourceName,\n        csvSize: csvData.length,\n        blobUrl: blob.downloadUrl,\n      };\n    } catch (error) {\n      logger.error(\"Export visits task failed\", {\n        exportId,\n        error: error instanceof Error ? error.message : String(error),\n      });\n\n      // Update job status to failed\n      await jobStore.updateJob(exportId, {\n        status: \"FAILED\",\n        error: error instanceof Error ? error.message : String(error),\n      });\n\n      throw error;\n    }\n  },\n});\n\n// Helper function to check if a view occurred during a pause period\nfunction isViewDuringPause(\n  viewedAt: Date,\n  pauseStartsAt: Date | null,\n  pauseEndsAt: Date | null,\n): boolean {\n  if (!pauseStartsAt || !pauseEndsAt) {\n    return false;\n  }\n  return viewedAt >= pauseStartsAt && viewedAt <= pauseEndsAt;\n}\n\nasync function exportDocumentVisits(\n  docId: string,\n  teamId: string,\n): Promise<{\n  csvData: string;\n  resourceName: string;\n}> {\n  // Fetch Document with team pause information\n  const document = await prisma.document.findUnique({\n    where: { id: docId, teamId: teamId },\n    select: {\n      id: true,\n      name: true,\n      numPages: true,\n      versions: {\n        orderBy: { createdAt: \"desc\" },\n        select: {\n          versionNumber: true,\n          createdAt: true,\n          numPages: true,\n        },\n      },\n      team: {\n        select: {\n          plan: true,\n          pauseStartsAt: true,\n          pauseEndsAt: true,\n        },\n      },\n    },\n  });\n\n  if (!document) {\n    throw new Error(\"Document not found\");\n  }\n\n  const { pauseStartsAt, pauseEndsAt } = document.team;\n\n  // Fetch views\n  const allViews = await prisma.view.findMany({\n    where: { documentId: docId },\n    include: {\n      link: { select: { name: true } },\n      agreementResponse: {\n        include: {\n          agreement: {\n            select: {\n              name: true,\n              content: true,\n            },\n          },\n        },\n      },\n      customFieldResponse: {\n        select: {\n          data: true,\n        },\n      },\n    },\n    orderBy: {\n      viewedAt: \"desc\",\n    },\n  });\n\n  // Filter out views that occurred during the pause period\n  const views = allViews.filter(\n    (view) => !isViewDuringPause(view.viewedAt, pauseStartsAt, pauseEndsAt),\n  );\n\n  if (!views || views.length === 0) {\n    throw new Error(\"Document has no views\");\n  }\n\n  const isProPlan = document.team.plan.includes(\"pro\");\n\n  // Collect all unique custom fields from all views\n  const uniqueCustomFields = collectUniqueCustomFields(views);\n\n  // Create CSV rows array starting with headers\n  const csvRows: string[] = [];\n  const headers = [\n    \"Viewed at\",\n    \"Name\",\n    \"Email\",\n    \"Link Name\",\n    \"Total View Duration (s)\",\n    \"Total Document Completion (%)\",\n    \"Document version\",\n    \"Downloaded at\",\n    \"Verified\",\n    \"Agreement Accepted\",\n    \"Agreement Name\",\n    \"Agreement Content\",\n    \"Agreement Accepted At\",\n    \"Viewed from dataroom\",\n    \"Browser\",\n    \"OS\",\n    \"Device\",\n  ];\n\n  if (!isProPlan) {\n    headers.push(\"Country\", \"City\");\n    // Add dynamic custom field headers\n    headers.push(...generateCustomFieldHeaders(uniqueCustomFields));\n  }\n\n  csvRows.push(createCsvRow(headers));\n\n  // Process views with rate limiting\n  logger.info(\"Processing document views with rate limiting\", {\n    viewCount: views.length,\n  });\n\n  for (let i = 0; i < views.length; i++) {\n    const view = views[i];\n\n    logger.info(`Processing view ${i + 1}/${views.length}`, {\n      viewId: view.id,\n      viewedAt: view.viewedAt,\n    });\n\n    // Rate-limited calls to tinybird\n    const [duration, userAgentData] = await Promise.all([\n      tinybirdLimiter.schedule(() =>\n        getViewPageDuration({\n          documentId: docId,\n          viewId: view.id,\n          since: 0,\n        }),\n      ),\n      tinybirdLimiter.schedule(async () => {\n        const result = await getViewUserAgent({\n          viewId: view.id,\n        });\n\n        if (!result || result.rows === 0) {\n          return getViewUserAgent_v2({\n            documentId: docId,\n            viewId: view.id,\n            since: 0,\n          });\n        }\n\n        return result;\n      }),\n    ]);\n\n    const relevantDocumentVersion = document.versions.find(\n      (version) => version.createdAt <= view.viewedAt,\n    );\n\n    const numPages =\n      relevantDocumentVersion?.numPages || document.numPages || 0;\n    const completionRate = numPages\n      ? (duration.data.length / numPages) * 100\n      : 0;\n\n    const totalDuration = duration.data.reduce(\n      (total, data) => total + data.sum_duration,\n      0,\n    );\n\n    const rowData = [\n      view.viewedAt.toISOString(),\n      view.viewerName || \"NaN\",\n      view.viewerEmail || \"NaN\",\n      view.link?.name || \"NaN\",\n      (totalDuration / 1000.0).toFixed(1),\n      completionRate.toFixed(2) + \"%\",\n      relevantDocumentVersion?.versionNumber ||\n        document.versions[0]?.versionNumber ||\n        \"NaN\",\n      view.downloadedAt ? view.downloadedAt.toISOString() : \"NaN\",\n      view.verified ? \"Yes\" : \"No\",\n      view.agreementResponse ? \"Yes\" : \"NaN\",\n      view.agreementResponse?.agreement.name || \"NaN\",\n      view.agreementResponse?.agreement.content || \"NaN\",\n      view.agreementResponse?.createdAt.toISOString() || \"NaN\",\n      view.dataroomId ? \"Yes\" : \"No\",\n      userAgentData?.data[0]?.browser || \"NaN\",\n      userAgentData?.data[0]?.os || \"NaN\",\n      userAgentData?.data[0]?.device || \"NaN\",\n    ];\n\n    if (!isProPlan) {\n      rowData.push(\n        userAgentData?.data[0]?.country || \"NaN\",\n        userAgentData?.data[0]?.city || \"NaN\",\n      );\n      // Add custom field values for this view\n      rowData.push(...extractCustomFieldValues(view, uniqueCustomFields));\n    }\n\n    csvRows.push(createCsvRow(rowData));\n  }\n\n  return {\n    csvData: csvRows.join(\"\\n\"),\n    resourceName: document.name,\n  };\n}\n\n// Helper function to extract all unique custom fields from views\nfunction collectUniqueCustomFields(\n  views: any[],\n): Array<{ identifier: string; label: string }> {\n  const uniqueFields = new Map<string, string>();\n\n  views.forEach((view, index) => {\n    try {\n      if (\n        view &&\n        view.customFieldResponse?.data &&\n        Array.isArray(view.customFieldResponse.data)\n      ) {\n        view.customFieldResponse.data.forEach((field: any) => {\n          if (field.identifier && field.label) {\n            uniqueFields.set(field.identifier, field.label);\n          }\n        });\n      }\n    } catch (error) {\n      logger.warn(`Error processing custom fields for view ${index}:`, {\n        error: String(error),\n      });\n    }\n  });\n\n  // Sort by identifier for consistent column ordering\n  return Array.from(uniqueFields.entries())\n    .sort(([a], [b]) => a.localeCompare(b))\n    .map(([identifier, label]) => ({ identifier, label }));\n}\n\n// Helper function to generate custom field headers\nfunction generateCustomFieldHeaders(\n  uniqueFields: Array<{ identifier: string; label: string }>,\n): string[] {\n  const headers: string[] = [];\n  uniqueFields.forEach((field, index) => {\n    headers.push(`Custom Field ${index + 1} Label`);\n    headers.push(`Custom Field ${index + 1} Value`);\n  });\n  return headers;\n}\n\n// Helper function to extract custom field values for a specific view\nfunction extractCustomFieldValues(\n  view: any,\n  uniqueFields: Array<{ identifier: string; label: string }>,\n): string[] {\n  const values: string[] = [];\n\n  try {\n    // Create a map of the current view's custom field responses\n    const responseMap = new Map<string, { label: string; response: string }>();\n\n    // Check if view exists and has customFieldResponse\n    if (\n      view &&\n      view.customFieldResponse?.data &&\n      Array.isArray(view.customFieldResponse.data)\n    ) {\n      view.customFieldResponse.data.forEach((field: any) => {\n        if (field && field.identifier) {\n          responseMap.set(field.identifier, {\n            label: field.label || \"NaN\",\n            response: field.response || \"NaN\",\n          });\n        }\n      });\n    }\n\n    // Fill in values for each unique field in order\n    uniqueFields.forEach((field) => {\n      const response = responseMap.get(field.identifier);\n      if (response) {\n        values.push(response.label);\n        values.push(response.response);\n      } else {\n        values.push(\"NaN\");\n        values.push(\"NaN\");\n      }\n    });\n  } catch (error) {\n    logger.warn(`Error extracting custom field values:`, {\n      error: String(error),\n    });\n    // Fill with NaN values if there's an error\n    uniqueFields.forEach(() => {\n      values.push(\"NaN\");\n      values.push(\"NaN\");\n    });\n  }\n\n  return values;\n}\n\nasync function exportDataroomVisits(\n  dataroomId: string,\n  teamId: string,\n  groupId?: string,\n): Promise<{\n  csvData: string;\n  resourceName: string;\n}> {\n  // Fetch Dataroom with team pause information\n  const dataroom = await prisma.dataroom.findUnique({\n    where: { id: dataroomId, teamId: teamId },\n    select: {\n      id: true,\n      name: true,\n      documents: {\n        select: {\n          id: true,\n          document: {\n            select: {\n              name: true,\n              numPages: true,\n              versions: {\n                orderBy: { createdAt: \"desc\" },\n                select: {\n                  versionNumber: true,\n                  createdAt: true,\n                  numPages: true,\n                },\n              },\n            },\n          },\n        },\n      },\n      team: {\n        select: {\n          pauseStartsAt: true,\n          pauseEndsAt: true,\n        },\n      },\n    },\n  });\n\n  if (!dataroom) {\n    throw new Error(\"Dataroom not found\");\n  }\n\n  const { pauseStartsAt, pauseEndsAt } = dataroom.team;\n\n  // Fetch views\n  const allViews = await prisma.view.findMany({\n    where: {\n      dataroomId: dataroomId,\n      ...(groupId && { groupId }),\n    },\n    include: {\n      link: { select: { name: true } },\n      agreementResponse: {\n        include: {\n          agreement: {\n            select: {\n              name: true,\n              content: true,\n            },\n          },\n        },\n      },\n      document: {\n        select: {\n          id: true,\n          name: true,\n          numPages: true,\n          versions: {\n            orderBy: { createdAt: \"desc\" },\n            select: {\n              versionNumber: true,\n              createdAt: true,\n              numPages: true,\n            },\n          },\n        },\n      },\n      customFieldResponse: {\n        select: {\n          data: true,\n        },\n      },\n    },\n    orderBy: {\n      viewedAt: \"desc\",\n    },\n  });\n\n  // Filter out views that occurred during the pause period\n  const views = allViews.filter(\n    (view) => !isViewDuringPause(view.viewedAt, pauseStartsAt, pauseEndsAt),\n  );\n\n  if (!views || views.length === 0) {\n    throw new Error(\"Dataroom has no views\");\n  }\n\n  // First get all dataroom views\n  const dataroomViews = views.filter(\n    (view) => view.viewType === \"DATAROOM_VIEW\",\n  );\n  const documentViews = views.filter(\n    (view) => view.viewType === \"DOCUMENT_VIEW\",\n  );\n\n  // Collect all unique custom fields from dataroom views\n  const uniqueCustomFields = collectUniqueCustomFields(dataroomViews);\n\n  logger.info(\"Processing dataroom views with rate limiting\", {\n    dataroomViewCount: dataroomViews.length,\n    documentViewCount: documentViews.length,\n  });\n\n  // Process dataroom views\n  const exportData = [];\n  for (let i = 0; i < dataroomViews.length; i++) {\n    const dataroomView = dataroomViews[i];\n\n    logger.info(`Processing dataroom view ${i + 1}/${dataroomViews.length}`, {\n      viewId: dataroomView.id,\n    });\n\n    // Find associated document views\n    const associatedDocViews = documentViews.filter(\n      (docView) => docView.dataroomViewId === dataroomView.id,\n    );\n\n    // Process document views with rate limiting\n    const documentViewDetails = [];\n    for (let j = 0; j < associatedDocViews.length; j++) {\n      const docView = associatedDocViews[j];\n\n      logger.info(\n        `Processing document view ${j + 1}/${associatedDocViews.length} for dataroom view ${i + 1}`,\n        {\n          docViewId: docView.id,\n        },\n      );\n\n      const duration = await tinybirdLimiter.schedule(() =>\n        getViewPageDuration({\n          documentId: docView.document?.id || \"null\",\n          viewId: docView.id,\n          since: 0,\n        }),\n      );\n\n      const relevantVersion = docView.document?.versions.find(\n        (version) => version.createdAt <= docView.viewedAt,\n      );\n\n      const numPages =\n        relevantVersion?.numPages || docView.document?.numPages || 0;\n      const completionRate = numPages\n        ? (duration.data.length / numPages) * 100\n        : 0;\n\n      documentViewDetails.push({\n        documentName: docView.document?.name || \"NaN\",\n        viewedAt: docView.viewedAt.toISOString(),\n        downloadedAt: docView.downloadedAt?.toISOString() || \"NaN\",\n        duration: duration.data.reduce(\n          (total, data) => total + data.sum_duration,\n          0,\n        ),\n        completionRate: completionRate.toFixed(2) + \"%\",\n        documentVersion:\n          relevantVersion?.versionNumber ||\n          docView.document?.versions[0]?.versionNumber ||\n          \"NaN\",\n        viewId: docView.id,\n      });\n    }\n\n    exportData.push({\n      dataroomViewId: dataroomView.id, // Add the unique view ID for direct matching\n      dataroomViewedAt: dataroomView.viewedAt.toISOString(),\n      dataroomDownloadedAt: dataroomView.downloadedAt?.toISOString() || \"NaN\",\n      viewerName: dataroomView.viewerName || \"NaN\",\n      viewerEmail: dataroomView.viewerEmail || \"NaN\",\n      linkName: dataroomView.link?.name || \"NaN\",\n      verified: dataroomView.verified ? \"Yes\" : \"NaN\",\n      agreementStatus: dataroomView.agreementResponse ? \"Yes\" : \"NaN\",\n      agreementName: dataroomView.agreementResponse?.agreement.name || \"NaN\",\n      agreementAcceptedAt:\n        dataroomView.agreementResponse?.createdAt.toISOString() || \"NaN\",\n      agreementContent:\n        dataroomView.agreementResponse?.agreement.content || \"NaN\",\n      documentViews: documentViewDetails,\n    });\n  }\n\n  // Create a map for efficient dataroom view lookups by ID\n  const dataroomViewMap = new Map();\n  dataroomViews.forEach((view) => {\n    dataroomViewMap.set(view.id, view);\n  });\n\n  // Get user agent data for all document views at once with rate limiting\n  const userAgentDataMap = new Map();\n  for (const docView of documentViews) {\n    const userAgentData = await tinybirdLimiter.schedule(async () => {\n      const result = await getViewUserAgent({\n        viewId: docView.id,\n      });\n\n      if (!result || result.rows === 0) {\n        return getViewUserAgent_v2({\n          documentId: docView.document?.id || \"null\",\n          viewId: docView.id,\n          since: 0,\n        });\n      }\n\n      return result;\n    });\n\n    userAgentDataMap.set(docView.id, userAgentData);\n  }\n\n  // Create CSV\n  const csvRows: string[] = [];\n  const headers = [\n    \"Dataroom Viewed At\",\n    \"Dataroom Downloaded At\",\n    \"Visitor Name\",\n    \"Visitor Email\",\n    \"Link Name\",\n    \"Verified\",\n    \"Agreement Accepted\",\n    \"Agreement Name\",\n    \"Agreement Content\",\n    \"Agreement Accepted At\",\n    \"Document Name\",\n    \"Document Viewed At\",\n    \"Document Downloaded At\",\n    \"Total Visit Duration (s)\",\n    \"Total Document Completion (%)\",\n    \"Document Version\",\n    \"Browser\",\n    \"OS\",\n    \"Device\",\n    \"Country\",\n    \"City\",\n  ];\n\n  // Add dynamic custom field headers\n  headers.push(...generateCustomFieldHeaders(uniqueCustomFields));\n\n  csvRows.push(createCsvRow(headers));\n\n  exportData.forEach((view) => {\n    if (view.documentViews.length === 0) {\n      const rowData = [\n        view.dataroomViewedAt,\n        view.dataroomDownloadedAt,\n        view.viewerName,\n        view.viewerEmail,\n        view.linkName,\n        view.verified,\n        view.agreementStatus,\n        view.agreementName,\n        view.agreementContent,\n        view.agreementAcceptedAt,\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n        \"NaN\",\n      ];\n\n      // Add custom field values for this dataroom view using direct ID lookup\n      const dataroomView = dataroomViewMap.get(view.dataroomViewId);\n      rowData.push(\n        ...extractCustomFieldValues(dataroomView, uniqueCustomFields),\n      );\n\n      csvRows.push(createCsvRow(rowData));\n    } else {\n      view.documentViews.forEach((docView) => {\n        const userAgentData = userAgentDataMap.get(docView.viewId);\n\n        const rowData = [\n          view.dataroomViewedAt,\n          view.dataroomDownloadedAt,\n          view.viewerName,\n          view.viewerEmail,\n          view.linkName,\n          view.verified,\n          view.agreementStatus,\n          view.agreementName,\n          view.agreementContent,\n          view.agreementAcceptedAt,\n          docView.documentName,\n          docView.viewedAt,\n          docView.downloadedAt,\n          (docView.duration / 1000).toFixed(1),\n          docView.completionRate,\n          docView.documentVersion,\n          userAgentData?.data[0]?.browser || \"NaN\",\n          userAgentData?.data[0]?.os || \"NaN\",\n          userAgentData?.data[0]?.device || \"NaN\",\n          userAgentData?.data[0]?.country || \"NaN\",\n          userAgentData?.data[0]?.city || \"NaN\",\n        ];\n\n        // Add custom field values for this dataroom view using direct ID lookup\n        const dataroomView = dataroomViewMap.get(view.dataroomViewId);\n        rowData.push(\n          ...extractCustomFieldValues(dataroomView, uniqueCustomFields),\n        );\n\n        csvRows.push(createCsvRow(rowData));\n      });\n    }\n  });\n\n  return {\n    csvData: csvRows.join(\"\\n\"),\n    resourceName: dataroom.name,\n  };\n}\n"
  },
  {
    "path": "lib/trigger/optimize-video-files.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\nimport ffmpeg from \"fluent-ffmpeg\";\nimport { createReadStream, createWriteStream } from \"fs\";\nimport fs from \"fs/promises\";\nimport os from \"os\";\nimport path from \"path\";\nimport { Readable } from \"stream\";\nimport { pipeline } from \"stream/promises\";\nimport { ReadableStream } from \"stream/web\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport { streamFileServer } from \"@/lib/files/stream-file-server\";\nimport prisma from \"@/lib/prisma\";\n\nexport const processVideo = task({\n  id: \"process-video\",\n  machine: {\n    preset: \"medium-1x\",\n  },\n  run: async (payload: {\n    videoUrl: string;\n    teamId: string;\n    docId: string;\n    documentVersionId: string;\n    fileSize: number;\n  }) => {\n    const { videoUrl, teamId, docId, documentVersionId, fileSize } = payload;\n\n    try {\n      const fileUrl = await getFile({\n        data: videoUrl,\n        type: \"S3_PATH\",\n      });\n\n      logger.info(\"Starting video optimization\", { fileUrl });\n\n      // Create temp directory for input and output\n      const tempDirectory = path.join(os.tmpdir(), `video_${Date.now()}`);\n      await fs.mkdir(tempDirectory, { recursive: true });\n      const inputPath = path.join(tempDirectory, \"input.mp4\");\n      const outputPath = path.join(tempDirectory, \"output.mp4\");\n\n      // Stream video to temporary file\n      const response = await fetch(fileUrl);\n      if (!response.body) {\n        throw new Error(\"Failed to fetch video stream\");\n      }\n\n      logger.info(\"Streaming video to temporary file\");\n      await pipeline(\n        Readable.fromWeb(response.body as ReadableStream),\n        createWriteStream(inputPath),\n      );\n\n      // Get input metadata first\n      const metadata = await new Promise<{\n        width: number;\n        height: number;\n        fps: number;\n        duration: number;\n      }>((resolve, reject) => {\n        ffmpeg.ffprobe(inputPath, (err, metadata) => {\n          if (err) {\n            logger.error(\"Probe error:\", { error: err.message });\n            reject(err);\n            return;\n          }\n          const videoStream = metadata.streams.find(\n            (s) => s.codec_type === \"video\",\n          );\n          if (!videoStream) {\n            reject(new Error(\"No video stream found\"));\n            return;\n          }\n\n          const fps = (() => {\n            const fpsStr =\n              videoStream.r_frame_rate || videoStream.avg_frame_rate;\n            const [num, den] = fpsStr?.split(\"/\").map(Number) || [0, 1];\n            return num / (den || 1);\n          })();\n\n          resolve({\n            width: videoStream.width || 1920,\n            height: videoStream.height || 1080,\n            fps,\n            duration: Math.round(metadata.format.duration || 0),\n          });\n        });\n      });\n\n      // Update document version with metadata\n      await prisma.documentVersion.update({\n        where: { id: documentVersionId },\n        data: {\n          length: metadata.duration,\n        },\n      });\n\n      if (fileSize > 500 * 1024 * 1024) {\n        // if file size is greater than 500MB, skip optimization\n        logger.info(\n          `File size is ${fileSize / 1024 / 1024} MB, skipping optimization`,\n        );\n\n        // Clean up temporary directory\n        await fs.rm(tempDirectory, { recursive: true });\n        logger.info(\"Temporary directory cleaned up\", { tempDirectory });\n        return {\n          success: true,\n          message: \"File size is too large, skipping optimization\",\n        };\n      }\n\n      // Calculate encoding parameters\n      const keyframeInterval = Math.round(metadata.fps * 2);\n      const bitrate = \"6000k\";\n      const maxBitrate = parseInt(bitrate.replace(\"k\", \"\")) * 2;\n\n      // Only scale if the video is larger than 1080p\n      const scaleFilter = metadata.width > 1920 ? \"-vf scale=1920:-2\" : null;\n\n      logger.info(\"Video metadata:\", {\n        originalWidth: metadata.width,\n        originalHeight: metadata.height,\n        fps: metadata.fps,\n        duration: metadata.duration,\n        willScale: !!scaleFilter,\n      });\n\n      // Process video to temporary file first\n      await new Promise<void>((resolve, reject) => {\n        const ffmpegCommand = ffmpeg(inputPath)\n          .inputOptions([\"-y\"])\n          .outputOptions([\n            ...(scaleFilter ? [scaleFilter] : []), // Only include scale if needed\n            \"-c:v libx264\",\n            \"-profile:v high\",\n            \"-level:v 4.1\",\n            \"-c:a aac\",\n            \"-ar 48000\",\n            \"-b:a 128k\",\n            `-b:v ${bitrate}`,\n            `-maxrate ${maxBitrate}k`,\n            `-bufsize ${maxBitrate}k`,\n            \"-preset medium\",\n            `-g ${keyframeInterval}`,\n            `-keyint_min ${keyframeInterval}`,\n            \"-sc_threshold 0\",\n            \"-movflags +faststart\",\n          ])\n          .output(outputPath)\n          .on(\"start\", (cmd) => {\n            logger.info(\"FFmpeg started:\", {\n              cmd,\n              originalSize: `${metadata.width}x${metadata.height}`,\n              scaling: !!scaleFilter,\n              fps: metadata.fps,\n              keyframeInterval,\n            });\n          })\n          .on(\"error\", (err, stdout, stderr) => {\n            logger.error(\"FFmpeg error:\", {\n              error: err.message,\n              stdout,\n              stderr,\n            });\n            reject(err);\n          })\n          .on(\"end\", () => {\n            logger.info(\"FFmpeg completed\");\n            resolve();\n          });\n\n        ffmpegCommand.run();\n      });\n\n      // Create read stream from output file\n      const fileStream = createReadStream(outputPath);\n\n      // Add error handling for the file stream\n      fileStream.on(\"error\", (err) => {\n        logger.error(\"Stream error:\", {\n          error: err.message,\n          stack: err.stack,\n        });\n      });\n\n      // Start the upload process using streamFileServer\n      const uploadPromise = streamFileServer({\n        file: {\n          name: \"optimized.mp4\",\n          type: \"video/mp4\",\n          stream: fileStream,\n        },\n        teamId,\n        docId,\n      });\n\n      // Wait for the upload to complete\n      const { type, data } = await uploadPromise;\n      logger.info(\"Upload completed\", { type, data });\n\n      if (!data) {\n        throw new Error(\"Upload failed: No file path returned\");\n      }\n\n      // Update the document version with the new file and length\n      await prisma.documentVersion.update({\n        where: {\n          id: documentVersionId,\n        },\n        data: {\n          file: data,\n        },\n      });\n\n      // Clean up temporary directory\n      await fs.rm(tempDirectory, { recursive: true });\n      logger.info(\"Temporary directory cleaned up\", { tempDirectory });\n\n      return {\n        success: true,\n        message: \"Successfully optimized video\",\n      };\n    } catch (error) {\n      logger.error(\"Failed to optimize video:\", {\n        error: error instanceof Error ? error.message : String(error),\n        stack: error instanceof Error ? error.stack : undefined,\n      });\n      throw error;\n    }\n  },\n});\n"
  },
  {
    "path": "lib/trigger/pause-reminder-notification.ts",
    "content": "import { sendPauseResumeNotificationTask } from \"@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification\";\n\nexport { sendPauseResumeNotificationTask };\n"
  },
  {
    "path": "lib/trigger/pdf-to-image-route.ts",
    "content": "import { AbortTaskRunError, logger, task } from \"@trigger.dev/sdk/v3\";\n\nimport { isTrustedTeam } from \"@/lib/edge-config/trusted-teams\";\nimport { getFile } from \"@/lib/files/get-file\";\nimport prisma from \"@/lib/prisma\";\nimport { updateStatus } from \"@/lib/utils/generate-trigger-status\";\n\ntype ConvertPdfToImagePayload = {\n  documentId: string;\n  documentVersionId: string;\n  teamId: string;\n  versionNumber?: number;\n};\n\nexport const convertPdfToImageRoute = task({\n  id: \"convert-pdf-to-image-route\",\n  run: async (payload: ConvertPdfToImagePayload) => {\n    const { documentVersionId, teamId, documentId, versionNumber } = payload;\n\n    updateStatus({ progress: 0, text: \"Initializing...\" });\n\n    // 1. get file url from document version\n    const documentVersion = await prisma.documentVersion.findUnique({\n      where: {\n        id: documentVersionId,\n      },\n      select: {\n        file: true,\n        storageType: true,\n        numPages: true,\n      },\n    });\n\n    // if documentVersion is null, log error and abort\n    if (!documentVersion) {\n      logger.error(\"File not found\", { payload });\n      updateStatus({ progress: 0, text: \"Document not found\" });\n      throw new AbortTaskRunError(\"Document version not found\");\n    }\n\n    logger.info(\"Document version\", { documentVersion });\n    updateStatus({ progress: 10, text: \"Retrieving file...\" });\n\n    // 2. get signed url from file\n    const signedUrl = await getFile({\n      type: documentVersion.storageType,\n      data: documentVersion.file,\n    });\n\n    logger.info(\"Retrieved signed url\", { signedUrl });\n\n    if (!signedUrl) {\n      logger.error(\"Failed to get signed url\", { payload });\n      updateStatus({ progress: 0, text: \"Failed to retrieve document\" });\n      throw new AbortTaskRunError(\"Failed to get signed URL for document\");\n    }\n\n    let numPages = documentVersion.numPages;\n\n    // skip if the numPages are already defined\n    if (!numPages || numPages === 1) {\n      // 3. send file to api/convert endpoint in a task and get back number of pages\n      logger.info(\"Sending file to api/get-pages endpoint\");\n\n      try {\n        const response = await fetch(\n          `${process.env.NEXT_PUBLIC_BASE_URL}/api/mupdf/get-pages`,\n          {\n            method: \"POST\",\n            body: JSON.stringify({ url: signedUrl }),\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n            },\n          },\n        );\n\n        if (!response.ok) {\n          const errorData = await response.json().catch(() => ({}));\n          logger.error(\"Failed to get number of pages\", {\n            signedUrl,\n            status: response.status,\n            error: errorData,\n            payload,\n          });\n          updateStatus({ progress: 0, text: \"Failed to get number of pages\" });\n          throw new AbortTaskRunError(\n            `Failed to get number of pages (status: ${response.status})`,\n          );\n        }\n\n        const { numPages: numPagesResult } = (await response.json()) as {\n          numPages: number;\n        };\n\n        logger.info(\"Received number of pages\", { numPagesResult });\n\n        if (numPagesResult < 1) {\n          logger.error(\"Failed to get number of pages\", { payload });\n          updateStatus({ progress: 0, text: \"Failed to get number of pages\" });\n          throw new AbortTaskRunError(\n            \"Failed to get number of pages - invalid page count\",\n          );\n        }\n\n        numPages = numPagesResult;\n      } catch (error: unknown) {\n        // Re-throw AbortTaskRunError so it propagates without retry\n        if (error instanceof AbortTaskRunError) {\n          throw error;\n        }\n\n        const errorMessage =\n          error instanceof Error ? error.message : \"Unknown error\";\n        const errorCause =\n          error instanceof Error && error.cause ? error.cause : undefined;\n\n        logger.error(\"Failed to fetch page count\", {\n          error: errorMessage,\n          cause: errorCause,\n          payload,\n        });\n        updateStatus({ progress: 0, text: \"Failed to retrieve page count\" });\n        throw new AbortTaskRunError(\n          `Failed to fetch page count: ${errorMessage}`,\n        );\n      }\n    }\n\n    // Check once if this team is trusted (skips keyword checks for all pages)\n    const trustedTeam = await isTrustedTeam(teamId);\n\n    updateStatus({ progress: 20, text: \"Converting document...\" });\n\n    // 4. iterate through pages and upload to blob in a task\n    let currentPage = 0;\n    let conversionWithoutError = true;\n    for (var i = 0; i < numPages; ++i) {\n      if (!conversionWithoutError) {\n        break;\n      }\n\n      // increment currentPage\n      currentPage = i + 1;\n      logger.info(`Converting page ${currentPage}`, {\n        currentPage,\n        numPages,\n      });\n\n      try {\n        // send page number to api/convert-page endpoint in a task and get back page img url\n        const response = await fetch(\n          `${process.env.NEXT_PUBLIC_BASE_URL}/api/mupdf/convert-page`,\n          {\n            method: \"POST\",\n            body: JSON.stringify({\n              documentVersionId: documentVersionId,\n              pageNumber: currentPage,\n              url: signedUrl,\n              teamId: teamId,\n              trustedTeam: trustedTeam,\n            }),\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n            },\n          },\n        );\n\n        if (!response.ok) {\n          const errorData = await response.json().catch(() => ({}));\n\n          // If document was blocked, stop processing entirely\n          if (response.status === 400 && errorData.error?.includes(\"blocked\")) {\n            logger.error(\"Document blocked\", {\n              pageNumber: currentPage,\n              matchedUrl: errorData.matchedUrl,\n              matchedKeyword: errorData.matchedKeyword,\n              payload,\n            });\n\n            updateStatus({\n              progress: 0,\n              text: `Document couldn't be processed`,\n            });\n\n            throw new AbortTaskRunError(\"Document processing blocked\");\n          }\n\n          throw new Error(\n            `Failed to convert page ${currentPage} (status: ${response.status})`,\n          );\n        }\n\n        const { documentPageId } = (await response.json()) as {\n          documentPageId: string;\n        };\n\n        logger.info(`Created document page for page ${currentPage}:`, {\n          documentPageId,\n          payload,\n        });\n      } catch (error: unknown) {\n        // Re-throw AbortTaskRunError so it propagates without retry\n        if (error instanceof AbortTaskRunError) {\n          throw error;\n        }\n\n        conversionWithoutError = false;\n        const errorMessage =\n          error instanceof Error ? error.message : \"Unknown error\";\n        const errorCause =\n          error instanceof Error && error.cause ? error.cause : undefined;\n\n        logger.error(\"Failed to convert page\", {\n          pageNumber: currentPage,\n          error: errorMessage,\n          cause: errorCause,\n          payload,\n        });\n      }\n\n      updateStatus({\n        progress: (currentPage / numPages) * 100,\n        text: `${currentPage} / ${numPages} pages processed`,\n      });\n    }\n\n    if (!conversionWithoutError) {\n      logger.error(\"Failed to process pages\", { payload });\n      updateStatus({\n        progress: (currentPage / numPages) * 100,\n        text: `Error processing page ${currentPage} of ${numPages}`,\n      });\n      throw new AbortTaskRunError(\n        `Failed to process page ${currentPage} of ${numPages}`,\n      );\n    }\n\n    // 5. after all pages are uploaded, update document version to hasPages = true\n    await prisma.documentVersion.update({\n      where: {\n        id: documentVersionId,\n      },\n      data: {\n        numPages: numPages,\n        hasPages: true,\n        isPrimary: true,\n      },\n      select: {\n        id: true,\n        hasPages: true,\n        isPrimary: true,\n      },\n    });\n\n    logger.info(\"Enabling pages\");\n    updateStatus({\n      progress: 90,\n      text: \"Enabling pages...\",\n    });\n\n    if (versionNumber) {\n      // after all pages are uploaded, update all other versions to be not primary\n      await prisma.documentVersion.updateMany({\n        where: {\n          documentId: documentId,\n          versionNumber: {\n            not: versionNumber,\n          },\n        },\n        data: {\n          isPrimary: false,\n        },\n      });\n    }\n\n    logger.info(\"Revalidating link\");\n    updateStatus({\n      progress: 95,\n      text: \"Revalidating link...\",\n    });\n\n    // initialize link revalidation for all the document's links\n    await fetch(\n      `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&documentId=${documentId}`,\n    );\n\n    updateStatus({\n      progress: 100,\n      text: \"Processing complete\",\n    });\n\n    logger.info(\"Processing complete\");\n    return {\n      success: true,\n      message: \"Successfully converted PDF to images\",\n      totalPages: numPages,\n    };\n  },\n});\n"
  },
  {
    "path": "lib/trigger/send-scheduled-email.ts",
    "content": "import { logger, task } from \"@trigger.dev/sdk/v3\";\n\nimport { sendDataroomInfoEmail } from \"@/lib/emails/send-dataroom-info\";\nimport { sendDataroomTrial24hReminderEmail } from \"@/lib/emails/send-dataroom-trial-24h\";\nimport { sendDataroomTrialEndEmail } from \"@/lib/emails/send-dataroom-trial-end\";\nimport { sendUpgradeOneMonthCheckinEmail } from \"@/lib/emails/send-upgrade-month-checkin\";\nimport prisma from \"@/lib/prisma\";\n\nexport const sendDataroomTrialInfoEmailTask = task({\n  id: \"send-dataroom-trial-info-email\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: { to: string; useCase: string }) => {\n    await sendDataroomInfoEmail(\n      {\n        user: { email: payload.to, name: \"Marc\" },\n      },\n      payload.useCase,\n    );\n    logger.info(\"Email sent\", { to: payload.to });\n  },\n});\n\nexport const sendDataroomTrial24hReminderEmailTask = task({\n  id: \"send-dataroom-trial-24h-reminder-email\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: { to: string; name: string; teamId: string }) => {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: payload.teamId,\n      },\n      select: {\n        plan: true,\n      },\n    });\n\n    if (!team) {\n      logger.error(\"Team not found\", { teamId: payload.teamId });\n      return;\n    }\n\n    // Only send reminder email if team still has trial plan\n    if (team.plan.includes(\"drtrial\")) {\n      await sendDataroomTrial24hReminderEmail({\n        email: payload.to,\n        name: payload.name,\n      });\n      logger.info(\"Email sent\", { to: payload.to, teamId: payload.teamId });\n    } else {\n      logger.info(\"Team upgraded - no trial reminder needed\", {\n        teamId: payload.teamId,\n        plan: team.plan,\n      });\n    }\n  },\n});\n\nexport const sendDataroomTrialExpiredEmailTask = task({\n  id: \"send-dataroom-trial-expired-email\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: { to: string; name: string; teamId: string }) => {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: payload.teamId,\n      },\n      select: {\n        plan: true,\n      },\n    });\n\n    if (!team) {\n      logger.error(\"Team not found\", { teamId: payload.teamId });\n      return;\n    }\n\n    if (team.plan.includes(\"drtrial\")) {\n      // send email to the user\n      await sendDataroomTrialEndEmail({\n        email: payload.to,\n        name: payload.name,\n      });\n      logger.info(\"Email sent\", { to: payload.to, teamId: payload.teamId });\n\n      // remove trial on the plan\n      const updatedTeam = await prisma.team.update({\n        where: { id: payload.teamId },\n        data: { plan: team.plan.replace(\"+drtrial\", \"\") },\n      });\n\n      const isPaid = [\n        \"pro\",\n        \"business\",\n        \"datarooms\",\n        \"datarooms-plus\",\n        \"datarooms-premium\",\n      ].includes(updatedTeam.plan);\n\n      if (!isPaid) {\n        // remove branding\n        await prisma.brand.deleteMany({\n          where: {\n            teamId: payload.teamId,\n          },\n        });\n        logger.info(\"Branding removed after trial expired\", {\n          teamId: payload.teamId,\n        });\n\n        // block all non-admin users\n        const blockedUsers = await prisma.userTeam.updateMany({\n          where: {\n            teamId: payload.teamId,\n            role: { not: \"ADMIN\" },\n          },\n          data: {\n            status: \"BLOCKED_TRIAL_EXPIRED\",\n            blockedAt: new Date(),\n          },\n        });\n        logger.info(\"Team members blocked after trial expired\", {\n          teamId: payload.teamId,\n          usersCount: blockedUsers.count,\n        });\n      }\n\n      logger.info(\"Trial removed\", { teamId: payload.teamId });\n      return;\n    }\n\n    logger.info(\"Team upgraded - no further action\", {\n      teamId: payload.teamId,\n      plan: team.plan,\n    });\n    return;\n  },\n});\n\nexport const sendUpgradeOneMonthCheckinEmailTask = task({\n  id: \"send-upgrade-one-month-checkin-email\",\n  retry: { maxAttempts: 3 },\n  run: async (payload: { to: string; name: string; teamId: string }) => {\n    try {\n      const team = await prisma.team.findUnique({\n        where: { id: payload.teamId },\n        select: {\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        logger.error(\"Team not found\", { teamId: payload.teamId });\n        return;\n      }\n\n      if (\n        ![\"pro\", \"business\", \"datarooms\", \"datarooms-plus\", \"datarooms-premium\"].includes(team.plan)\n      ) {\n        logger.info(\"Team not on paid plan - no further action\", {\n          teamId: payload.teamId,\n          plan: team.plan,\n        });\n        return;\n      }\n\n      await sendUpgradeOneMonthCheckinEmail({\n        user: { email: payload.to, name: payload.name },\n      });\n      logger.info(\"Email sent\", { to: payload.to });\n    } catch (error) {\n      logger.error(\"Error sending upgrade one month checkin email\", { error });\n      return;\n    }\n  },\n});\n"
  },
  {
    "path": "lib/types/document-preview.ts",
    "content": "export interface DocumentPreviewData {\n  documentId: string;\n  documentName: string;\n  documentType: string;\n  fileType: string;\n  isVertical: boolean;\n  numPages: number;\n  advancedExcelEnabled?: boolean;\n  pages?: {\n    file: string | null;\n    pageNumber: string;\n    embeddedLinks: string[];\n    pageLinks: {\n      href: string;\n      coords: string;\n      isInternal?: boolean;\n      targetPage?: number;\n    }[];\n    metadata: { width: number; height: number; scaleFactor: number };\n  }[];\n  file?: string;\n  sheetData?: any;\n}\n"
  },
  {
    "path": "lib/types/index-file.ts",
    "content": "export interface DataroomIndexEntry {\n  hierarchicalIndex: string | null | undefined;\n  name: string;\n  type: \"File\" | \"Folder\" | \"Root Folder\";\n  path: string;\n  size?: number;\n  pages?: number;\n  lastUpdated: Date;\n  onlineUrl?: string;\n  mimeType?: string;\n  createdAt?: Date;\n  version?: number;\n}\n\nexport interface DataroomIndex {\n  dataroomId: string;\n  dataroomName: string;\n  linkId: string;\n  generatedAt: Date;\n  entries: DataroomIndexEntry[];\n  totalFiles: number;\n  totalFolders: number;\n  totalSize: number;\n}\n\nexport type IndexFileFormat = \"excel\" | \"csv\" | \"json\";\n"
  },
  {
    "path": "lib/types.ts",
    "content": "import {\n  Agreement,\n  CustomField,\n  DataroomDocument,\n  DataroomFolder,\n  Document,\n  DocumentVersion,\n  Link,\n  PermissionGroupAccessControls,\n  User as PrismaUser,\n  View,\n  ViewerGroupAccessControls,\n} from \"@prisma/client\";\nimport { User as NextAuthUser } from \"next-auth\";\nimport { z } from \"zod\";\n\nexport type CustomUser = NextAuthUser & PrismaUser;\n\nexport interface CreateUserEmailProps {\n  user: {\n    name: string | null | undefined;\n    email: string | null | undefined;\n  };\n}\n\nexport interface DocumentWithLinksAndLinkCountAndViewCount extends Document {\n  _count: {\n    links: number;\n    views: number;\n    versions: number;\n    datarooms: number;\n  };\n  links: Link[];\n  folder: {\n    name: string;\n    path: string;\n  };\n  folderList: string[];\n}\n\nexport interface DocumentWithVersion extends Document {\n  versions: DocumentVersion[];\n  folder: {\n    name: string;\n    path: string;\n  };\n  datarooms: {\n    dataroom: {\n      id: string;\n      name: string;\n    };\n    folder: {\n      id: string;\n      name: string;\n      path: string;\n    };\n  }[];\n  hasPageLinks: boolean;\n}\n\nexport interface LinkWithViews extends Link {\n  _count: {\n    views: number;\n  };\n  views: View[];\n  feedback: { id: true; data: { question: string; type: string } } | null;\n  customFields: CustomField[];\n  tags: TagProps[];\n  uploadFolderName: string | undefined;\n  visitorGroups?: { visitorGroupId: string }[];\n}\n\nexport interface LinkWithDocument extends Link {\n  document: Document & {\n    versions: {\n      id: string;\n      versionNumber: number;\n      type: string;\n      hasPages: boolean;\n      file: string;\n      isVertical: boolean;\n    }[];\n    team: {\n      plan: string;\n    } | null;\n  };\n  feedback: {\n    id: string;\n    data: {\n      question: string;\n      type: string;\n    };\n  } | null;\n  agreement: Agreement | null;\n  customFields: CustomField[];\n}\n\nexport interface LinkWithDataroomDocument extends Link {\n  dataroomDocument: DataroomDocument & {\n    document: Document & {\n      versions: {\n        id: string;\n        versionNumber: number;\n        type: string;\n        hasPages: boolean;\n        file: string;\n        isVertical: boolean;\n      }[];\n    };\n  };\n  feedback: {\n    id: string;\n    data: {\n      question: string;\n      type: string;\n    };\n  } | null;\n  agreement: Agreement | null;\n  customFields: CustomField[];\n  teamId: string;\n  team: {\n    plan: string;\n  };\n}\n\nexport interface LinkWithDataroom extends Link {\n  dataroom: {\n    id: string;\n    name: string;\n    teamId: string;\n    documents: {\n      id: string;\n      folderId: string | null;\n      updatedAt: Date;\n      orderIndex: number | null;\n      hierarchicalIndex: string | null;\n      document: {\n        id: string;\n        name: string;\n        versions: {\n          id: string;\n          versionNumber: number;\n          type: string;\n          hasPages: boolean;\n          file: string;\n          isVertical: boolean;\n          updatedAt: Date;\n        }[];\n      };\n    }[];\n    folders: DataroomFolder[];\n    lastUpdatedAt: Date;\n    createdAt: Date;\n  };\n  group?: {\n    accessControls: ViewerGroupAccessControls[];\n  };\n  accessControls?:\n    | ViewerGroupAccessControls[]\n    | PermissionGroupAccessControls[];\n  agreement: Agreement | null;\n  customFields: CustomField[];\n  enableIndexFile: boolean;\n}\n\nexport interface Geo {\n  city?: string | undefined;\n  country?: string | undefined;\n  region?: string | undefined;\n  latitude?: string | undefined;\n  longitude?: string | undefined;\n}\n\n// Custom Domain Types\n\nexport type DomainVerificationStatusProps =\n  | \"Valid Configuration\"\n  | \"Invalid Configuration\"\n  | \"Pending Verification\"\n  | \"Domain Not Found\"\n  | \"Unknown Error\"\n  | \"Conflicting DNS Records\";\n\n// From https://vercel.com/docs/rest-api/endpoints#get-a-project-domain\nexport interface DomainResponse {\n  name: string;\n  apexName: string;\n  projectId: string;\n  redirect?: string | null;\n  redirectStatusCode?: (307 | 301 | 302 | 308) | null;\n  gitBranch?: string | null;\n  updatedAt?: number;\n  createdAt?: number;\n  /** `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. */\n  verified: boolean;\n  /** 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`. */\n  verification: {\n    type: string;\n    domain: string;\n    value: string;\n    reason: string;\n  }[];\n}\n\n// From https://vercel.com/docs/rest-api/endpoints#get-a-domain-s-configuration\nexport interface DomainConfigResponse {\n  /** 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. */\n  configuredBy?: (\"CNAME\" | \"A\" | \"http\") | null;\n  /** Which challenge types the domain can use for issuing certs. */\n  acceptedChallenges?: (\"dns-01\" | \"http-01\")[];\n  /** Whether or not the domain is configured AND we can automatically generate a TLS certificate. */\n  misconfigured: boolean;\n  /** conflicts */\n  conflicts: {\n    name: string;\n    type: string;\n    value: string;\n  }[];\n}\n\n// From https://vercel.com/docs/rest-api/endpoints#verify-project-domain\nexport interface DomainVerificationResponse {\n  name: string;\n  apexName: string;\n  projectId: string;\n  redirect?: string | null;\n  redirectStatusCode?: (307 | 301 | 302 | 308) | null;\n  gitBranch?: string | null;\n  updatedAt?: number;\n  createdAt?: number;\n  /** `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. */\n  verified: boolean;\n  /** 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`. */\n  verification?: {\n    type: string;\n    domain: string;\n    value: string;\n    reason: string;\n  }[];\n}\n\nexport type AnalyticsEvents =\n  | {\n      event: \"User Signed Up\";\n      userId: string;\n      email: string | null | undefined;\n    }\n  | {\n      event: \"Document Added\";\n      documentId: string;\n      name: string;\n      fileSize: string | null | undefined;\n      path: string | null | undefined;\n    }\n  | {\n      event: \"Link Added\";\n      linkId: string;\n      documentId: string;\n      customDomain: string | null | undefined;\n    }\n  | { event: \"User Upgraded\"; email: string | null | undefined }\n  | {\n      event: \"User Signed In\";\n      email: string | null | undefined;\n    }\n  | {\n      event: \"Link Viewed\";\n      documentId: string;\n      linkId: string;\n      viewerId: string;\n      viewerEmail: string | null | undefined;\n    }\n  | {\n      event: \"Domain Added\";\n      slug: string;\n    }\n  | {\n      event: \"Domain Verified\";\n      slug: string;\n    }\n  | {\n      event: \"Domain Deleted\";\n      slug: string;\n    }\n  | {\n      event: \"Team Member Invitation Accepted\";\n      teamId: string;\n    }\n  | {\n      event: \"Stripe Checkout Clicked\";\n      teamId: string;\n      priceId: string;\n    }\n  | {\n      event: \"Stripe Billing Portal Clicked\";\n      teamId: string;\n      action?: string;\n    }\n  | {\n      event: \"User Sign In Attempted\";\n      email: string | undefined;\n      userId: string;\n    };\n\nexport interface Team {\n  id: string;\n  name?: string;\n  logo?: React.ElementType;\n  plan?: string;\n  createdAt?: Date;\n  enableExcelAdvancedMode?: boolean;\n  replicateDataroomFolders?: boolean;\n}\n\nexport interface TeamDetail {\n  id: string;\n  name: string;\n  users: {\n    role: \"ADMIN\" | \"MANAGER\" | \"MEMBER\";\n    status: \"ACTIVE\" | \"BLOCKED_TRIAL_EXPIRED\";\n    teamId: string;\n    userId: string;\n    user: {\n      email: string;\n      name: string;\n    };\n  }[];\n  documents: {\n    owner: {\n      name: string;\n      id: string;\n    };\n  }[];\n}\n\nexport const WatermarkConfigSchema = z.object({\n  text: z.string().min(1, \"Text is required.\"),\n  isTiled: z.boolean(),\n  position: z.enum([\n    \"top-left\",\n    \"top-center\",\n    \"top-right\",\n    \"middle-left\",\n    \"middle-center\",\n    \"middle-right\",\n    \"bottom-left\",\n    \"bottom-center\",\n    \"bottom-right\",\n  ]),\n  rotation: z.union([\n    z.literal(0),\n    z.literal(30),\n    z.literal(45),\n    z.literal(90),\n    z.literal(180),\n  ]),\n  color: z.string().refine((val) => /^#([0-9A-F]{3}){1,2}$/i.test(val), {\n    message: \"Invalid color format. Use HEX format like #RRGGBB.\",\n  }),\n  fontSize: z.number().min(1, \"Font size must be greater than 0.\"),\n  opacity: z.number().min(0).max(1, \"Opacity must be between 0 and 1.\"),\n});\n\nexport type WatermarkConfig = z.infer<typeof WatermarkConfigSchema>;\n\nexport type NotionTheme = \"light\" | \"dark\";\n\nexport type BasePlan =\n  | \"free\"\n  | \"pro\"\n  | \"business\"\n  | \"datarooms\"\n  | \"datarooms-plus\"\n  | \"datarooms-premium\"\n  | \"enterprise\";\n\nexport const tagColors = [\n  \"red\",\n  \"yellow\",\n  \"green\",\n  \"blue\",\n  \"purple\",\n  \"pink\",\n  \"slate\",\n  \"fuchsia\",\n] as const;\n\nexport type TagColorProps = (typeof tagColors)[number];\n\nexport interface TagsWithTotalCount {\n  tags: TagProps[];\n  totalCount: number;\n}\n\nexport interface TagProps {\n  id: string;\n  name: string;\n  description: string | null;\n  color: TagColorProps | string;\n  _count?: {\n    items: number;\n  };\n}\n"
  },
  {
    "path": "lib/unsend.ts",
    "content": "import { Unsend } from \"unsend\";\n\nimport prisma from \"@/lib/prisma\";\n\nconst unsend = process.env.UNSEND_API_KEY\n  ? new Unsend(process.env.UNSEND_API_KEY, process.env.UNSEND_BASE_URL)\n  : null;\n\nconst contactBookId = process.env.UNSEND_CONTACT_BOOK_ID as string;\n\nconst subscribe = async (email: string) => {\n  if (!unsend) {\n    console.error(\"UNSEND_API_KEY is not set in the .env. Skipping.\");\n    return;\n  }\n\n  if (!contactBookId) {\n    console.error(\"UNSEND_CONTACT_BOOK_ID is not set in the .env. Skipping.\");\n    return;\n  }\n\n  const contactId = await unsend.contacts.create(contactBookId, {\n    email,\n  });\n\n  if (!contactId.data?.contactId) {\n    return;\n  }\n\n  await prisma.user.update({\n    where: {\n      email,\n    },\n    data: {\n      contactId: contactId.data.contactId,\n    },\n  });\n};\n\nconst unsubscribe = async (email: string): Promise<void> => {\n  if (!unsend) {\n    console.error(\"UNSEND_API_KEY is not set in the .env. Skipping.\");\n    return;\n  }\n\n  if (!contactBookId) {\n    console.error(\"UNSEND_CONTACT_BOOK_ID is not set in the .env. Skipping.\");\n    return;\n  }\n\n  if (!email || email === \"\") {\n    return;\n  }\n\n  const user = await prisma.user.findUnique({\n    where: { email },\n    select: { contactId: true },\n  });\n\n  if (!user?.contactId) {\n    return;\n  }\n\n  await unsend.contacts.update(contactBookId, user.contactId, {\n    subscribed: false,\n  });\n};\n\nexport default unsend;\nexport { subscribe, unsubscribe };\n"
  },
  {
    "path": "lib/utils/calculate-hierarchical-indexes.ts",
    "content": "import { Prisma } from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\n\ninterface DataroomItem {\n  id: string;\n  name: string;\n  orderIndex: number | null;\n  parentId?: string | null;\n  folderId?: string | null;\n  type: \"folder\" | \"document\";\n}\n\ninterface HierarchicalItem extends DataroomItem {\n  hierarchicalIndex: string;\n  children: HierarchicalItem[];\n}\n\n/**\n * Sorts items by orderIndex first (nulls last), then by name\n */\nfunction sortItems(items: DataroomItem[]): DataroomItem[] {\n  return items.sort((a, b) => {\n    // First sort by orderIndex (nulls go to the end)\n    if (a.orderIndex !== null && b.orderIndex !== null) {\n      if (a.orderIndex !== b.orderIndex) {\n        return a.orderIndex - b.orderIndex;\n      }\n    } else if (a.orderIndex !== null) {\n      return -1; // a comes first\n    } else if (b.orderIndex !== null) {\n      return 1; // b comes first\n    }\n\n    // Then sort by name\n    return a.name.localeCompare(b.name);\n  });\n}\n\n/**\n * Builds a hierarchical tree structure from flat items\n */\nfunction buildHierarchy(\n  items: DataroomItem[],\n  parentId: string | null = null,\n): HierarchicalItem[] {\n  const children = items.filter((item) => {\n    if (item.type === \"folder\") {\n      return item.parentId === parentId;\n    } else {\n      return item.folderId === parentId;\n    }\n  });\n\n  const sortedChildren = sortItems(children);\n\n  return sortedChildren.map((item, index) => {\n    const hierarchicalItem: HierarchicalItem = {\n      ...item,\n      hierarchicalIndex: \"\", // Will be set later\n      children: buildHierarchy(items, item.id),\n    };\n\n    return hierarchicalItem;\n  });\n}\n\n/**\n * Assigns hierarchical indexes to items recursively\n */\nfunction assignHierarchicalIndexes(\n  items: HierarchicalItem[],\n  prefix: string = \"\",\n): void {\n  items.forEach((item, index) => {\n    const currentIndex = index + 1;\n    item.hierarchicalIndex = prefix\n      ? `${prefix}.${currentIndex}`\n      : `${currentIndex}`;\n\n    if (item.children.length > 0) {\n      assignHierarchicalIndexes(item.children, item.hierarchicalIndex);\n    }\n  });\n}\n\n/**\n * Flattens the hierarchical tree back to a flat array with hierarchical indexes\n */\nfunction flattenHierarchy(items: HierarchicalItem[]): Array<{\n  id: string;\n  hierarchicalIndex: string;\n  type: \"folder\" | \"document\";\n}> {\n  const result: Array<{\n    id: string;\n    hierarchicalIndex: string;\n    type: \"folder\" | \"document\";\n  }> = [];\n\n  items.forEach((item) => {\n    result.push({\n      id: item.id,\n      hierarchicalIndex: item.hierarchicalIndex,\n      type: item.type,\n    });\n\n    if (item.children.length > 0) {\n      result.push(...flattenHierarchy(item.children));\n    }\n  });\n\n  return result;\n}\n\n/**\n * Calculates and updates hierarchical indexes for all folders and documents in a dataroom\n */\nexport async function calculateAndUpdateHierarchicalIndexes(\n  dataroomId: string,\n): Promise<{ foldersUpdated: number; documentsUpdated: number }> {\n  try {\n    return await prisma.$transaction(\n      async (tx) => {\n        // Consistent snapshot of folders and documents\n        const folders = await tx.dataroomFolder.findMany({\n          where: { dataroomId },\n          select: {\n            id: true,\n            name: true,\n            parentId: true,\n            orderIndex: true,\n          },\n        });\n        const documents = await tx.dataroomDocument.findMany({\n          where: { dataroomId },\n          select: {\n            id: true,\n            folderId: true,\n            orderIndex: true,\n            document: {\n              select: {\n                name: true,\n              },\n            },\n          },\n        });\n\n        // Convert to unified format\n        const allItems: DataroomItem[] = [\n          ...folders.map((folder) => ({\n            id: folder.id,\n            name: folder.name,\n            orderIndex: folder.orderIndex,\n            parentId: folder.parentId,\n            type: \"folder\" as const,\n          })),\n          ...documents.map((doc) => ({\n            id: doc.id,\n            name: doc.document.name,\n            orderIndex: doc.orderIndex,\n            folderId: doc.folderId,\n            type: \"document\" as const,\n          })),\n        ];\n\n        // Build hierarchy starting from root items (no parent)\n        const hierarchy = buildHierarchy(allItems, null);\n\n        // Assign hierarchical indexes\n        assignHierarchicalIndexes(hierarchy);\n\n        // Flatten back to get all items with their indexes\n        const flattenedItems = flattenHierarchy(hierarchy);\n\n        // Separate folders and documents for batch updates\n        const folderUpdates = flattenedItems.filter(\n          (item) => item.type === \"folder\",\n        );\n        const documentUpdates = flattenedItems.filter(\n          (item) => item.type === \"document\",\n        );\n\n        // Batched updates to reduce open query fan-out\n        const BATCH = 200;\n        for (let i = 0; i < folderUpdates.length; i += BATCH) {\n          const chunk = folderUpdates.slice(i, i + BATCH);\n          for (const f of chunk) {\n            await tx.dataroomFolder.update({\n              where: { id: f.id },\n              data: { hierarchicalIndex: f.hierarchicalIndex },\n            });\n          }\n        }\n        for (let i = 0; i < documentUpdates.length; i += BATCH) {\n          const chunk = documentUpdates.slice(i, i + BATCH);\n          for (const d of chunk) {\n            await tx.dataroomDocument.update({\n              where: { id: d.id },\n              data: { hierarchicalIndex: d.hierarchicalIndex },\n            });\n          }\n        }\n\n        return {\n          foldersUpdated: folderUpdates.length,\n          documentsUpdated: documentUpdates.length,\n        };\n      },\n      { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },\n    );\n  } catch (error) {\n    console.error(\n      \"Error calculating hierarchical indexes for\",\n      dataroomId,\n      error,\n    );\n    throw new Error(\"Failed to calculate and update hierarchical indexes\");\n  }\n}\n"
  },
  {
    "path": "lib/utils/create-adaptive-surface-palette.ts",
    "content": "type Rgb = { r: number; g: number; b: number };\n\nexport type AdaptiveSurfacePalette = {\n  backgroundColor?: string;\n  rgb: Rgb;\n  usesLightText: boolean;\n  isDefaultLightSurface: boolean;\n  textColor: string;\n  mutedTextColor: string;\n  subtleTextColor: string;\n  inverseTextColor: string;\n  panelBgColor: string;\n  panelHoverBgColor: string;\n  panelActiveBgColor: string;\n  panelBorderColor: string;\n  panelBorderHoverColor: string;\n  controlBgColor: string;\n  controlBorderColor: string;\n  controlBorderStrongColor: string;\n  controlIconColor: string;\n  controlPlaceholderColor: string;\n  ctaBgColor: string;\n  ctaTextColor: string;\n};\n\nconst LIGHT_TEXT: Rgb = { r: 248, g: 250, b: 252 };\nconst DARK_TEXT: Rgb = { r: 15, g: 23, b: 42 };\nconst FALLBACK_BG: Rgb = { r: 3, g: 7, b: 18 };\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value));\n}\n\nfunction hexToRgb(hex: string): Rgb | null {\n  if (!hex.startsWith(\"#\")) return null;\n  const compact = hex.trim();\n\n  if (compact.length === 4) {\n    const r = parseInt(compact[1] + compact[1], 16);\n    const g = parseInt(compact[2] + compact[2], 16);\n    const b = parseInt(compact[3] + compact[3], 16);\n    return { r, g, b };\n  }\n\n  if (compact.length === 7) {\n    const r = parseInt(compact.slice(1, 3), 16);\n    const g = parseInt(compact.slice(3, 5), 16);\n    const b = parseInt(compact.slice(5, 7), 16);\n    return { r, g, b };\n  }\n\n  return null;\n}\n\nfunction rgbStringToRgb(input: string): Rgb | null {\n  const match = input\n    .trim()\n    .match(/^rgba?\\(\\s*([0-9.]+)[,\\s]+([0-9.]+)[,\\s]+([0-9.]+)/i);\n  if (!match) return null;\n\n  return {\n    r: clamp(Math.round(Number(match[1])), 0, 255),\n    g: clamp(Math.round(Number(match[2])), 0, 255),\n    b: clamp(Math.round(Number(match[3])), 0, 255),\n  };\n}\n\nfunction parseToRgb(color: string | null | undefined): Rgb {\n  if (!color) return FALLBACK_BG;\n  return hexToRgb(color) || rgbStringToRgb(color) || FALLBACK_BG;\n}\n\nfunction toCssRgb(rgb: Rgb) {\n  return `rgb(${rgb.r} ${rgb.g} ${rgb.b})`;\n}\n\nfunction mixRgb(background: Rgb, foreground: Rgb, amount: number): Rgb {\n  const weight = clamp(amount, 0, 1);\n  return {\n    r: Math.round(background.r + (foreground.r - background.r) * weight),\n    g: Math.round(background.g + (foreground.g - background.g) * weight),\n    b: Math.round(background.b + (foreground.b - background.b) * weight),\n  };\n}\n\nfunction toLinearChannel(channel: number) {\n  const value = channel / 255;\n  return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;\n}\n\nfunction luminance(rgb: Rgb) {\n  return (\n    0.2126 * toLinearChannel(rgb.r) +\n    0.7152 * toLinearChannel(rgb.g) +\n    0.0722 * toLinearChannel(rgb.b)\n  );\n}\n\nfunction contrastRatio(a: Rgb, b: Rgb) {\n  const l1 = luminance(a);\n  const l2 = luminance(b);\n  const lighter = Math.max(l1, l2);\n  const darker = Math.min(l1, l2);\n  return (lighter + 0.05) / (darker + 0.05);\n}\n\nfunction saturation(rgb: Rgb) {\n  const r = rgb.r / 255;\n  const g = rgb.g / 255;\n  const b = rgb.b / 255;\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  if (max === min) return 0;\n  const lightness = (max + min) / 2;\n  const delta = max - min;\n  return delta / (1 - Math.abs(2 * lightness - 1));\n}\n\nexport function createAdaptiveSurfacePalette(\n  backgroundColor: string | null | undefined,\n): AdaptiveSurfacePalette {\n  const rgb = parseToRgb(backgroundColor);\n  const bgLuminance = luminance(rgb);\n  const lightContrast = contrastRatio(rgb, LIGHT_TEXT);\n  const darkContrast = contrastRatio(rgb, DARK_TEXT);\n  const usesLightText = lightContrast >= darkContrast;\n  const isDefaultLightSurface =\n    bgLuminance >= 0.97 &&\n    saturation(rgb) <= 0.08 &&\n    (!backgroundColor || backgroundColor.toLowerCase() !== \"black\");\n\n  const textRgb = usesLightText ? LIGHT_TEXT : DARK_TEXT;\n  const inverseTextRgb = usesLightText ? DARK_TEXT : LIGHT_TEXT;\n  const sat = saturation(rgb);\n  const saturationNormalizer = clamp(1 - sat * 0.35, 0.65, 1);\n\n  const mix = (value: number) => clamp(value * saturationNormalizer, 0, 0.95);\n  const panelBase = usesLightText ? 0.13 : 0.08;\n\n  return {\n    backgroundColor: backgroundColor || undefined,\n    rgb,\n    usesLightText,\n    isDefaultLightSurface,\n    textColor: toCssRgb(textRgb),\n    mutedTextColor: toCssRgb(mixRgb(rgb, textRgb, usesLightText ? 0.7 : 0.62)),\n    subtleTextColor: toCssRgb(mixRgb(rgb, textRgb, usesLightText ? 0.58 : 0.5)),\n    inverseTextColor: toCssRgb(inverseTextRgb),\n    panelBgColor: isDefaultLightSurface\n      ? \"transparent\"\n      : toCssRgb(mixRgb(rgb, textRgb, mix(panelBase))),\n    panelHoverBgColor: isDefaultLightSurface\n      ? \"rgb(248 250 252)\"\n      : toCssRgb(\n          mixRgb(rgb, textRgb, mix(panelBase + (usesLightText ? 0.06 : 0.04))),\n        ),\n    panelActiveBgColor: isDefaultLightSurface\n      ? \"rgb(241 245 249)\"\n      : toCssRgb(\n          mixRgb(rgb, textRgb, mix(panelBase + (usesLightText ? 0.1 : 0.07))),\n        ),\n    panelBorderColor: isDefaultLightSurface\n      ? \"rgb(226 232 240)\"\n      : toCssRgb(mixRgb(rgb, textRgb, mix(usesLightText ? 0.25 : 0.17))),\n    panelBorderHoverColor: isDefaultLightSurface\n      ? \"rgb(203 213 225)\"\n      : toCssRgb(mixRgb(rgb, textRgb, mix(usesLightText ? 0.34 : 0.24))),\n    controlBgColor: toCssRgb(\n      mixRgb(\n        rgb,\n        usesLightText ? textRgb : LIGHT_TEXT,\n        mix(usesLightText ? 0.1 : 0.16),\n      ),\n    ),\n    controlBorderColor: toCssRgb(\n      mixRgb(rgb, textRgb, mix(usesLightText ? 0.24 : 0.15)),\n    ),\n    controlBorderStrongColor: toCssRgb(\n      mixRgb(rgb, textRgb, mix(usesLightText ? 0.34 : 0.23)),\n    ),\n    controlIconColor: toCssRgb(\n      mixRgb(rgb, textRgb, usesLightText ? 0.64 : 0.58),\n    ),\n    controlPlaceholderColor: toCssRgb(\n      mixRgb(rgb, textRgb, usesLightText ? 0.56 : 0.68),\n    ),\n    ctaBgColor: toCssRgb(textRgb),\n    ctaTextColor: toCssRgb(rgb),\n  };\n}\n"
  },
  {
    "path": "lib/utils/csv.ts",
    "content": "import { toast } from \"sonner\";\n\nexport function downloadCSV(data: any[], filename: string) {\n  try {\n    if (data.length === 0) {\n      toast.error(\"No data to export\");\n      return;\n    }\n\n    // Get headers from the first object\n    const headers = Object.keys(data[0]);\n\n    // Convert data to CSV format\n    const csvContent = [\n      // Headers row\n      headers.join(\",\"),\n      // Data rows\n      ...data.map((row) =>\n        headers\n          .map((header) => {\n            const value = row[header];\n            // Handle special cases\n            if (value instanceof Date) {\n              return value.toISOString();\n            }\n            if (typeof value === \"string\" && value.includes(\",\")) {\n              return `\"${value}\"`;\n            }\n            return value;\n          })\n          .join(\",\"),\n      ),\n    ].join(\"\\n\");\n\n    // Create blob and download\n    const blob = new Blob([csvContent], { type: \"text/csv;charset=utf-8;\" });\n    const url = URL.createObjectURL(blob);\n    const link = document.createElement(\"a\");\n    link.href = url;\n    const formattedTime = new Date().toISOString().replace(/[-:Z]/g, \"\");\n    link.setAttribute(\"download\", `${filename}_${formattedTime}.csv`);\n    document.body.appendChild(link);\n    link.click();\n    document.body.removeChild(link);\n    URL.revokeObjectURL(url);\n\n    toast.success(\"CSV file downloaded successfully\");\n  } catch (error) {\n    console.error(\"Error:\", error);\n    toast.error(\n      \"An error occurred while downloading the CSV. Please try again.\",\n    );\n  }\n}\n"
  },
  {
    "path": "lib/utils/decode-base64url.ts",
    "content": "export function decodeBase64Url(base64url: string) {\n  base64url = base64url.replace(/-/g, \"+\").replace(/_/g, \"/\");\n  while (base64url.length % 4) {\n    base64url += \"=\";\n  }\n  return decodeURIComponent(\n    Array.prototype.map\n      .call(atob(base64url), function (c) {\n        return \"%\" + (\"00\" + c.charCodeAt(0).toString(16)).slice(-2);\n      })\n      .join(\"\"),\n  );\n}\n"
  },
  {
    "path": "lib/utils/determine-text-color.ts",
    "content": "function hexToRgb(hex: string) {\n  let r = 0,\n    g = 0,\n    b = 0;\n  // 3 digits\n  if (hex.length === 4) {\n    r = parseInt(hex[1] + hex[1], 16);\n    g = parseInt(hex[2] + hex[2], 16);\n    b = parseInt(hex[3] + hex[3], 16);\n  }\n  // 6 digits\n  else if (hex.length === 7) {\n    r = parseInt(hex[1] + hex[2], 16);\n    g = parseInt(hex[3] + hex[4], 16);\n    b = parseInt(hex[5] + hex[6], 16);\n  }\n  return [r, g, b];\n}\n\nfunction luminance(r: number, g: number, b: number) {\n  return (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n}\n\nexport function determineTextColor(hexColor: string | null | undefined) {\n  if (!hexColor) return \"white\";\n  const [r, g, b] = hexToRgb(hexColor);\n  return luminance(r, g, b) > 0.5 ? \"black\" : \"white\";\n}\n"
  },
  {
    "path": "lib/utils/email-domain.ts",
    "content": "export const GENERIC_EMAIL_DOMAINS = [\n  \"gmail.com\",\n  \"googlemail.com\",\n  \"yahoo.com\",\n  \"yahoo.co.uk\",\n  \"ymail.com\",\n  \"hotmail.com\",\n  \"outlook.com\",\n  \"live.com\",\n  \"msn.com\",\n  \"aol.com\",\n  \"icloud.com\",\n  \"me.com\",\n  \"mac.com\",\n  \"comcast.net\",\n  \"verizon.net\",\n  \"att.net\",\n  \"protonmail.com\",\n  \"proton.me\",\n  \"zoho.com\",\n  \"mail.com\",\n  \"gmx.com\",\n  \"gmx.net\",\n  \"yandex.com\",\n  \"tutanota.com\",\n  \"tuta.com\",\n  \"fastmail.com\",\n  \"hey.com\",\n];\n\n/**\n * Returns true if the email belongs to a well-known free / consumer email\n * provider (e.g. gmail.com, outlook.com).  Useful for distinguishing\n * organisation-owned domains from personal addresses.\n */\nexport const isGenericEmail = (email: string): boolean => {\n  const domain = email.trim().toLowerCase().split(\"@\").pop();\n  return !!domain && GENERIC_EMAIL_DOMAINS.includes(domain);\n};\n\n/**\n * Returns true if the bare domain (no \"@\" prefix) is a well-known free /\n * consumer email provider.\n */\nexport const isGenericDomain = (domain: string): boolean => {\n  return GENERIC_EMAIL_DOMAINS.includes(domain.trim().toLowerCase());\n};\n\nexport function extractEmailDomain(email: string): string | null {\n  if (!email || typeof email !== \"string\") {\n    return null;\n  }\n  const normalizedEmail = email.trim().toLowerCase();\n\n  const atSymbolCount = (normalizedEmail.match(/@/g) || []).length;\n  if (atSymbolCount !== 1) {\n    return null;\n  }\n\n  const atIndex = normalizedEmail.lastIndexOf(\"@\");\n  if (atIndex === -1 || atIndex === normalizedEmail.length - 1) {\n    return null;\n  }\n\n  const domain = normalizedEmail.substring(atIndex);\n\n  if (domain.length <= 1) {\n    return null;\n  }\n\n  return domain;\n}\n\nexport function normalizeListEntry(entry: string): string {\n  if (!entry || typeof entry !== \"string\") {\n    return \"\";\n  }\n  return entry.trim().toLowerCase();\n}\n\nexport function isEmailMatched(email: string, entry: string): boolean {\n  if (!email || !entry) {\n    return false;\n  }\n\n  const normalizedEmail = email.trim().toLowerCase();\n  const normalizedEntry = normalizeListEntry(entry);\n\n  // Direct email match\n  if (normalizedEmail === normalizedEntry) {\n    return true;\n  }\n\n  // Domain match (entry starts with @)\n  if (normalizedEntry.startsWith(\"@\")) {\n    const emailDomain = extractEmailDomain(normalizedEmail);\n    return emailDomain === normalizedEntry;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "lib/utils/generate-checksum.ts",
    "content": "import crypto from \"crypto\";\n\nexport function generateChecksum(url: string): string {\n  // Use a secure secret key stored in environment variables\n  const secret = process.env.NEXT_PRIVATE_VERIFICATION_SECRET!;\n\n  // Create HMAC using SHA-256\n  const hmac = crypto.createHmac(\"sha256\", secret);\n  hmac.update(url);\n\n  // Return hex digest\n  return hmac.digest(\"hex\");\n}\n"
  },
  {
    "path": "lib/utils/generate-jwt.ts",
    "content": "import jwt from \"jsonwebtoken\";\n\nconst JWT_SECRET = process.env.NEXT_PRIVATE_UNSUBSCRIBE_JWT_SECRET as string;\n\ntype JWTPayload = {\n  [key: string]: any;\n  exp?: number; // Expiration timestamp\n};\n\n/**\n * Generates a JWT token with the provided payload\n * @param payload The data to encode in the JWT\n * @param expiresInSeconds Optional expiration time in seconds (default: 24 hours)\n * @returns The signed JWT token\n */\nexport function generateJWT(\n  payload: JWTPayload,\n  expiresInSeconds: number = 60 * 60 * 24, // 24 hours\n): string {\n  const tokenPayload = {\n    ...payload,\n    exp: payload.exp || Math.floor(Date.now() / 1000) + expiresInSeconds,\n  };\n\n  return jwt.sign(tokenPayload, JWT_SECRET);\n}\n\n/**\n * Verifies a JWT token and returns the decoded payload\n * @param token The JWT token to verify\n * @returns The decoded payload or null if invalid\n */\nexport function verifyJWT<T = JWTPayload>(token: string): T | null {\n  try {\n    return jwt.verify(token, JWT_SECRET) as T;\n  } catch (error) {\n    return null;\n  }\n}\n"
  },
  {
    "path": "lib/utils/generate-otp.ts",
    "content": "export function generateOTP(): string {\n  // Generate a random number between 0 and 999999\n  const randomNumber = Math.floor(Math.random() * 1000000);\n\n  // Pad the number with leading zeros if necessary to ensure it is always 6 digits\n  const otp = randomNumber.toString().padStart(6, \"0\");\n\n  return otp;\n}\n"
  },
  {
    "path": "lib/utils/generate-trigger-auth-token.ts",
    "content": "import { auth } from \"@trigger.dev/sdk/v3\";\n\nexport async function generateTriggerPublicAccessToken(tag: string) {\n  return auth.createPublicToken({\n    scopes: {\n      read: {\n        tags: [tag],\n      },\n    },\n    expirationTime: \"15m\",\n  });\n}\n"
  },
  {
    "path": "lib/utils/generate-trigger-status.ts",
    "content": "import { metadata } from \"@trigger.dev/sdk/v3\";\nimport { z } from \"zod\";\n\nconst ZDocumentProgressStatus = z.object({\n  progress: z.number(),\n  text: z.string(),\n});\n\ntype TDocumentProgressStatus = z.infer<typeof ZDocumentProgressStatus>;\n\nconst ZDocumentProgressMetadata = z.object({\n  status: ZDocumentProgressStatus,\n});\n\ntype TDocumentProgressMetadata = z.infer<typeof ZDocumentProgressMetadata>;\n\n/**\n * Update the status of the convert document task. Wraps the `metadata.set` method.\n */\nexport function updateStatus(status: TDocumentProgressStatus) {\n  // `metadata.set` can be used to update the status of the task\n  // as long as `updateStatus` is called within the task's `run` function.\n  metadata.set(\"status\", status);\n}\n\n/**\n * Parse the status from the metadata.\n */\nexport function parseStatus(data: unknown): TDocumentProgressStatus {\n  return ZDocumentProgressMetadata.parse(data).status;\n}\n"
  },
  {
    "path": "lib/utils/geo.ts",
    "content": "import { Geo } from \"../types\";\n\nexport function getGeoData(headers: {\n  [key: string]: string | string[] | undefined;\n}): Geo {\n  return {\n    city: Array.isArray(headers[\"x-vercel-ip-city\"])\n      ? headers[\"x-vercel-ip-city\"][0]\n      : headers[\"x-vercel-ip-city\"],\n    region: Array.isArray(headers[\"x-vercel-ip-region\"])\n      ? headers[\"x-vercel-ip-region\"][0]\n      : headers[\"x-vercel-ip-region\"],\n    country: Array.isArray(headers[\"x-vercel-ip-country\"])\n      ? headers[\"x-vercel-ip-country\"][0]\n      : headers[\"x-vercel-ip-country\"],\n    latitude: Array.isArray(headers[\"x-vercel-ip-latitude\"])\n      ? headers[\"x-vercel-ip-latitude\"][0]\n      : headers[\"x-vercel-ip-latitude\"],\n    longitude: Array.isArray(headers[\"x-vercel-ip-longitude\"])\n      ? headers[\"x-vercel-ip-longitude\"][0]\n      : headers[\"x-vercel-ip-longitude\"],\n  };\n}\n\nexport const LOCALHOST_GEO_DATA = {\n  continent: \"Europe\",\n  city: \"Munich\",\n  region: \"BY\",\n  country: \"DE\",\n  latitude: \"48.1371\",\n  longitude: \"11.5761\",\n};\n\nexport const LOCALHOST_IP = \"127.0.0.1\";\n"
  },
  {
    "path": "lib/utils/get-content-type.ts",
    "content": "export function getSupportedContentType(contentType: string): string | null {\n  switch (contentType) {\n    case \"application/pdf\":\n      return \"pdf\";\n    case \"application/vnd.ms-excel\":\n    case \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\":\n    case \"application/vnd.ms-excel.sheet.macroEnabled.12\":\n    case \"text/csv\":\n    case \"text/tab-separated-values\":\n    case \"application/vnd.oasis.opendocument.spreadsheet\":\n      return \"sheet\";\n    case \"application/msword\":\n    case \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\":\n    case \"application/vnd.oasis.opendocument.text\":\n    case \"application/rtf\":\n    case \"text/rtf\":\n    case \"text/plain\":\n      return \"docs\";\n    case \"application/vnd.ms-powerpoint\":\n    case \"application/vnd.openxmlformats-officedocument.presentationml.presentation\":\n    case \"application/vnd.oasis.opendocument.presentation\":\n    case \"application/vnd.apple.keynote\":\n    case \"application/x-iwork-keynote-sffkey\":\n      return \"slides\";\n    case \"image/vnd.dwg\":\n    case \"image/vnd.dxf\":\n      return \"cad\";\n    case \"image/png\":\n    case \"image/jpeg\":\n    case \"image/jpg\":\n      return \"image\";\n    case \"application/zip\":\n    case \"application/x-zip-compressed\":\n      return \"zip\";\n    case \"video/mp4\":\n    case \"video/quicktime\":\n    case \"video/x-msvideo\":\n    case \"video/webm\":\n    case \"video/ogg\":\n    case \"audio/mp4\":\n    case \"audio/x-m4a\":\n    case \"audio/m4a\":\n    case \"audio/mpeg\":\n      return \"video\";\n    case \"application/vnd.google-earth.kml+xml\":\n    case \"application/vnd.google-earth.kmz\":\n      return \"map\";\n    case \"application/vnd.ms-outlook\":\n      return \"email\";\n    default:\n      return null;\n  }\n}\n\nexport function getExtensionFromContentType(\n  contentType: string,\n): string | null {\n  switch (contentType) {\n    case \"application/pdf\":\n      return \"pdf\";\n    case \"application/vnd.ms-excel\":\n      return \"xls\";\n    case \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\":\n      return \"xlsx\";\n    case \"application/vnd.ms-excel.sheet.macroEnabled.12\":\n      return \"xlsm\";\n    case \"text/csv\":\n      return \"csv\";\n    case \"text/tab-separated-values\":\n      return \"tsv\";\n    case \"application/vnd.oasis.opendocument.spreadsheet\":\n      return \"ods\";\n    case \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\":\n      return \"docx\";\n    case \"application/vnd.oasis.opendocument.text\":\n      return \"odt\";\n    case \"application/vnd.openxmlformats-officedocument.presentationml.presentation\":\n      return \"pptx\";\n    case \"application/vnd.oasis.opendocument.presentation\":\n      return \"odp\";\n    case \"application/vnd.ms-powerpoint\":\n      return \"ppt\";\n    case \"application/vnd.apple.keynote\":\n    case \"application/x-iwork-keynote-sffkey\":\n      return \"key\";\n    case \"application/msword\":\n      return \"doc\";\n    case \"application/rtf\":\n    case \"text/rtf\":\n      return \"rtf\";\n    case \"text/plain\":\n      return \"txt\";\n    case \"image/vnd.dwg\":\n      return \"dwg\";\n    case \"image/vnd.dxf\":\n      return \"dxf\";\n    case \"image/png\":\n      return \"png\";\n    case \"image/jpeg\":\n      return \"jpeg\";\n    case \"image/jpg\":\n      return \"jpg\";\n    case \"video/mp4\":\n      return \"mp4\";\n    case \"video/quicktime\":\n      return \"mov\";\n    case \"video/x-msvideo\":\n      return \"avi\";\n    case \"video/webm\":\n      return \"webm\";\n    case \"video/ogg\":\n      return \"ogg\";\n    case \"audio/mp4\":\n    case \"audio/x-m4a\":\n    case \"audio/m4a\":\n      return \"m4a\";\n    case \"audio/mpeg\":\n      return \"mp3\";\n    case \"application/vnd.google-earth.kml+xml\":\n      return \"kml\";\n    case \"application/vnd.google-earth.kmz\":\n      return \"kmz\";\n    case \"application/vnd.ms-outlook\":\n      return \"msg\";\n    default:\n      return null;\n  }\n}\n\nexport function supportsAdvancedExcelMode(\n  contentType: string | null | undefined,\n): boolean {\n  if (!contentType) return false;\n\n  return (\n    contentType === \"application/vnd.ms-excel\" || // .xls\n    contentType ===\n      \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\" || // .xlsx\n    contentType === \"application/vnd.ms-excel.sheet.macroEnabled.12\" // .xlsm\n  );\n}\n"
  },
  {
    "path": "lib/utils/get-file-icon.tsx",
    "content": "import { FileIcon, Link as LinkIcon, MailIcon } from \"lucide-react\";\n\nimport CadIcon from \"@/components/shared/icons/files/cad\";\nimport DocsIcon from \"@/components/shared/icons/files/docs\";\nimport ImageFileIcon from \"@/components/shared/icons/files/image\";\nimport MapIcon from \"@/components/shared/icons/files/map\";\nimport NotionIcon from \"@/components/shared/icons/files/notion\";\nimport PdfIcon from \"@/components/shared/icons/files/pdf\";\nimport SheetIcon from \"@/components/shared/icons/files/sheet\";\nimport SlidesIcon from \"@/components/shared/icons/files/slides\";\nimport VideoIcon from \"@/components/shared/icons/files/video\";\n\nexport function fileIcon({\n  fileType,\n  className = \"mx-auto h-6 w-6\",\n  isLight = true,\n}: {\n  fileType: string;\n  className?: string;\n  isLight?: boolean;\n}) {\n  switch (fileType) {\n    case \"pdf\":\n    case \"application/pdf\":\n      return <PdfIcon className={className} isLight={isLight} />;\n    case \"image/png\":\n    case \"image/jpeg\":\n    case \"image/jpg\":\n    case \"image\":\n      return <ImageFileIcon className={className} isLight={isLight} />;\n    case \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\":\n    case \"application/msword\":\n    case \"application/vnd.oasis.opendocument.text\":\n    case \"docs\":\n      return <DocsIcon className={className} isLight={isLight} />;\n    case \"application/vnd.openxmlformats-officedocument.presentationml.presentation\":\n    case \"application/vnd.ms-powerpoint\":\n    case \"application/vnd.oasis.opendocument.presentation\":\n    case \"application/vnd.apple.keynote\":\n    case \"application/x-iwork-keynote-sffkey\":\n    case \"slides\":\n      return <SlidesIcon className={className} isLight={isLight} />;\n    case \"application/vnd.ms-excel\":\n    case \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\":\n    case \"text/csv\":\n    case \"text/tab-separated-values\":\n    case \"application/vnd.oasis.opendocument.spreadsheet\":\n    case \"sheet\":\n      return <SheetIcon className={className} isLight={isLight} />;\n    case \"notion\":\n      return <NotionIcon className={className} />;\n    case \"link\":\n      return <LinkIcon className={className} />;\n    case \"image/vnd.dwg\":\n    case \"image/vnd.dxf\":\n    case \"cad\":\n      return <CadIcon className={className} isLight={isLight} />;\n    case \"video/mp4\":\n    case \"video/quicktime\":\n    case \"video/webm\":\n    case \"video/ogg\":\n    case \"video/x-msvideo\":\n    case \"video\":\n    case \"audio/mp4\":\n    case \"audio/mpeg\":\n      return <VideoIcon className={className} isLight={isLight} />;\n    case \"application/vnd.google-earth.kml+xml\":\n    case \"application/vnd.google-earth.kmz\":\n    case \"map\":\n      return <MapIcon className={className} isLight={isLight} />;\n    case \"application/vnd.ms-outlook\":\n    case \"email\":\n      return <MailIcon className={className} />;\n    default:\n      return <FileIcon className={className} />;\n  }\n}\n"
  },
  {
    "path": "lib/utils/get-file-size-limits.ts",
    "content": "type FileSizeLimits = {\n  video: number;\n  document: number;\n  image: number;\n  excel: number;\n  maxFiles: number;\n  maxPages: number;\n};\n\nexport function getFileSizeLimits({\n  limits,\n  isFree,\n  isTrial,\n}: {\n  limits?: { fileSizeLimits?: Partial<FileSizeLimits> } | null;\n  isFree: boolean;\n  isTrial: boolean;\n}): FileSizeLimits {\n  // Default limits based on plan type\n  const defaultLimits: FileSizeLimits = {\n    video: 500, // 500MB\n    document: isFree && !isTrial ? 100 : 350, // 100MB free, 350MB paid\n    image: isFree && !isTrial ? 30 : 100, // 30MB free, 100MB paid\n    excel: 40, // 40MB\n    maxFiles: 150,\n    maxPages: isFree && !isTrial ? 100 : 500,\n  };\n\n  // If no custom limits are set, return default limits\n  if (!limits?.fileSizeLimits) {\n    return defaultLimits;\n  }\n\n  // Merge custom limits with defaults\n  return {\n    video: limits.fileSizeLimits.video ?? defaultLimits.video,\n    document: limits.fileSizeLimits.document ?? defaultLimits.document,\n    image: limits.fileSizeLimits.image ?? defaultLimits.image,\n    excel: limits.fileSizeLimits.excel ?? defaultLimits.excel,\n    maxFiles: limits.fileSizeLimits.maxFiles ?? defaultLimits.maxFiles,\n    maxPages: limits.fileSizeLimits.maxPages ?? defaultLimits.maxPages,\n  };\n}\n\n// Helper function to get size limit for a specific file type\nexport function getFileSizeLimit(\n  fileType: string,\n  limits: FileSizeLimits,\n): number {\n  if (fileType.startsWith(\"video/\")) {\n    return limits.video;\n  }\n  if (fileType.startsWith(\"image/\")) {\n    return limits.image;\n  }\n  if (\n    fileType.startsWith(\n      \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n    ) ||\n    fileType.startsWith(\"application/vnd.ms-excel\") ||\n    fileType.startsWith(\"application/vnd.oasis.opendocument.spreadsheet\")\n  ) {\n    return limits.excel;\n  }\n  return limits.document;\n}\n"
  },
  {
    "path": "lib/utils/get-page-number-count.ts",
    "content": "import { PDF, SecurityError } from \"@libpdf/core\";\nimport * as XLSX from \"xlsx\";\n\nexport const getPagesCount = async (arrayBuffer: ArrayBuffer) => {\n  try {\n    const bytes = new Uint8Array(arrayBuffer);\n    const pdf = await PDF.load(bytes);\n    return pdf.getPageCount();\n  } catch (error) {\n    if (error instanceof SecurityError) {\n      console.warn(\"PDF is password-protected, cannot determine page count\");\n    } else {\n      console.error(\"Error getting PDF page count:\", error);\n    }\n    return 1; // Assuming at least one page if we can't determine\n  }\n};\n\nexport const getSheetsCount = (arrayBuffer: ArrayBuffer) => {\n  const data = new Uint8Array(arrayBuffer);\n  const workbook = XLSX.read(data, { type: \"array\" });\n  return workbook.SheetNames.length ?? 1;\n};\n"
  },
  {
    "path": "lib/utils/get-search-params.ts",
    "content": "export const getSearchParams = (url: string) => {\n  // Create a params object\n  let params = {} as Record<string, string>;\n\n  new URL(url).searchParams.forEach(function (val, key) {\n    params[key] = val;\n  });\n\n  return params;\n};\n"
  },
  {
    "path": "lib/utils/global-block-list.ts",
    "content": "import { isEmailMatched } from \"@/lib/utils/email-domain\";\nimport { validateEmail } from \"@/lib/utils/validate-email\";\n\nexport function checkGlobalBlockList(\n  email: string | undefined,\n  globalBlockList: string[] | undefined,\n): { isBlocked: boolean; error?: string } {\n  if (!email || !globalBlockList || globalBlockList.length === 0) {\n    return { isBlocked: false };\n  }\n\n  if (!validateEmail(email)) {\n    return {\n      isBlocked: false,\n      error: \"Invalid email address\",\n    };\n  }\n\n  const isBlocked = globalBlockList.some((blockedEntry) =>\n    isEmailMatched(email, blockedEntry),\n  );\n\n  return { isBlocked };\n}\n"
  },
  {
    "path": "lib/utils/hierarchical-display.ts",
    "content": "import { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\n\n/**\n * Returns the display name with hierarchical index if enabled\n */\nexport function useHierarchicalDisplayName(\n  name: string,\n  hierarchicalIndex?: string | null,\n): string {\n  const { isFeatureEnabled } = useFeatureFlags();\n  const isDataroomIndexEnabled = isFeatureEnabled(\"dataroomIndex\");\n\n  if (isDataroomIndexEnabled && hierarchicalIndex) {\n    return `${hierarchicalIndex} ${name}`;\n  }\n\n  return name;\n}\n\n/**\n * Non-hook version for use in non-React contexts\n */\nexport function getHierarchicalDisplayName(\n  name: string,\n  hierarchicalIndex?: string | null,\n  isFeatureEnabled: boolean = false,\n): string {\n  if (isFeatureEnabled && hierarchicalIndex) {\n    return `${hierarchicalIndex} ${name}`;\n  }\n\n  return name;\n}\n\n/**\n * CSS class for tabular numbers styling\n */\nexport const HIERARCHICAL_DISPLAY_STYLE = {\n  fontVariantNumeric: \"tabular-nums\" as const,\n};\n"
  },
  {
    "path": "lib/utils/ip.ts",
    "content": "export function getIpAddress(headers: {\n  [key: string]: string | string[] | undefined;\n}): string {\n  // Check x-forwarded-for header (most common for proxied requests)\n  const forwardedFor = headers[\"x-forwarded-for\"];\n  if (typeof forwardedFor === \"string\") {\n    const ip = forwardedFor.split(\",\")[0]?.trim();\n    if (ip) return ip;\n  }\n  if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {\n    const ip = forwardedFor[0].split(\",\")[0]?.trim();\n    if (ip) return ip;\n  }\n\n  // Check x-real-ip header (nginx proxy)\n  const realIp = headers[\"x-real-ip\"];\n  if (typeof realIp === \"string\") {\n    const ip = realIp.trim();\n    if (ip) return ip;\n  }\n  if (Array.isArray(realIp) && realIp.length > 0) {\n    const ip = realIp[0].trim();\n    if (ip) return ip;\n  }\n\n  // Fallback to localhost\n  return \"127.0.0.1\";\n}\n"
  },
  {
    "path": "lib/utils/link-url.ts",
    "content": "export function constructLinkUrl(link: {\n  id: string;\n  domainId?: string | null;\n  domainSlug?: string | null;\n  slug?: string | null;\n}) {\n  if (link.domainId && link.domainSlug && link.slug) {\n    return `https://${link.domainSlug}/${link.slug}`;\n  }\n\n  return `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`;\n}\n\n\n"
  },
  {
    "path": "lib/utils/reliable-tracking.ts",
    "content": "interface TrackingData {\n    linkId: string;\n    documentId: string;\n    viewId?: string;\n    duration: number;\n    pageNumber: number;\n    versionNumber: number;\n    dataroomId?: string;\n    isPreview?: boolean;\n}\n\ninterface TrackingOptions {\n    retryAttempts?: number;\n    retryDelay?: number;\n}\n\nexport async function trackPageViewReliably(\n    data: TrackingData,\n    useBeacon: boolean = false,\n    options: TrackingOptions = {}\n): Promise<void> {\n    // If the view is a preview, do not track the view\n    if (data.isPreview) return;\n\n    const {\n        retryAttempts = 3,\n        retryDelay = 1000\n    } = options;\n    const payload = JSON.stringify(data);\n    const url = \"/api/record_view\";\n\n    // 1: Use sendBeacon for maximum reliability during page unload\n    if (useBeacon && navigator.sendBeacon) {\n        try {\n            const blob = new Blob([payload], { type: \"application/json\" });\n            const success = navigator.sendBeacon(url, blob);\n\n            if (success) {\n                return;\n            }\n        } catch (error) {\n            console.warn(\"sendBeacon failed:\", error);\n        }\n    }\n\n    // 2: Use fetch with keepalive for better reliability\n    try {\n        const response = await fetch(url, {\n            method: \"POST\",\n            body: payload,\n            headers: {\n                \"Content-Type\": \"application/json\",\n            },\n            keepalive: true, // Critical for page unload scenarios\n        });\n\n        if (response.ok) {\n            return;\n        }\n    } catch (error) {\n        console.warn(\"Fetch with keepalive failed:\", error);\n    }\n\n    // 3: Fallback to sendBeacon if fetch failed\n    if (!useBeacon && navigator.sendBeacon) {\n        try {\n            const blob = new Blob([payload], { type: \"application/json\" });\n            const success = navigator.sendBeacon(url, blob);\n\n            if (success) {\n                return;\n            }\n        } catch (error) {\n            console.warn(\"Fallback sendBeacon failed:\", error);\n        }\n    }\n\n    // 4: Retry with exponential backoff (only if not during page unload)\n    if (!useBeacon && retryAttempts > 0) {\n        for (let attempt = 1; attempt <= retryAttempts; attempt++) {\n            try {\n                await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));\n\n                const response = await fetch(url, {\n                    method: \"POST\",\n                    body: payload,\n                    headers: {\n                        \"Content-Type\": \"application/json\",\n                    },\n                    keepalive: true,\n                });\n\n                if (response.ok) {\n                    return;\n                }\n            } catch (error) {\n                console.warn(`Retry attempt ${attempt} failed:`, error);\n            }\n        }\n    }\n\n    // 5: Last resort - Image beacon (limited payload size)\n    if (payload.length < 2000) { // URL length limit\n        try {\n            const img = new Image();\n            const params = new URLSearchParams({ data: payload });\n            img.src = `${url}?${params.toString()}`;\n\n            // Don't wait for image to load, just fire and forget\n            return;\n        } catch (error) {\n            console.warn(\"Image beacon fallback failed:\", error);\n        }\n    }\n\n    console.error(\"All tracking strategies failed - data may be lost\");\n}\n\nexport async function trackPageView(data: TrackingData): Promise<void> {\n    return trackPageViewReliably(data, false);\n} "
  },
  {
    "path": "lib/utils/resize-image.ts",
    "content": "export const resizeImage = (\n  file: File,\n  opts: {\n    width: number;\n    height: number;\n    quality: number;\n  } = {\n    width: 1200, // Desired output width\n    height: 630, // Desired output height\n    quality: 1.0, // Set quality to maximum\n  },\n): Promise<string> => {\n  return new Promise((resolve, reject) => {\n    const reader = new FileReader();\n    reader.onload = (e: ProgressEvent<FileReader>) => {\n      const img = new Image();\n      img.src = e.target?.result as string;\n      img.onload = () => {\n        const targetWidth = opts.width;\n        const targetHeight = opts.height;\n        const canvas = document.createElement(\"canvas\");\n        canvas.width = targetWidth;\n        canvas.height = targetHeight;\n\n        const ctx = canvas.getContext(\"2d\") as CanvasRenderingContext2D;\n        ctx.imageSmoothingQuality = \"high\"; // Set image smoothing quality to high\n\n        // Calculating the aspect ratio\n        const sourceWidth = img.width;\n        const sourceHeight = img.height;\n        const sourceAspectRatio = sourceWidth / sourceHeight;\n        const targetAspectRatio = targetWidth / targetHeight;\n\n        let drawWidth: number;\n        let drawHeight: number;\n        let offsetX = 0;\n        let offsetY = 0;\n\n        // Adjust drawing sizes based on the aspect ratio\n        if (sourceAspectRatio > targetAspectRatio) {\n          // Source is wider\n          drawHeight = sourceHeight;\n          drawWidth = sourceHeight * targetAspectRatio;\n          offsetX = (sourceWidth - drawWidth) / 2;\n        } else {\n          // Source is taller or has the same aspect ratio\n          drawWidth = sourceWidth;\n          drawHeight = sourceWidth / targetAspectRatio;\n          offsetY = (sourceHeight - drawHeight) / 2;\n        }\n\n        // Draw the image onto the canvas\n        ctx.drawImage(\n          img,\n          offsetX,\n          offsetY,\n          drawWidth,\n          drawHeight,\n          0,\n          0,\n          targetWidth,\n          targetHeight,\n        );\n\n        // Determine the original mime image type\n        const originalType = file.type || \"image/png\";\n\n        // Convert the canvas to a base64 string\n        const base64Image = canvas.toDataURL(originalType, opts.quality);\n        resolve(base64Image);\n      };\n      img.onerror = (error) =>\n        reject(new Error(\"Image loading error: \" + error));\n    };\n    reader.onerror = (error) => reject(new Error(\"FileReader error: \" + error));\n    reader.readAsDataURL(file);\n  });\n};\n"
  },
  {
    "path": "lib/utils/sanitize-html.ts",
    "content": "import sanitizeHtml from \"sanitize-html\";\nimport { decodeHTML } from \"entities\";\n\nconst plainTextSanitizeConfig = {\n  allowedTags: [],\n  allowedAttributes: {},\n};\n\nconst controlCharsRegex = /[\\u0000-\\u001F\\u007F-\\u009F]/g;\nconst invisibleControlRegex = /[\\u200B-\\u200D\\uFEFF\\u202A-\\u202E\\u2066-\\u2069]/g;\n\nexport function sanitizePlainText(content: string) {\n  const sanitized = sanitizeHtml(content, plainTextSanitizeConfig);\n  const decoded = decodeHTML(sanitized).normalize(\"NFC\");\n\n  return decoded\n    .replace(controlCharsRegex, \" \")\n    .replace(invisibleControlRegex, \"\")\n    .trim();\n}\n\nexport const MAX_MESSAGE_LENGTH = 4000;\n\nexport function validateContent(html: string, length: number = MAX_MESSAGE_LENGTH) {\n  if (html.length > length) {\n    throw new Error(`Content cannot be longer than ${length} characters`);\n  }\n  const sanitized = sanitizePlainText(html);\n\n  if (sanitized.length === 0 || sanitized === \"\") {\n    throw new Error(\"Content cannot be empty\");\n  }\n\n  return sanitized;\n}\n"
  },
  {
    "path": "lib/utils/sort-items-by-index-name.ts",
    "content": "export const sortItemsByIndexAndName = <\n  T extends {\n    orderIndex: number | null;\n    document: { name: string };\n  },\n>(\n  items: T[],\n): T[] => {\n  // Sort documents by orderIndex or name considering the numerical part\n  return items.sort((a, b) => {\n    // First, compare by orderIndex if both items have it\n    if (a.orderIndex && b.orderIndex) {\n      if (a.orderIndex !== b.orderIndex) {\n        return a.orderIndex - b.orderIndex;\n      }\n    }\n\n    // If orderIndex is not available or equal, use name-based sorting\n    const numA = getNumber(a.document.name);\n    const numB = getNumber(b.document.name);\n    if (numA !== numB) {\n      return numA - numB;\n    }\n    // If numerical parts are the same, fall back to lexicographical order\n    return a.document.name.localeCompare(b.document.name);\n  });\n};\n\nexport const sortByIndexThenName = <\n  T extends {\n    orderIndex: number | null;\n    name?: string;\n    document?: { name: string };\n  },\n>(\n  items: T[],\n): T[] => {\n  return items.sort((a, b) => {\n    if (a.orderIndex !== null && b.orderIndex !== null) {\n      if (a.orderIndex !== b.orderIndex) {\n        return a.orderIndex - b.orderIndex;\n      }\n    }\n    if (a.orderIndex !== null && b.orderIndex === null) {\n      return -1;\n    }\n    if (a.orderIndex === null && b.orderIndex !== null) {\n      return 1;\n    }\n    const nameA = a.name || a.document?.name || \"\";\n    const nameB = b.name || b.document?.name || \"\";\n    return nameA.localeCompare(nameB);\n  });\n};\n\n// Helper function to extract the numerical part of a string\nconst getNumber = (str: string): number => {\n  const match = str.match(/^\\d+/);\n  return match ? parseInt(match[0], 10) : 0;\n};\n"
  },
  {
    "path": "lib/utils/trigger-utils.ts",
    "content": "import { BasePlan } from \"../swr/use-billing\";\n\ntype TQueueConfig = {\n  name: string;\n  concurrencyLimit: number;\n};\n\nconst concurrencyConfig: Record<string, number> = {\n  free: 1,\n  starter: 1,\n  pro: 2,\n  business: 10,\n  datarooms: 10,\n  \"datarooms-plus\": 10,\n  \"datarooms-premium\": 10,\n};\n\nexport const conversionQueue = (plan: string): TQueueConfig => {\n  const planName = plan.split(\"+\")[0] as BasePlan;\n\n  return {\n    name: `conversion-${planName}`,\n    concurrencyLimit: concurrencyConfig[planName],\n  };\n};\n"
  },
  {
    "path": "lib/utils/unsubscribe.ts",
    "content": "import jwt from \"jsonwebtoken\";\n\nconst JWT_SECRET = process.env.NEXT_PRIVATE_UNSUBSCRIBE_JWT_SECRET as string;\nconst UNSUBSCRIBE_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL as string;\n\ntype UnsubscribePayload = {\n  viewerId: string;\n  teamId: string;\n  dataroomId?: string;\n  exp?: number; // Expiration timestamp\n};\n\nexport function generateUnsubscribeUrl(payload: UnsubscribePayload): string {\n  const tokenPayload = {\n    ...payload,\n    exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 90,\n  };\n\n  const token = jwt.sign(tokenPayload, JWT_SECRET);\n\n  if (payload.dataroomId) {\n    return `${UNSUBSCRIBE_BASE_URL}/api/notification-preferences/dataroom?token=${token}`;\n  }\n\n  return `${UNSUBSCRIBE_BASE_URL}/api/unsubscribe/yir?token=${token}`;\n}\n\nexport function verifyUnsubscribeToken(\n  token: string,\n): UnsubscribePayload | null {\n  try {\n    return jwt.verify(token, JWT_SECRET) as UnsubscribePayload;\n  } catch (error) {\n    return null;\n  }\n}\n"
  },
  {
    "path": "lib/utils/use-at-bottom.ts",
    "content": "import * as React from \"react\";\n\nexport function useAtBottom(offset = 0) {\n  const [isAtBottom, setIsAtBottom] = React.useState(false);\n\n  React.useEffect(() => {\n    const handleScroll = () => {\n      setIsAtBottom(\n        window.innerHeight + window.scrollY >=\n          document.body.offsetHeight - offset,\n      );\n    };\n\n    window.addEventListener(\"scroll\", handleScroll, { passive: true });\n    handleScroll();\n\n    return () => {\n      window.removeEventListener(\"scroll\", handleScroll);\n    };\n  }, [offset]);\n\n  return isAtBottom;\n}\n"
  },
  {
    "path": "lib/utils/use-copy-to-clipboard.ts",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { toast } from \"sonner\";\n\nexport interface useCopyToClipboardProps {\n  timeout?: number;\n}\n\nexport function useCopyToClipboard({\n  timeout = 2000,\n}: useCopyToClipboardProps) {\n  const [isCopied, setIsCopied] = React.useState<Boolean>(false);\n\n  const copyToClipboard = (value: string, message?: string) => {\n    if (typeof window === \"undefined\" || !navigator.clipboard?.writeText) {\n      return;\n    }\n\n    if (!value) {\n      return;\n    }\n\n    navigator.clipboard.writeText(value).then(() => {\n      setIsCopied(true);\n      message && toast.success(message);\n\n      setTimeout(() => {\n        setIsCopied(false);\n      }, timeout);\n    });\n  };\n\n  return { isCopied, copyToClipboard };\n}\n"
  },
  {
    "path": "lib/utils/use-enter-submit.ts",
    "content": "import { type RefObject, useRef } from \"react\";\n\nexport function useEnterSubmit(): {\n  formRef: RefObject<HTMLFormElement>;\n  onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;\n} {\n  const formRef = useRef<HTMLFormElement>(null);\n\n  const handleKeyDown = (\n    event: React.KeyboardEvent<HTMLTextAreaElement>,\n  ): void => {\n    if (\n      event.key === \"Enter\" &&\n      !event.shiftKey &&\n      !event.nativeEvent.isComposing\n    ) {\n      formRef.current?.requestSubmit();\n      event.preventDefault();\n    }\n  };\n\n  return { formRef, onKeyDown: handleKeyDown };\n}\n"
  },
  {
    "path": "lib/utils/use-media-query.ts",
    "content": "import { useEffect, useState } from \"react\";\n\nexport function useMediaQuery() {\n  const [device, setDevice] = useState<\"mobile\" | \"tablet\" | \"desktop\" | null>(\n    null,\n  );\n  const [dimensions, setDimensions] = useState<{\n    width: number;\n    height: number;\n  } | null>(null);\n\n  useEffect(() => {\n    const checkDevice = () => {\n      if (window.matchMedia(\"(max-width: 640px)\").matches) {\n        setDevice(\"mobile\");\n      } else if (\n        window.matchMedia(\"(min-width: 641px) and (max-width: 1024px)\").matches\n      ) {\n        setDevice(\"tablet\");\n      } else {\n        setDevice(\"desktop\");\n      }\n      setDimensions({ width: window.innerWidth, height: window.innerHeight });\n    };\n\n    // Initial detection\n    checkDevice();\n\n    // Listener for windows resize\n    window.addEventListener(\"resize\", checkDevice);\n\n    // Cleanup listener\n    return () => {\n      window.removeEventListener(\"resize\", checkDevice);\n    };\n  }, []);\n\n  return {\n    device,\n    width: dimensions?.width,\n    height: dimensions?.height,\n    isMobile: device === \"mobile\",\n    isTablet: device === \"tablet\",\n    isDesktop: device === \"desktop\",\n  };\n}\n"
  },
  {
    "path": "lib/utils/use-progress-status.ts",
    "content": "\"use client\";\n\nimport { type RunStatus } from \"@trigger.dev/core/v3\";\nimport { useRealtimeRunsWithTag } from \"@trigger.dev/react-hooks\";\n\nimport { parseStatus } from \"@/lib/utils/generate-trigger-status\";\n\ninterface IDocumentProgressStatus {\n  state: RunStatus;\n  progress: number;\n  text: string;\n}\n\nexport function useDocumentProgressStatus(\n  documentVersionId: string,\n  publicAccessToken: string | undefined,\n) {\n  const { runs, error } = useRealtimeRunsWithTag(\n    `version:${documentVersionId}`,\n    {\n      enabled: !!publicAccessToken,\n      accessToken: publicAccessToken,\n    },\n  );\n\n  // Find the most recent active run (QUEUED or EXECUTING)\n  const activeRun = runs.find((run) =>\n    [\"QUEUED\", \"EXECUTING\"].includes(run.status),\n  );\n\n  const status: IDocumentProgressStatus = {\n    state: \"QUEUED\",\n    progress: 0,\n    text: \"Initializing...\",\n  };\n\n  // If we have no runs at all\n  if (runs.length === 0) {\n    return { status, error, run: undefined };\n  }\n\n  // If we found an active run, use its status\n  if (activeRun) {\n    status.state = activeRun.status;\n    if (activeRun.metadata) {\n      const { progress, text } = parseStatus(activeRun.metadata);\n      status.progress = progress;\n      status.text = text;\n    }\n    return { status, error, run: activeRun };\n  }\n\n  // Check if any run has failed\n  const failedRun = runs.find((run) =>\n    [\"FAILED\", \"CRASHED\", \"CANCELED\", \"SYSTEM_FAILURE\"].includes(run.status),\n  );\n\n  if (failedRun) {\n    status.state = failedRun.status;\n    if (failedRun.metadata) {\n      const { progress, text } = parseStatus(failedRun.metadata);\n      status.progress = progress;\n      status.text = text;\n    }\n    return { status, error, run: failedRun };\n  }\n\n  // If all runs are completed\n  const allCompleted = runs.every((run) => run.status === \"COMPLETED\");\n  if (allCompleted) {\n    status.state = \"COMPLETED\";\n    status.progress = 100;\n    status.text = \"Processing complete\";\n  }\n\n  return {\n    status,\n    error,\n    run: runs[0], // Return most recent run\n  };\n}\n"
  },
  {
    "path": "lib/utils/user-agent.ts",
    "content": "import { UAParser } from \"ua-parser-js\";\n\nexport function isBot(input: string) {\n  return /bot|chatgpt|Googlebot|Mediapartners-Google|AdsBot-Google|googleweblight|Storebot-Google|Google-PageRenderer|Bingbot|BingPreview|Slurp|DuckDuckBot|baiduspider|yandex|sogou|LinkedInBot|bitlybot|tumblr|vkShare|quora link preview|facebookexternalhit|facebookcatalog|Twitterbot|applebot|redditbot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|ia_archiver/i.test(\n    input,\n  );\n}\n\nexport function userAgentFromString(input: string | undefined): UserAgent {\n  return {\n    ...new UAParser(input).getResult(),\n    isBot: input === undefined ? false : isBot(input),\n  };\n}\n\ninterface UserAgent {\n  isBot: boolean;\n  ua: string;\n  browser: {\n    name?: string;\n    version?: string;\n  };\n  device: {\n    model?: string;\n    type?: string;\n    vendor?: string;\n  };\n  engine: {\n    name?: string;\n    version?: string;\n  };\n  os: {\n    name?: string;\n    version?: string;\n  };\n  cpu: {\n    architecture?: string;\n  };\n}\n"
  },
  {
    "path": "lib/utils/validate-email.ts",
    "content": "// RFC 5322 compliant regex\nexport const fullyCompliantEmailRegex =\n  /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])/;\n\n// Simple email regex - supports university domains with subdomains and hyphens\nexport const simpleEmailRegex =\n  /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\\.[a-zA-Z]{2,}$/;\n\nexport const validateEmail = (email: string) => {\n  return simpleEmailRegex.test(email.toLowerCase().trim());\n};\n"
  },
  {
    "path": "lib/utils.ts",
    "content": "import { NextRouter } from \"next/router\";\n\nimport slugify from \"@sindresorhus/slugify\";\nimport { upload } from \"@vercel/blob/client\";\nimport { transliterate } from \"transliteration\";\nimport bcrypt from \"bcryptjs\";\nimport * as chrono from \"chrono-node\";\nimport { type ClassValue, clsx } from \"clsx\";\nimport crypto from \"crypto\";\nimport ms from \"ms\";\nimport { customAlphabet } from \"nanoid\";\nimport { rgb } from \"pdf-lib\";\nimport { ParsedUrlQuery } from \"querystring\";\nimport { toast } from \"sonner\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n\nexport function classNames(...classes: string[]) {\n  const uniqueClasses = Array.from(new Set(classes.join(\" \").split(\" \")));\n  return uniqueClasses.join(\" \");\n}\n\nexport function getExtension(url: string) {\n  // @ts-ignore\n  return url.split(/[#?]/)[0].split(\".\").pop().trim();\n}\n\n/**\n * Ensures a filename has a .pdf extension for watermarked documents\n * Removes any existing extension and adds .pdf\n */\nexport function getFileNameWithPdfExtension(filename?: string): string {\n  if (!filename) return \"document.pdf\";\n\n  // Remove existing extension and add .pdf\n  const nameWithoutExt = filename.replace(/\\.[^/.]+$/, \"\");\n  return `${nameWithoutExt}.pdf`;\n}\n\ninterface SWRError extends Error {\n  status: number;\n}\n\nexport async function fetcher<JSON = any>(\n  input: RequestInfo,\n  init?: RequestInit,\n): Promise<JSON> {\n  const res = await fetch(input, init);\n\n  if (!res.ok) {\n    const error = await res.text();\n    const err = new Error(error) as SWRError;\n    err.status = res.status;\n    throw err;\n  }\n\n  return res.json();\n}\n\nexport const logStore = async ({ object }: { object: any }) => {\n  /* If in development or env variable not set, log to the console */\n  if (\n    process.env.NODE_ENV === \"development\" ||\n    !process.env.PPMK_STORE_WEBHOOK_URL\n  ) {\n    console.log(object);\n    return;\n  }\n\n  try {\n    if (process.env.PPMK_STORE_WEBHOOK_URL) {\n      return await fetch(process.env.PPMK_STORE_WEBHOOK_URL, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(object),\n      });\n    }\n  } catch (e) {\n    console.error(\"Error logging store:\", e);\n    return;\n  }\n};\n\nconst LOG_TIMEOUT_MS = 2500;\n\nconst postJsonWithTimeout = async (\n  url: string,\n  body: unknown,\n  timeoutMs: number,\n) => {\n  const controller = new AbortController();\n  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);\n  timeoutId.unref?.();\n  try {\n    return await fetch(url, {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify(body),\n      signal: controller.signal,\n    });\n  } finally {\n    clearTimeout(timeoutId);\n  }\n};\n\nexport const log = async ({\n  message,\n  type,\n  mention = false,\n}: {\n  message: string;\n  type: \"info\" | \"cron\" | \"links\" | \"error\" | \"trial\";\n  mention?: boolean;\n}) => {\n  /* If in development or env variable not set, log to the console */\n  if (\n    process.env.NODE_ENV === \"development\" ||\n    !process.env.PPMK_SLACK_WEBHOOK_URL\n  ) {\n    console.log(message);\n    return;\n  }\n\n  /* Log a message to channel */\n  try {\n    const payload = {\n      blocks: [\n        {\n          type: \"section\",\n          text: {\n            type: \"mrkdwn\",\n            // prettier-ignore\n            text: `${mention ? \"<@U05BTDUKPLZ> \" : \"\"}${type === \"error\" ? \":rotating_light: \" : \"\"}${message}`,\n          },\n        },\n      ],\n    };\n\n    if (type === \"trial\" && process.env.PPMK_TRIAL_SLACK_WEBHOOK_URL) {\n      return await postJsonWithTimeout(\n        process.env.PPMK_TRIAL_SLACK_WEBHOOK_URL,\n        payload,\n        LOG_TIMEOUT_MS,\n      );\n    }\n\n    return await postJsonWithTimeout(\n      `${process.env.PPMK_SLACK_WEBHOOK_URL}`,\n      payload,\n      LOG_TIMEOUT_MS,\n    );\n  } catch (e) {}\n};\n\nexport function bytesToSize(bytes: number) {\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  if (bytes === 0) return \"n/a\";\n  const i = Math.floor(Math.log(bytes) / Math.log(1000));\n  if (i === 0) return `${bytes} ${sizes[i]}`;\n  const sizeInCurrentUnit = bytes / Math.pow(1000, i);\n  if (sizeInCurrentUnit >= 1000 && i < sizes.length - 1) {\n    return `1 ${sizes[i + 1]}`;\n  }\n  return `${Math.round(sizeInCurrentUnit)} ${sizes[i]}`;\n}\n\nconst isValidUrl = (url: string) => {\n  try {\n    new URL(url);\n    return true;\n  } catch (e) {\n    return false;\n  }\n};\n\nexport const getDomainWithoutWWW = (url: string) => {\n  if (isValidUrl(url)) {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  }\n  try {\n    if (url.includes(\".\") && !url.includes(\" \")) {\n      return new URL(`https://${url}`).hostname.replace(/^www\\./, \"\");\n    }\n  } catch (e) {\n    return \"(direct)\"; // Not a valid URL, but cannot return null\n  }\n};\n\nexport function capitalize(str: string) {\n  if (!str || typeof str !== \"string\") return str;\n  return str.charAt(0).toUpperCase() + str.slice(1);\n}\n\nexport const timeAgo = (timestamp?: Date | string | number): string => {\n  if (!timestamp) return \"Just now\";\n  const date = new Date(timestamp);\n  const diff = Date.now() - date.getTime();\n  if (diff < 60000) {\n    // less than 1 second\n    return \"Just now\";\n  } else if (diff > 82800000) {\n    // more than 23 hours – similar to how Twitter displays timestamps\n    return date.toLocaleDateString(\"en-US\", {\n      month: \"short\",\n      day: \"numeric\",\n      year:\n        date.getFullYear() !== new Date().getFullYear() ? \"numeric\" : undefined,\n    });\n  }\n  return `${ms(diff)} ago`;\n};\n\nexport const timeIn = (timestamp?: Date): string => {\n  if (!timestamp) return \"Just now\";\n  const diff = new Date(timestamp).getTime() - Date.now();\n  if (diff < 60000) {\n    return \"Just now\";\n  }\n  return `in ${ms(diff, { long: true })}`;\n};\n\nexport const durationFormat = (durationInMilliseconds?: number): string => {\n  if (!durationInMilliseconds) return \"0 secs\";\n\n  if (durationInMilliseconds < 60000) {\n    return `${Math.round(durationInMilliseconds / 1000)} secs`;\n  } else {\n    const minutes = Math.floor(durationInMilliseconds / 60000);\n    const seconds = Math.round((durationInMilliseconds % 60000) / 1000);\n    return `${minutes}:${seconds.toString().padStart(2, \"0\")} mins`;\n  }\n};\n\nexport function nFormatter(num?: number, digits?: number) {\n  if (!num) return \"0\";\n  const lookup = [\n    { value: 1, symbol: \"\" },\n    { value: 1e3, symbol: \"K\" },\n    { value: 1e6, symbol: \"M\" },\n    { value: 1e9, symbol: \"G\" },\n    { value: 1e12, symbol: \"T\" },\n    { value: 1e15, symbol: \"P\" },\n    { value: 1e18, symbol: \"E\" },\n  ];\n  const rx = /\\.0+$|(\\.[0-9]*[1-9])0+$/;\n  var item = lookup\n    .slice()\n    .reverse()\n    .find(function (item) {\n      return num >= item.value;\n    });\n  return item\n    ? (num / item.value).toFixed(digits || 1).replace(rx, \"$1\") + item.symbol\n    : \"0\";\n}\n\nexport const getDateTimeLocal = (timestamp?: Date): string => {\n  const d = timestamp ? new Date(timestamp) : new Date();\n  if (d.toString() === \"Invalid Date\") return \"\";\n  return new Date(d.getTime() - d.getTimezoneOffset() * 60000)\n    .toISOString()\n    .split(\":\")\n    .slice(0, 2)\n    .join(\":\");\n};\n\nexport const formatDateTime = (\n  datetime: Date | string,\n  options?: Intl.DateTimeFormatOptions,\n) => {\n  if (datetime.toString() === \"Invalid Date\") return \"\";\n  return new Date(datetime).toLocaleTimeString(\"en-US\", {\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    hour12: true,\n    ...options,\n  });\n};\n\nexport async function hashPassword(password: string): Promise<string> {\n  const saltRounds = 10;\n  const hashedPassword = await bcrypt.hash(password, saltRounds);\n  return hashedPassword;\n}\n\nexport async function checkPassword(\n  password: string,\n  hashedPassword: string,\n): Promise<boolean> {\n  const match = await bcrypt.compare(password, hashedPassword);\n  return match;\n}\n\nexport function copyToClipboard(text: string, message: string): void {\n  navigator.clipboard\n    .writeText(text)\n    .then(() => {\n      toast.success(message);\n    })\n    .catch((error) => {\n      toast.warning(\"Please copy your link manually.\");\n    });\n}\n\nexport const getFirstAndLastDay = (day: number) => {\n  const today = new Date();\n  const currentDay = today.getDate();\n  const currentMonth = today.getMonth();\n  const currentYear = today.getFullYear();\n  if (currentDay >= day) {\n    // if the current day is greater than target day, it means that we just passed it\n    return {\n      firstDay: new Date(currentYear, currentMonth, day),\n      lastDay: new Date(currentYear, currentMonth + 1, day - 1),\n    };\n  } else {\n    // if the current day is less than target day, it means that we haven't passed it yet\n    const lastYear = currentMonth === 0 ? currentYear - 1 : currentYear; // if the current month is January, we need to go back a year\n    const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1; // if the current month is January, we need to go back to December\n    return {\n      firstDay: new Date(lastYear, lastMonth, day),\n      lastDay: new Date(currentYear, currentMonth, day - 1),\n    };\n  }\n};\n\nexport const formatDate = (dateString: string, updateDate?: boolean) => {\n  return new Date(dateString).toLocaleDateString(\"en-US\", {\n    day: \"numeric\",\n    month: \"long\",\n    year:\n      updateDate &&\n      new Date(dateString).getFullYear() === new Date().getFullYear()\n        ? undefined\n        : \"numeric\",\n    timeZone: \"UTC\",\n  });\n};\n\nexport const nanoid = customAlphabet(\n  \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n  7,\n); // 7-character random string\n\n/**\n * CJK-safe slugify: transliterates non-Latin characters (CJK, Cyrillic, etc.)\n * to their romanized equivalents before slugifying, so the same input always\n * produces the same slug. e.g. \"文件报告\" → \"wen-jian-bao-gao\"\n */\nexport function safeSlugify(input: string): string {\n  const slug = slugify(input);\n  if (slug.length > 0) return slug;\n  return slugify(transliterate(input)) || nanoid();\n}\n\nexport const daysLeft = (\n  accountCreationDate: Date,\n  maxDays: number,\n): number => {\n  const now = new Date();\n  const endPeriodDate = new Date(accountCreationDate);\n  endPeriodDate.setDate(accountCreationDate.getDate() + maxDays);\n\n  const diffInMilliseconds = endPeriodDate.getTime() - now.getTime();\n\n  // Convert milliseconds to days and round down to show complete days remaining\n  return Math.floor(diffInMilliseconds / (1000 * 60 * 60 * 24));\n};\n\nconst cutoffDate = new Date(\"2023-10-17T00:00:00.000Z\");\n\nexport const calculateDaysLeft = (accountCreationDate: Date): number => {\n  let maxDays;\n  if (accountCreationDate < cutoffDate) {\n    maxDays = 30;\n    accountCreationDate = new Date(\"2023-10-01T00:00:00.000Z\");\n  } else {\n    maxDays = 14;\n  }\n  return daysLeft(accountCreationDate, maxDays);\n};\n\nexport function constructMetadata({\n  title = \"Papermark | The Open Source DocSend Alternative\",\n  description = \"Papermark is an open-source document sharing alternative to DocSend with built-in engagement analytics and 100% white-labeling.\",\n  image = \"https://www.papermark.com/_static/meta-image.png\",\n  favicon = \"/favicon.ico\",\n  noIndex = false,\n}: {\n  title?: string;\n  description?: string;\n  image?: string;\n  favicon?: string;\n  noIndex?: boolean;\n} = {}) {\n  return {\n    title,\n    description,\n    openGraph: {\n      title,\n      description,\n      images: [\n        {\n          url: image,\n        },\n      ],\n    },\n    twitter: {\n      card: \"summary_large_image\",\n      title,\n      description,\n      images: [image],\n      creator: \"@papermarkio\",\n    },\n    favicon,\n    ...(noIndex && {\n      robots: {\n        index: false,\n        follow: false,\n      },\n    }),\n  };\n}\n\nexport const isDataUrl = (str: string): boolean => {\n  return str?.startsWith(\"data:\");\n};\n\nexport const convertDataUrlToFile = ({\n  dataUrl,\n  filename = \"logo.png\",\n}: {\n  dataUrl: string;\n  filename?: string;\n}) => {\n  let arr = dataUrl.split(\",\"),\n    match = arr[0].match(/:(.*?);/),\n    mime = match ? match[1] : \"\",\n    bstr = atob(arr[1]),\n    n = bstr.length,\n    u8arr = new Uint8Array(n);\n\n  while (n--) {\n    u8arr[n] = bstr.charCodeAt(n);\n  }\n\n  filename =\n    mime == \"image/png\"\n      ? \"logo.png\"\n      : mime == \"image/jpeg\"\n        ? \"logo.jpg\"\n        : filename;\n\n  return new File([u8arr], filename, { type: mime });\n};\n\nexport const convertDataUrlToBuffer = (\n  dataUrl: string,\n): { buffer: Buffer; mimeType: string; filename: string } => {\n  // Extract mime type\n  const match = dataUrl.match(/:(.*?);/);\n  const mimeType = match ? match[1] : \"\";\n\n  // Extract base64 data\n  const base64Data = dataUrl.split(\",\")[1];\n  const buffer = Buffer.from(base64Data, \"base64\");\n\n  // Determine filename based on mime type\n  const filename =\n    mimeType === \"image/png\"\n      ? \"image.png\"\n      : mimeType === \"image/jpeg\"\n        ? \"image.jpg\"\n        : mimeType === \"image/x-icon\" || mimeType === \"image/vnd.microsoft.icon\"\n          ? \"favicon.ico\"\n          : \"image\";\n\n  return { buffer, mimeType, filename };\n};\n\nexport const validateImageDimensions = (\n  image: string,\n  minSize: number,\n  maxSize: number,\n): Promise<boolean> => {\n  return new Promise((resolve) => {\n    const img = new Image();\n    img.src = image;\n    img.onload = () => {\n      const { width, height } = img;\n      if (\n        width >= minSize &&\n        height >= minSize &&\n        width <= maxSize &&\n        height <= maxSize\n      ) {\n        resolve(true);\n      } else {\n        resolve(false);\n      }\n    };\n    img.onerror = () => {\n      resolve(false);\n    };\n  });\n};\n\nexport const uploadImage = async (\n  file: File,\n  uploadType: \"profile\" | \"assets\" = \"assets\",\n) => {\n  const newBlob = await upload(file.name, file, {\n    access: \"public\",\n    handleUploadUrl: `/api/file/image-upload?type=${uploadType}`,\n  });\n\n  return newBlob.url;\n};\n\n/**\n * Generates a Gravatar hash for the given email.\n * @param {string} email - The email address.\n * @returns {string} The Gravatar hash.\n */\nexport const generateGravatarHash = (email: string | null): string => {\n  if (!email) return \"\";\n  // 1. Trim leading and trailing whitespace from an email address\n  const trimmedEmail = email.trim();\n\n  // 2. Force all characters to lower-case\n  const lowerCaseEmail = trimmedEmail.toLowerCase();\n\n  // 3. Hash the final string with SHA256\n  const hash = crypto.createHash(\"sha256\").update(lowerCaseEmail).digest(\"hex\");\n\n  return hash;\n};\n\nexport async function generateEncrpytedPassword(\n  password: string,\n): Promise<string> {\n  // If the password is empty, return an empty string\n  if (!password) return \"\";\n  // If the password is already encrypted, return it\n  // Check if it's encrypted by validating the format: 32-char hex IV + \":\" + hex encrypted text\n  const textParts: string[] = password.split(\":\");\n  if (\n    textParts.length === 2 &&\n    textParts[0].length === 32 &&\n    /^[a-fA-F0-9]+$/.test(textParts[0]) &&\n    /^[a-fA-F0-9]+$/.test(textParts[1])\n  ) {\n    return password;\n  }\n  // Otherwise, encrypt the password\n  const encryptedKey: string = crypto\n    .createHash(\"sha256\")\n    .update(String(process.env.NEXT_PRIVATE_DOCUMENT_PASSWORD_KEY))\n    .digest(\"base64\")\n    .substring(0, 32);\n  const IV: Buffer = crypto.randomBytes(16);\n  const cipher = crypto.createCipheriv(\"aes-256-ctr\", encryptedKey, IV);\n  let encryptedText: string = cipher.update(password, \"utf8\", \"hex\");\n  encryptedText += cipher.final(\"hex\");\n  return IV.toString(\"hex\") + \":\" + encryptedText;\n}\n\nexport function decryptEncrpytedPassword(password: string): string {\n  if (!password) return \"\";\n  const encryptedKey: string = crypto\n    .createHash(\"sha256\")\n    .update(String(process.env.NEXT_PRIVATE_DOCUMENT_PASSWORD_KEY))\n    .digest(\"base64\")\n    .substring(0, 32);\n  const textParts: string[] = password.split(\":\");\n  // Check if it's in the expected encrypted format: 32-char hex IV + \":\" + hex encrypted text\n  if (\n    !textParts ||\n    textParts.length !== 2 ||\n    textParts[0].length !== 32 ||\n    !/^[a-fA-F0-9]+$/.test(textParts[0]) ||\n    !/^[a-fA-F0-9]+$/.test(textParts[1])\n  ) {\n    return password; // Return as-is if not in encrypted format\n  }\n  try {\n    const IV: Buffer = Buffer.from(textParts[0], \"hex\");\n    const encryptedText: string = textParts[1];\n    const decipher = crypto.createDecipheriv(\"aes-256-ctr\", encryptedKey, IV);\n    let decrypted: string = decipher.update(encryptedText, \"hex\", \"utf8\");\n    decrypted += decipher.final(\"utf8\");\n    return decrypted;\n  } catch (error) {\n    return password;\n  }\n}\n\ntype FilterMode = \"email\" | \"domain\" | \"both\";\n\nexport const sanitizeList = (\n  list: string,\n  mode: FilterMode = \"both\",\n): string[] => {\n  const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n  const domainRegex = /^@[^\\s@]+\\.[^\\s@]+$/;\n\n  const sanitized = list\n    .split(\"\\n\")\n    .map((item) => item.trim().replace(/,$/, \"\").toLowerCase())\n    .filter((item) => item !== \"\")\n    .filter((item) => {\n      if (mode === \"email\") return emailRegex.test(item);\n      if (mode === \"domain\") return domainRegex.test(item);\n      return emailRegex.test(item) || domainRegex.test(item);\n    });\n\n  return [...new Set(sanitized)];\n};\n\nexport function hexToRgb(hex: string) {\n  let bigint = parseInt(hex.slice(1), 16);\n  let r = ((bigint >> 16) & 255) / 255; // Convert to 0-1 range\n  let g = ((bigint >> 8) & 255) / 255; // Convert to 0-1 range\n  let b = (bigint & 255) / 255; // Convert to 0-1 range\n  return rgb(r, g, b);\n}\n\nexport const trim = (u: unknown) => (typeof u === \"string\" ? u.trim() : u);\n\nexport const getBreadcrumbPath = (path: string[]) => {\n  const segments = path?.filter(Boolean);\n  if (!Array.isArray(path) || path.length === 0) {\n    return [{ name: \"Home\", pathLink: \"/documents\" }];\n  }\n  let currentPath = \"documents/tree\";\n\n  return [\n    { name: \"Home\", pathLink: \"/documents\" },\n    ...segments.map((segment, index) => {\n      currentPath += `/${safeSlugify(segment)}`;\n      return {\n        name: segment,\n        pathLink: currentPath,\n      };\n    }),\n  ];\n};\n\nexport const handleInvitationStatus = (\n  invitationStatus: \"accepted\" | \"teamMember\",\n  queryParams: ParsedUrlQuery,\n  router: NextRouter,\n) => {\n  switch (invitationStatus) {\n    case \"accepted\":\n      toast.success(\"Welcome to the team! You've successfully joined.\");\n      break;\n    case \"teamMember\":\n      toast.error(\"You've already accepted this invitation!\");\n      break;\n    default:\n      toast.error(\"Invalid invitation status\");\n  }\n\n  delete queryParams[\"invitation\"];\n  router.replace(\"/documents\", undefined, {\n    shallow: true,\n  });\n};\n\n/**\n * Preset options for the expiration time of a link.\n * @type {Array<{ label: string, value: number }>}\n */\n\nexport const PRESET_OPTIONS: { label: string; value: number }[] = [\n  { label: \"in 1 hour\", value: 3600 },\n  { label: \"in 6 hours\", value: 21600 },\n  { label: \"in 12 hours\", value: 43200 },\n  { label: \"in 1 day\", value: 86400 },\n  { label: \"in 3 days\", value: 259200 },\n  { label: \"in 7 days\", value: 604800 },\n  { label: \"in 14 days\", value: 1209600 },\n  { label: \"in 1 month\", value: 2592000 },\n  { label: \"in 3 months\", value: 7776000 },\n  { label: \"in 6 months\", value: 15552000 },\n  { label: \"in 1 year\", value: 31536000 },\n];\nexport const WITH_CUSTOM_PRESET_OPTION: {\n  label: string;\n  value: number | string;\n}[] = [...PRESET_OPTIONS, { label: \"Custom\", value: \"custom\" }];\n\nexport const formatExpirationTime = (seconds: number) => {\n  // Define constants for time units\n  const MINUTE = 60;\n  const HOUR = 3600;\n  const DAY = 86400;\n  const YEAR = 31536000;\n\n  seconds = Math.ceil(seconds / MINUTE) * MINUTE;\n\n  if (seconds < MINUTE) {\n    return \"Less than a minute\";\n  }\n\n  // Return exact unit match if possible\n  if (seconds % YEAR === 0) {\n    const years = seconds / YEAR;\n    return `${years} year${years !== 1 ? \"s\" : \"\"}`;\n  }\n\n  if (seconds % DAY === 0) {\n    const days = seconds / DAY;\n    return `${days} day${days !== 1 ? \"s\" : \"\"}`;\n  }\n\n  if (seconds % HOUR === 0 && seconds < DAY) {\n    const hours = seconds / HOUR;\n    return `${hours} hour${hours !== 1 ? \"s\" : \"\"}`;\n  }\n\n  if (seconds % MINUTE === 0 && seconds < HOUR) {\n    const minutes = seconds / MINUTE;\n    return `${minutes} minute${minutes !== 1 ? \"s\" : \"\"}`;\n  }\n\n  // Mixed unit fallbacks\n  if (seconds < HOUR) {\n    const minutes = Math.floor(seconds / MINUTE);\n    return `${minutes} minute${minutes !== 1 ? \"s\" : \"\"}`;\n  }\n\n  if (seconds < DAY) {\n    const hours = Math.floor(seconds / HOUR);\n    const minutes = Math.floor((seconds % HOUR) / MINUTE);\n    return (\n      `${hours} hour${hours !== 1 ? \"s\" : \"\"}` +\n      (minutes > 0 ? ` and ${minutes} minute${minutes !== 1 ? \"s\" : \"\"}` : \"\")\n    );\n  }\n\n  if (seconds < YEAR) {\n    const days = Math.floor(seconds / DAY);\n    const remainingSeconds = seconds % DAY;\n    const hours = Math.floor(remainingSeconds / HOUR);\n    const minutes = Math.floor((remainingSeconds % HOUR) / MINUTE);\n\n    let result = `${days} day${days !== 1 ? \"s\" : \"\"}`;\n\n    if (hours > 0 && minutes > 0) {\n      result += `, ${hours} hour${hours !== 1 ? \"s\" : \"\"} and ${minutes} minute${minutes !== 1 ? \"s\" : \"\"}`;\n    } else if (hours > 0) {\n      result += ` and ${hours} hour${hours !== 1 ? \"s\" : \"\"}`;\n    } else if (minutes > 0) {\n      result += ` and ${minutes} minute${minutes !== 1 ? \"s\" : \"\"}`;\n    }\n\n    return result;\n  }\n\n  // Years + remaining time\n  const years = Math.floor(seconds / YEAR);\n  const remainingSeconds = seconds % YEAR;\n  const days = Math.floor(remainingSeconds / DAY);\n  const hours = Math.floor((remainingSeconds % DAY) / HOUR);\n  const minutes = Math.floor((remainingSeconds % HOUR) / MINUTE);\n\n  let result = `${years} year${years !== 1 ? \"s\" : \"\"}`;\n\n  if (days > 0) {\n    result += `, ${days} day${days !== 1 ? \"s\" : \"\"}`;\n  }\n  if (hours > 0) {\n    result += `, ${hours} hour${hours !== 1 ? \"s\" : \"\"}`;\n  }\n  if (minutes > 0) {\n    result += ` and ${minutes} minute${minutes !== 1 ? \"s\" : \"\"}`;\n  }\n\n  return result;\n};\n\n// from DUB.IO\nexport const parseDateTime = (str: Date | string) => {\n  if (str instanceof Date) return str;\n  return chrono.parseDate(str);\n};\n\n/**\n * Safely replaces template variables in user input with actual values.\n * Only allows whitelisted variables to prevent template injection.\n */\nexport function safeTemplateReplace(\n  template: string,\n  data: Record<string, any>,\n): string {\n  // Define allowed template variables - only these will be replaced\n  const allowedVariables = [\"email\", \"date\", \"time\", \"link\", \"ipAddress\"];\n\n  let result = template;\n\n  for (const key of allowedVariables) {\n    if (data[key] !== undefined && data[key] !== null) {\n      // Use a regex to match {{variable}} patterns with optional whitespace\n      const regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, \"gi\");\n      result = result.replace(regex, String(data[key]));\n    }\n  }\n\n  return result;\n}\n\n/**\n * Converts BigInt fileSize values to numbers for safe serialization\n * Recursively processes objects and arrays, converting only fileSize fields\n */\nexport function serializeFileSize(obj: any): any {\n  if (obj === null || obj === undefined) {\n    return obj;\n  }\n\n  if (Array.isArray(obj)) {\n    return obj.map(serializeFileSize);\n  }\n\n  if (typeof obj === \"object\") {\n    const serialized: any = {};\n    for (const key in obj) {\n      if (obj.hasOwnProperty(key)) {\n        if (key === \"fileSize\" && typeof obj[key] === \"bigint\") {\n          // Convert BigInt fileSize to number\n          serialized[key] = Number(obj[key]);\n        } else {\n          serialized[key] = serializeFileSize(obj[key]);\n        }\n      }\n    }\n    return serialized;\n  }\n\n  return obj;\n}\n"
  },
  {
    "path": "lib/webhook/constants.ts",
    "content": "export const TEAM_LEVEL_WEBHOOK_TRIGGERS = [\n  \"document.created\",\n  \"document.updated\",\n  \"document.deleted\",\n  \"dataroom.created\",\n] as const;\n\nexport const DOCUMENT_LEVEL_WEBHOOK_TRIGGERS = [\n  \"link.created\",\n  \"link.updated\",\n] as const;\n\nexport const LINK_LEVEL_WEBHOOK_TRIGGERS = [\n  \"link.viewed\",\n  \"link.downloaded\",\n] as const;\n\nexport const WEBHOOK_TRIGGERS = [\n  ...TEAM_LEVEL_WEBHOOK_TRIGGERS,\n  ...DOCUMENT_LEVEL_WEBHOOK_TRIGGERS,\n  ...LINK_LEVEL_WEBHOOK_TRIGGERS,\n] as const;\n\nexport const WEBHOOK_TRIGGER_DESCRIPTIONS = {\n  \"link.created\": \"Link created\",\n  \"link.updated\": \"Link updated\",\n  \"link.deleted\": \"Link deleted\",\n  \"link.viewed\": \"Link viewed\",\n  \"link.downloaded\": \"Link downloaded\",\n  \"document.created\": \"Document created\",\n  \"document.updated\": \"Document updated\",\n  \"document.deleted\": \"Document deleted\",\n  \"dataroom.created\": \"Data room created\",\n} as const;\n"
  },
  {
    "path": "lib/webhook/send-webhooks.ts",
    "content": "import { Webhook } from \"@prisma/client\";\n\nimport { qstash } from \"@/lib/cron\";\n\nimport { createWebhookSignature } from \"./signature\";\nimport { prepareWebhookPayload } from \"./transform\";\nimport { EventDataProps, WebhookPayload, WebhookTrigger } from \"./types\";\n\n// Send webhooks to multiple webhooks\nexport const sendWebhooks = async ({\n  webhooks,\n  trigger,\n  data,\n}: {\n  webhooks: Pick<Webhook, \"pId\" | \"url\" | \"secret\">[];\n  trigger: WebhookTrigger;\n  data: EventDataProps;\n}) => {\n  if (webhooks.length === 0) {\n    return;\n  }\n\n  const payload = prepareWebhookPayload(trigger, data);\n\n  return await Promise.all(\n    webhooks.map((webhook) =>\n      publishWebhookEventToQStash({ webhook, payload }),\n    ),\n  );\n};\n\n// Publish webhook event to QStash\nconst publishWebhookEventToQStash = async ({\n  webhook,\n  payload,\n}: {\n  webhook: Pick<Webhook, \"pId\" | \"url\" | \"secret\">;\n  payload: WebhookPayload;\n}) => {\n  const callbackUrl = new URL(\n    `${process.env.NEXT_PUBLIC_BASE_URL}/api/webhooks/callback`,\n  );\n  callbackUrl.searchParams.append(\"webhookId\", webhook.pId);\n  callbackUrl.searchParams.append(\"eventId\", payload.id);\n  callbackUrl.searchParams.append(\"event\", payload.event);\n\n  const signature = await createWebhookSignature(webhook.secret, payload);\n\n  const response = await qstash.publishJSON({\n    url: webhook.url,\n    body: payload,\n    headers: {\n      \"X-Papermark-Signature\": signature,\n      \"Upstash-Hide-Headers\": \"true\",\n    },\n    callback: callbackUrl.href,\n    failureCallback: callbackUrl.href,\n  });\n\n  if (!response.messageId) {\n    console.error(\"Failed to publish webhook event to QStash\", response);\n  }\n\n  return response;\n};\n"
  },
  {
    "path": "lib/webhook/signature.ts",
    "content": "export const createWebhookSignature = async (secret: string, body: any) => {\n  if (!secret) {\n    throw new Error(\"A secret must be provided to create a webhook signature.\");\n  }\n\n  const keyData = new TextEncoder().encode(secret);\n  const messageData = new TextEncoder().encode(JSON.stringify(body));\n\n  const cryptoKey = await crypto.subtle.importKey(\n    \"raw\",\n    keyData,\n    { name: \"HMAC\", hash: \"SHA-256\" },\n    false,\n    [\"sign\"],\n  );\n\n  const signature = await crypto.subtle.sign(\"HMAC\", cryptoKey, messageData);\n  const signatureArray = Array.from(new Uint8Array(signature));\n  const hexSignature = signatureArray\n    .map((byte) => byte.toString(16).padStart(2, \"0\"))\n    .join(\"\");\n\n  return hexSignature;\n};\n"
  },
  {
    "path": "lib/webhook/transform.ts",
    "content": "import { newId } from \"@/lib/id-helper\";\nimport { webhookPayloadSchema } from \"@/lib/zod/schemas/webhooks\";\n\nimport { WebhookTrigger } from \"./types\";\n\nexport const prepareWebhookPayload = (trigger: WebhookTrigger, data: any) => {\n  const payload = webhookPayloadSchema.parse({\n    id: newId(\"webhookEvent\"),\n    event: trigger,\n    data: data,\n    createdAt: new Date().toISOString(),\n  });\n\n  return payload;\n};\n"
  },
  {
    "path": "lib/webhook/triggers/document-created.ts",
    "content": "import { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\nimport { sendWebhooks } from \"@/lib/webhook/send-webhooks\";\n\nexport async function sendDocumentCreatedWebhook({\n  teamId,\n  data,\n}: {\n  teamId: string;\n  data: any;\n}) {\n  try {\n    const { document_id: documentId } = data;\n\n    if (!documentId || !teamId) {\n      throw new Error(\"Missing required parameters\");\n    }\n\n    // check if team is on paid plan\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      select: { plan: true },\n    });\n\n    if (team?.plan === \"free\" || team?.plan === \"pro\") {\n      // team is not on paid plan, so we don't need to send webhooks\n      return;\n    }\n\n    // check if team is paused\n    const teamIsPaused = await isTeamPausedById(teamId);\n    if (teamIsPaused) {\n      // team is paused, so we don't send webhooks\n      return;\n    }\n\n    // Get webhooks for team\n    const webhooks = await prisma.webhook.findMany({\n      where: {\n        teamId,\n        triggers: {\n          array_contains: [\"document.created\"],\n        },\n      },\n      select: {\n        pId: true,\n        url: true,\n        secret: true,\n      },\n    });\n\n    if (!webhooks || (webhooks && webhooks.length === 0)) {\n      // No webhooks for team, so we don't need to send webhooks\n      return;\n    }\n\n    // Get document information\n    const document = await prisma.document.findUnique({\n      where: { id: documentId, teamId },\n    });\n\n    if (!document) {\n      throw new Error(\"Document not found\");\n    }\n\n    // Prepare document data for webhook\n    const documentData = {\n      id: document.id,\n      name: document.name,\n      contentType: document.contentType,\n      teamId: document.teamId,\n      createdAt: document.createdAt.toISOString(),\n    };\n\n    // Prepare webhook payload\n    const webhookData = {\n      document: documentData,\n    };\n\n    // Send webhooks\n    if (webhooks.length > 0) {\n      await sendWebhooks({\n        webhooks,\n        trigger: \"document.created\",\n        data: webhookData,\n      });\n    }\n    return;\n  } catch (error) {\n    log({\n      message: `Error sending webhooks for document created: ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    return;\n  }\n}\n"
  },
  {
    "path": "lib/webhook/triggers/link-created.ts",
    "content": "import { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\n\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\nimport { sendWebhooks } from \"@/lib/webhook/send-webhooks\";\n\nexport async function sendLinkCreatedWebhook({\n  teamId,\n  data,\n}: {\n  teamId: string;\n  data: any;\n}) {\n  try {\n    const {\n      link_id: linkId,\n      document_id: documentId,\n      dataroom_id: dataroomId,\n    } = data;\n\n    if (!linkId || !teamId) {\n      throw new Error(\"Missing required parameters\");\n    }\n\n    // check if team is on paid plan\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      select: { plan: true },\n    });\n\n    if (\n      team?.plan === \"free\" ||\n      team?.plan === \"pro\" ||\n      team?.plan.includes(\"trial\")\n    ) {\n      // team is not on paid plan, so we don't need to send webhooks\n      return;\n    }\n\n    // check if team is paused\n    const teamIsPaused = await isTeamPausedById(teamId);\n    if (teamIsPaused) {\n      // team is paused, so we don't send webhooks\n      return;\n    }\n\n    // Get webhooks for team\n    const webhooks = await prisma.webhook.findMany({\n      where: {\n        teamId,\n        triggers: {\n          array_contains: [\"link.created\"],\n        },\n      },\n      select: {\n        pId: true,\n        url: true,\n        secret: true,\n      },\n    });\n\n    if (!webhooks || (webhooks && webhooks.length === 0)) {\n      // No webhooks for team, so we don't need to send webhooks\n      return;\n    }\n\n    // Get link information\n    const link = await prisma.link.findUnique({\n      where: { id: linkId, teamId },\n    });\n\n    if (!link) {\n      throw new Error(\"Link not found\");\n    }\n\n    // Prepare link data for webhook\n    const linkData = {\n      id: link.id,\n      url: link.domainId\n        ? `https://${link.domainSlug}/${link.slug}`\n        : `https://www.papermark.com/view/${link.id}`,\n      domain:\n        link.domainId && link.domainSlug ? link.domainSlug : \"papermark.com\",\n      key: link.domainId && link.slug ? link.slug : `view/${link.id}`,\n      name: link.name,\n      expiresAt: link.expiresAt?.toISOString() || null,\n      hasPassword: !!link.password,\n      allowList: link.allowList,\n      denyList: link.denyList,\n      enabledEmailProtection: link.emailProtected,\n      enabledEmailVerification: link.emailAuthenticated,\n      allowDownload: link.allowDownload ?? false,\n      isArchived: link.isArchived,\n      enabledNotification: link.enableNotification ?? false,\n      enabledFeedback: link.enableFeedback ?? false,\n      enabledQuestion: link.enableQuestion ?? false,\n      enabledScreenshotProtection: link.enableScreenshotProtection ?? false,\n      enabledAgreement: link.enableAgreement ?? false,\n      enabledWatermark: link.enableWatermark ?? false,\n      metaTitle: link.metaTitle,\n      metaDescription: link.metaDescription,\n      metaImage: link.metaImage,\n      metaFavicon: link.metaFavicon,\n      documentId: link.documentId,\n      dataroomId: link.dataroomId,\n      groupId: link.groupId,\n      permissionGroupId: link.permissionGroupId,\n      linkType: link.linkType,\n      teamId: teamId,\n      createdAt: link.createdAt.toISOString(),\n      updatedAt: link.updatedAt.toISOString(),\n    };\n\n    // Get document and dataroom information for webhook in parallel\n    const [document, dataroom] = await Promise.all([\n      documentId\n        ? prisma.document.findUnique({\n            where: { id: documentId, teamId },\n            select: {\n              id: true,\n              name: true,\n              contentType: true,\n              createdAt: true,\n            },\n          })\n        : null,\n      dataroomId\n        ? prisma.dataroom.findUnique({\n            where: { id: dataroomId, teamId },\n            select: { id: true, name: true, createdAt: true },\n          })\n        : null,\n    ]);\n\n    // Prepare webhook payload\n    const webhookData = {\n      link: linkData,\n      ...(document && {\n        document: {\n          id: document.id,\n          name: document.name,\n          contentType: document.contentType,\n          teamId: teamId,\n          createdAt: document.createdAt.toISOString(),\n        },\n      }),\n      ...(dataroom && {\n        dataroom: {\n          id: dataroom.id,\n          name: dataroom.name,\n          teamId: teamId,\n          createdAt: dataroom.createdAt.toISOString(),\n        },\n      }),\n    };\n\n    // Send webhooks\n    if (webhooks.length > 0) {\n      await sendWebhooks({\n        webhooks,\n        trigger: \"link.created\",\n        data: webhookData,\n      });\n    }\n    return;\n  } catch (error) {\n    log({\n      message: `Error sending webhooks for link created: ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    return;\n  }\n}\n"
  },
  {
    "path": "lib/webhook/types.ts",
    "content": "import { z } from \"zod\";\n\nimport {\n  dataroomCreatedWebhookSchema,\n  documentCreatedWebhookSchema,\n  linkCreatedWebhookSchema,\n  webhookPayloadSchema,\n} from \"../zod/schemas/webhooks\";\nimport { WEBHOOK_TRIGGER_DESCRIPTIONS } from \"./constants\";\n\nexport type WebhookTrigger = keyof typeof WEBHOOK_TRIGGER_DESCRIPTIONS;\n\nexport type WebhookPayload =\n  | z.infer<typeof webhookPayloadSchema>\n  | z.infer<typeof linkCreatedWebhookSchema>\n  | z.infer<typeof documentCreatedWebhookSchema>\n  | z.infer<typeof dataroomCreatedWebhookSchema>;\n\n// TODO: only show the link.viewed, link.created, document.created data props for now\nexport type EventDataProps = WebhookPayload[\"data\"];\n"
  },
  {
    "path": "lib/webstorage.ts",
    "content": "/**\n * Provides a wrapper around localStorage(and sessionStorage(TODO when needed)) to avoid errors in case of restricted storage access.\n *\n * TODO: In case of an embed if localStorage is not available(third party), use localStorage of parent(first party) that contains the iframe.\n */\nexport const localStorage = {\n    getItem(key: string) {\n        try {\n            return window.localStorage.getItem(key);\n        } catch (e) {\n            // In case storage is restricted. Possible reasons\n            // 1. Third Party Context in Chrome Incognito mode.\n            return null;\n        }\n    },\n    setItem(key: string, value: string) {\n        try {\n            window.localStorage.setItem(key, value);\n        } catch (e) {\n            // In case storage is restricted. Possible reasons\n            // 1. Third Party Context in Chrome Incognito mode.\n            // 2. Storage limit reached\n            return;\n        }\n    },\n    removeItem: (key: string) => {\n        try {\n            window.localStorage.removeItem(key);\n        } catch (e) {\n            return;\n        }\n    },\n};\n"
  },
  {
    "path": "lib/year-in-review/calculate-percentile.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\n// Function to get total views for all teams and calculate percentiles\nexport async function calculateViewPercentile(teamTotalViews: number) {\n  const allTeamViews = await prisma.$queryRaw<Array<{ total_views: number }>>`\n    WITH TeamStats AS (\n      SELECT \n        \"teamId\",\n        CAST(stats->>'totalViews' AS INTEGER) as total_views\n      FROM \"YearInReview\"\n      WHERE stats->>'totalViews' IS NOT NULL\n    )\n    SELECT total_views \n    FROM TeamStats \n    ORDER BY total_views DESC\n  `;\n\n  // Convert to array of numbers and sort descending\n  const viewCounts = allTeamViews\n    .map((t) => t.total_views)\n    .sort((a, b) => b - a);\n  const totalTeams = viewCounts.length;\n\n  if (totalTeams === 0) return 100; // If no other teams, you're at the top\n\n  // Find position of current team\n  const position = viewCounts.findIndex((views) => views <= teamTotalViews) + 1;\n\n  // Calculate percentile (position / total * 100)\n  const percentile = (position / totalTeams) * 100;\n\n  // Map to predefined brackets\n  if (percentile <= 1) return 1;\n  if (percentile <= 3) return 3;\n  if (percentile <= 5) return 5;\n  if (percentile <= 10) return 10;\n  if (percentile <= 25) return 25;\n  if (percentile <= 50) return 50;\n  return 100;\n}\n\n// Updated getYearInReviewStats to include percentile\nexport async function getYearInReviewStats(teamId: string) {\n  // Get the team's stats first\n  const stats = await prisma.yearInReview.findFirst({\n    where: { teamId },\n    select: { stats: true },\n  });\n\n  if (!stats?.stats) {\n    throw new Error(\"Stats not found for team\");\n  }\n\n  const parsedStats = stats.stats as any;\n  const totalViews = parseInt(parsedStats.totalViews);\n\n  // Calculate the percentile\n  const sharerPercentile = await calculateViewPercentile(totalViews);\n\n  // Return stats with percentile\n  return {\n    ...parsedStats,\n    sharerPercentile,\n  };\n}\n"
  },
  {
    "path": "lib/year-in-review/get-stats.ts",
    "content": "import { Prisma } from \"@prisma/client\";\n\nimport prisma from \"@/lib/prisma\";\nimport { getTotalTeamDuration } from \"@/lib/tinybird/pipes\";\n\nimport { COUNTRIES } from \"../constants\";\n\n// Country centroids (latitude, longitude) for distance calculations\nconst COUNTRY_CENTROIDS: { [key: string]: { lat: number; lng: number } } = {\n  AF: { lat: 33.94, lng: 67.71 },\n  AL: { lat: 41.15, lng: 20.17 },\n  DZ: { lat: 28.03, lng: 1.66 },\n  AD: { lat: 42.55, lng: 1.6 },\n  AO: { lat: -11.2, lng: 17.87 },\n  AR: { lat: -38.42, lng: -63.62 },\n  AM: { lat: 40.07, lng: 45.04 },\n  AU: { lat: -25.27, lng: 133.78 },\n  AT: { lat: 47.52, lng: 14.55 },\n  AZ: { lat: 40.14, lng: 47.58 },\n  BH: { lat: 25.93, lng: 50.64 },\n  BD: { lat: 23.68, lng: 90.36 },\n  BY: { lat: 53.71, lng: 27.95 },\n  BE: { lat: 50.5, lng: 4.47 },\n  BZ: { lat: 17.19, lng: -88.5 },\n  BJ: { lat: 9.31, lng: 2.32 },\n  BT: { lat: 27.51, lng: 90.43 },\n  BO: { lat: -16.29, lng: -63.59 },\n  BA: { lat: 43.92, lng: 17.68 },\n  BW: { lat: -22.33, lng: 24.68 },\n  BR: { lat: -14.24, lng: -51.93 },\n  BN: { lat: 4.54, lng: 114.73 },\n  BG: { lat: 42.73, lng: 25.49 },\n  BF: { lat: 12.24, lng: -1.56 },\n  BI: { lat: -3.37, lng: 29.92 },\n  KH: { lat: 12.57, lng: 104.99 },\n  CM: { lat: 7.37, lng: 12.35 },\n  CA: { lat: 56.13, lng: -106.35 },\n  CF: { lat: 6.61, lng: 20.94 },\n  TD: { lat: 15.45, lng: 18.73 },\n  CL: { lat: -35.68, lng: -71.54 },\n  CN: { lat: 35.86, lng: 104.2 },\n  CO: { lat: 4.57, lng: -74.3 },\n  CG: { lat: -0.23, lng: 15.83 },\n  CD: { lat: -4.04, lng: 21.76 },\n  CR: { lat: 9.75, lng: -83.75 },\n  CI: { lat: 7.54, lng: -5.55 },\n  HR: { lat: 45.1, lng: 15.2 },\n  CU: { lat: 21.52, lng: -77.78 },\n  CY: { lat: 35.13, lng: 33.43 },\n  CZ: { lat: 49.82, lng: 15.47 },\n  DK: { lat: 56.26, lng: 9.5 },\n  DO: { lat: 18.74, lng: -70.16 },\n  EC: { lat: -1.83, lng: -78.18 },\n  EG: { lat: 26.82, lng: 30.8 },\n  SV: { lat: 13.79, lng: -88.9 },\n  EE: { lat: 58.6, lng: 25.01 },\n  ET: { lat: 9.15, lng: 40.49 },\n  FI: { lat: 61.92, lng: 25.75 },\n  FR: { lat: 46.23, lng: 2.21 },\n  GA: { lat: -0.8, lng: 11.61 },\n  GE: { lat: 42.32, lng: 43.36 },\n  DE: { lat: 51.17, lng: 10.45 },\n  GH: { lat: 7.95, lng: -1.02 },\n  GR: { lat: 39.07, lng: 21.82 },\n  GT: { lat: 15.78, lng: -90.23 },\n  HN: { lat: 15.2, lng: -86.24 },\n  HK: { lat: 22.4, lng: 114.11 },\n  HU: { lat: 47.16, lng: 19.5 },\n  IS: { lat: 64.96, lng: -19.02 },\n  IN: { lat: 20.59, lng: 78.96 },\n  ID: { lat: -0.79, lng: 113.92 },\n  IR: { lat: 32.43, lng: 53.69 },\n  IQ: { lat: 33.22, lng: 43.68 },\n  IE: { lat: 53.41, lng: -8.24 },\n  IL: { lat: 31.05, lng: 34.85 },\n  IT: { lat: 41.87, lng: 12.57 },\n  JM: { lat: 18.11, lng: -77.3 },\n  JP: { lat: 36.2, lng: 138.25 },\n  JO: { lat: 30.59, lng: 36.24 },\n  KZ: { lat: 48.02, lng: 66.92 },\n  KE: { lat: -0.02, lng: 37.91 },\n  KR: { lat: 35.91, lng: 127.77 },\n  KW: { lat: 29.31, lng: 47.48 },\n  KG: { lat: 41.2, lng: 74.77 },\n  LA: { lat: 19.86, lng: 102.5 },\n  LV: { lat: 56.88, lng: 24.6 },\n  LB: { lat: 33.85, lng: 35.86 },\n  LY: { lat: 26.34, lng: 17.23 },\n  LT: { lat: 55.17, lng: 23.88 },\n  LU: { lat: 49.82, lng: 6.13 },\n  MO: { lat: 22.2, lng: 113.54 },\n  MK: { lat: 41.51, lng: 21.75 },\n  MG: { lat: -18.77, lng: 46.87 },\n  MY: { lat: 4.21, lng: 101.98 },\n  MV: { lat: 3.2, lng: 73.22 },\n  ML: { lat: 17.57, lng: -4.0 },\n  MT: { lat: 35.94, lng: 14.38 },\n  MX: { lat: 23.63, lng: -102.55 },\n  MD: { lat: 47.41, lng: 28.37 },\n  MN: { lat: 46.86, lng: 103.85 },\n  ME: { lat: 42.71, lng: 19.37 },\n  MA: { lat: 31.79, lng: -7.09 },\n  MZ: { lat: -18.67, lng: 35.53 },\n  MM: { lat: 21.91, lng: 95.96 },\n  NA: { lat: -22.96, lng: 18.49 },\n  NP: { lat: 28.39, lng: 84.12 },\n  NL: { lat: 52.13, lng: 5.29 },\n  NZ: { lat: -40.9, lng: 174.89 },\n  NI: { lat: 12.87, lng: -85.21 },\n  NE: { lat: 17.61, lng: 8.08 },\n  NG: { lat: 9.08, lng: 8.68 },\n  NO: { lat: 60.47, lng: 8.47 },\n  OM: { lat: 21.51, lng: 55.92 },\n  PK: { lat: 30.38, lng: 69.35 },\n  PA: { lat: 8.54, lng: -80.78 },\n  PY: { lat: -23.44, lng: -58.44 },\n  PE: { lat: -9.19, lng: -75.02 },\n  PH: { lat: 12.88, lng: 121.77 },\n  PL: { lat: 51.92, lng: 19.15 },\n  PT: { lat: 39.4, lng: -8.22 },\n  PR: { lat: 18.22, lng: -66.59 },\n  QA: { lat: 25.35, lng: 51.18 },\n  RO: { lat: 45.94, lng: 24.97 },\n  RU: { lat: 61.52, lng: 105.32 },\n  RW: { lat: -1.94, lng: 29.87 },\n  SA: { lat: 23.89, lng: 45.08 },\n  SN: { lat: 14.5, lng: -14.45 },\n  RS: { lat: 44.02, lng: 21.01 },\n  SG: { lat: 1.35, lng: 103.82 },\n  SK: { lat: 48.67, lng: 19.7 },\n  SI: { lat: 46.15, lng: 14.99 },\n  ZA: { lat: -30.56, lng: 22.94 },\n  ES: { lat: 40.46, lng: -3.75 },\n  LK: { lat: 7.87, lng: 80.77 },\n  SE: { lat: 60.13, lng: 18.64 },\n  CH: { lat: 46.82, lng: 8.23 },\n  TW: { lat: 23.7, lng: 120.96 },\n  TJ: { lat: 38.86, lng: 71.28 },\n  TZ: { lat: -6.37, lng: 34.89 },\n  TH: { lat: 15.87, lng: 100.99 },\n  TN: { lat: 33.89, lng: 9.54 },\n  TR: { lat: 38.96, lng: 35.24 },\n  TM: { lat: 38.97, lng: 59.56 },\n  UG: { lat: 1.37, lng: 32.29 },\n  UA: { lat: 48.38, lng: 31.17 },\n  AE: { lat: 23.42, lng: 53.85 },\n  GB: { lat: 55.38, lng: -3.44 },\n  US: { lat: 37.09, lng: -95.71 },\n  UY: { lat: -32.52, lng: -55.77 },\n  UZ: { lat: 41.38, lng: 64.59 },\n  VE: { lat: 6.42, lng: -66.59 },\n  VN: { lat: 14.06, lng: 108.28 },\n  YE: { lat: 15.55, lng: 48.52 },\n  ZM: { lat: -13.13, lng: 27.85 },\n  ZW: { lat: -19.02, lng: 29.15 },\n};\n\n// Haversine formula to calculate distance between two points on Earth\nfunction calculateDistance(\n  lat1: number,\n  lng1: number,\n  lat2: number,\n  lng2: number,\n): number {\n  const R = 6371; // Earth's radius in kilometers\n  const dLat = ((lat2 - lat1) * Math.PI) / 180;\n  const dLng = ((lng2 - lng1) * Math.PI) / 180;\n  const a =\n    Math.sin(dLat / 2) * Math.sin(dLat / 2) +\n    Math.cos((lat1 * Math.PI) / 180) *\n      Math.cos((lat2 * Math.PI) / 180) *\n      Math.sin(dLng / 2) *\n      Math.sin(dLng / 2);\n  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));\n  return R * c;\n}\n\n// Default origin (San Francisco) used when user's location is not available\nconst DEFAULT_ORIGIN = { lat: 37.77, lng: -122.42 };\n\n// Calculate total distance traveled based on unique countries\n// Uses the user's current location as the origin point\nfunction calculateTotalDistance(\n  countryCodes: string[],\n  origin: { lat: number; lng: number },\n): number {\n  let totalDistance = 0;\n\n  for (const code of countryCodes) {\n    const centroid = COUNTRY_CENTROIDS[code];\n    if (centroid) {\n      totalDistance += calculateDistance(\n        origin.lat,\n        origin.lng,\n        centroid.lat,\n        centroid.lng,\n      );\n    }\n  }\n\n  return Math.round(totalDistance);\n}\n\nexport async function getYearInReviewStats(\n  teamId: string,\n  year?: number,\n  userGeo?: { latitude?: string; longitude?: string },\n) {\n  const currentYear = year || new Date().getFullYear();\n  const yearStart = new Date(`${currentYear}-01-01`);\n  const yearEnd = new Date(`${currentYear + 1}-01-01`);\n\n  // First, get team member emails to exclude from \"most viewed\" and \"most active viewer\"\n  const teamMembers = await prisma.userTeam.findMany({\n    where: { teamId },\n    select: {\n      user: {\n        select: {\n          email: true,\n        },\n      },\n    },\n  });\n\n  const teamMemberEmails = teamMembers\n    .map((member) => member.user.email)\n    .filter((email): email is string => email !== null);\n\n  // Run all queries in parallel for a specific team\n  const [\n    documents,\n    links,\n    views,\n    mostViewedDoc,\n    mostActiveMonth,\n    dataroomCounts,\n    mostActiveViewer,\n  ] = await Promise.all([\n    prisma.document.findMany({\n      where: {\n        teamId,\n        createdAt: {\n          gte: yearStart,\n          lt: yearEnd,\n        },\n      },\n      select: {\n        id: true,\n      },\n    }),\n\n    prisma.link.count({\n      where: {\n        teamId,\n        createdAt: {\n          gte: yearStart,\n          lt: yearEnd,\n        },\n      },\n    }),\n\n    prisma.view.count({\n      where: {\n        teamId,\n        viewedAt: {\n          gte: yearStart,\n          lt: yearEnd,\n        },\n        isArchived: false,\n      },\n    }),\n\n    // 2. Most viewed document\n    prisma.$queryRaw<\n      Array<{ documentId: string; documentName: string; viewCount: number }>\n    >(Prisma.sql`\n      WITH RankedDocuments AS (\n        SELECT \n          d.\"id\" as \"documentId\",\n          d.\"name\" as \"documentName\",\n          COUNT(v.\"id\") as \"viewCount\",\n          ROW_NUMBER() OVER (ORDER BY COUNT(v.\"id\") DESC) as rn\n        FROM \"Document\" d\n        LEFT JOIN \"View\" v ON v.\"documentId\" = d.\"id\"\n        WHERE \n          d.\"teamId\" = ${teamId}\n          AND v.\"viewedAt\" >= ${yearStart}\n          AND v.\"viewedAt\" < ${yearEnd}\n          AND v.\"isArchived\" = false\n        GROUP BY d.\"id\", d.\"name\"\n      )\n      SELECT \n        \"documentId\",\n        \"documentName\",\n        \"viewCount\"\n      FROM RankedDocuments\n      WHERE rn = 1\n      LIMIT 1\n    `),\n\n    // 3. Most active month\n    prisma.$queryRaw<Array<{ month: Date; viewCount: number }>>(Prisma.sql`\n      WITH MonthlyViews AS (\n        SELECT \n          DATE_TRUNC('month', \"viewedAt\") as month,\n          COUNT(*) as \"viewCount\",\n          ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) as rn\n        FROM \"View\"\n        WHERE \n          \"teamId\" = ${teamId}\n          AND \"viewedAt\" >= ${yearStart}\n          AND \"viewedAt\" < ${yearEnd}\n          AND \"isArchived\" = false\n        GROUP BY DATE_TRUNC('month', \"viewedAt\")\n      )\n      SELECT month, \"viewCount\"\n      FROM MonthlyViews\n      WHERE rn = 1\n      LIMIT 1;\n    `),\n\n    // 4. Total datarooms for the year\n    prisma.dataroom.count({\n      where: {\n        teamId,\n        createdAt: {\n          gte: yearStart,\n          lt: yearEnd,\n        },\n      },\n    }),\n\n    // 5. Most active viewer (person who viewed most, excluding team members)\n    teamMemberEmails.length > 0\n      ? prisma.$queryRaw<\n          Array<{\n            viewerEmail: string;\n            viewerName: string | null;\n            viewCount: number;\n          }>\n        >(Prisma.sql`\n      WITH RankedViewers AS (\n        SELECT \n          v.\"viewerEmail\",\n          MAX(v.\"viewerName\") as \"viewerName\",\n          COUNT(*) as \"viewCount\",\n          ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) as rn\n        FROM \"View\" v\n        WHERE \n          v.\"teamId\" = ${teamId}\n          AND v.\"viewedAt\" >= ${yearStart}\n          AND v.\"viewedAt\" < ${yearEnd}\n          AND v.\"isArchived\" = false\n          AND v.\"viewerEmail\" IS NOT NULL\n          AND v.\"viewerEmail\" NOT IN (${Prisma.join(teamMemberEmails)})\n        GROUP BY v.\"viewerEmail\"\n      )\n      SELECT \n        \"viewerEmail\",\n        \"viewerName\",\n        \"viewCount\"\n      FROM RankedViewers\n      WHERE rn = 1\n      LIMIT 1\n    `)\n      : prisma.$queryRaw<\n          Array<{\n            viewerEmail: string;\n            viewerName: string | null;\n            viewCount: number;\n          }>\n        >(Prisma.sql`\n      WITH RankedViewers AS (\n        SELECT \n          v.\"viewerEmail\",\n          MAX(v.\"viewerName\") as \"viewerName\",\n          COUNT(*) as \"viewCount\",\n          ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) as rn\n        FROM \"View\" v\n        WHERE \n          v.\"teamId\" = ${teamId}\n          AND v.\"viewedAt\" >= ${yearStart}\n          AND v.\"viewedAt\" < ${yearEnd}\n          AND v.\"isArchived\" = false\n          AND v.\"viewerEmail\" IS NOT NULL\n        GROUP BY v.\"viewerEmail\"\n      )\n      SELECT \n        \"viewerEmail\",\n        \"viewerName\",\n        \"viewCount\"\n      FROM RankedViewers\n      WHERE rn = 1\n      LIMIT 1\n    `),\n  ]);\n\n  // skip if no documents\n  if (documents.length === 0) {\n    return {\n      year: currentYear,\n      totalDocuments: documents.length || 0,\n      totalLinks: links || 0,\n      totalViews: views || 0,\n      totalDatarooms: dataroomCounts || 0,\n      mostViewedDocument: null,\n      mostActiveMonth: null,\n      mostActiveViewer: null,\n      totalDuration: 0,\n      uniqueCountries: [],\n      distanceTraveled: 0,\n    };\n  }\n\n  // Batch document IDs to avoid URL length limits (max ~100 IDs per request)\n  const BATCH_SIZE = 100;\n  const documentIds = documents.map((doc) => doc.id);\n  const batches: string[][] = [];\n\n  for (let i = 0; i < documentIds.length; i += BATCH_SIZE) {\n    batches.push(documentIds.slice(i, i + BATCH_SIZE));\n  }\n\n  // Fetch all batches in parallel\n  const tinybirdResults = await Promise.all(\n    batches.map((batch) =>\n      getTotalTeamDuration({\n        documentIds: batch.join(\",\"),\n        since: yearStart.getTime(),\n        until: yearEnd.getTime(),\n      }).catch(() => ({ data: [] })),\n    ),\n  );\n\n  // Aggregate results from all batches\n  let totalDuration = 0;\n  const allCountries = new Set<string>();\n\n  for (const result of tinybirdResults) {\n    if (result.data[0]) {\n      totalDuration += result.data[0].total_duration || 0;\n      result.data[0].unique_countries?.forEach((country: string) =>\n        allCountries.add(country),\n      );\n    }\n  }\n\n  // Get country codes array for distance calculation\n  const countryCodes = Array.from(allCountries);\n\n  // Determine origin point from user's IP location or use default\n  const parsedLat =\n    userGeo?.latitude !== undefined ? Number(userGeo.latitude) : NaN;\n  const parsedLng =\n    userGeo?.longitude !== undefined ? Number(userGeo.longitude) : NaN;\n\n  const origin =\n    Number.isFinite(parsedLat) && Number.isFinite(parsedLng)\n      ? { lat: parsedLat, lng: parsedLng }\n      : DEFAULT_ORIGIN;\n\n  // Calculate total distance traveled based on IP locations\n  const distanceTraveled = calculateTotalDistance(countryCodes, origin);\n\n  // Write full name of countries for display\n  const uniqueCountries = countryCodes.map((country: string) => {\n    // Map country codes to full names using COUNTRIES constant\n    return COUNTRIES[country] || country;\n  });\n\n  return {\n    year: currentYear,\n    totalDocuments: documents.length || 0,\n    totalLinks: links || 0,\n    totalViews: views || 0,\n    totalDatarooms: dataroomCounts || 0,\n    mostViewedDocument: mostViewedDoc[0]\n      ? {\n          documentId: mostViewedDoc[0].documentId,\n          documentName: mostViewedDoc[0].documentName,\n          viewCount: Number(mostViewedDoc[0].viewCount),\n        }\n      : null,\n    mostActiveMonth: mostActiveMonth?.[0]\n      ? {\n          month: new Date(mostActiveMonth[0].month).toLocaleString(\"en-US\", {\n            month: \"long\",\n          }),\n          viewCount: Number(mostActiveMonth[0].viewCount),\n        }\n      : null,\n    mostActiveViewer: mostActiveViewer?.[0]\n      ? {\n          email: mostActiveViewer[0].viewerEmail,\n          name: mostActiveViewer[0].viewerName,\n          viewCount: Number(mostActiveViewer[0].viewCount),\n        }\n      : null,\n    totalDuration: totalDuration || 0,\n    uniqueCountries: uniqueCountries || [],\n    distanceTraveled: distanceTraveled || 0,\n  };\n}\n"
  },
  {
    "path": "lib/year-in-review/index.ts",
    "content": "import prisma from \"@/lib/prisma\";\n\nimport { getYearInReviewStats } from \"./get-stats\";\n\nexport async function initializeEmailQueue() {\n  const batchSize = 100; // Process teams in batches during initialization\n  let skip = 0;\n  let totalProcessed = 0;\n\n  while (true) {\n    // Get batch of teams\n    const teams = await prisma.team.findMany({\n      skip,\n      take: batchSize,\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n\n    if (teams.length === 0) break;\n\n    // Process each team in the batch and filter out teams with no views\n    const jobData = (\n      await Promise.all(\n        teams.map(async (team) => {\n          const stats = await getYearInReviewStats(team.id);\n\n          // Skip teams with no views\n          if (stats.totalViews === 0) return null;\n\n          return {\n            teamId: team.id,\n            status: \"pending\",\n            stats: stats,\n          };\n        }),\n      )\n    ).filter((job): job is NonNullable<typeof job> => job !== null);\n\n    // Bulk create jobs with precomputed stats\n    await prisma.yearInReview.createMany({\n      data: jobData,\n      skipDuplicates: true,\n    });\n\n    totalProcessed += teams.length;\n    skip += batchSize;\n\n    console.log(`Processed ${totalProcessed} teams...`);\n  }\n\n  console.log(\n    `Completed creating ${totalProcessed} email jobs with precomputed stats`,\n  );\n}\n"
  },
  {
    "path": "lib/year-in-review/send-emails.ts",
    "content": "import { render } from \"@react-email/components\";\nimport { nanoid } from \"nanoid\";\n\nimport prisma from \"@/lib/prisma\";\nimport { resend } from \"@/lib/resend\";\nimport { log } from \"@/lib/utils\";\nimport { generateUnsubscribeUrl } from \"@/lib/utils/unsubscribe\";\n\nimport YearInReviewEmail from \"@/components/emails/year-in-review-papermark\";\n\nconst BATCH_SIZE = 100; // Maximum number of emails Resend supports in one batch\nconst MAX_ATTEMPTS = 3;\nconst RATE_LIMIT_DELAY = 10000; // 10 seconds\n\ntype YearReviewStats = {\n  totalDocuments: number;\n  totalLinks: number;\n  totalViews: number;\n  mostViewedDocument: {\n    documentId: string;\n    documentName: string;\n    viewCount: number;\n  };\n  mostActiveMonth: {\n    month: string;\n    viewCount: number;\n  };\n  totalDuration: number;\n  uniqueCountries: string[];\n  sharerPercentile: number;\n};\n\ntype EmailWithMetadata = {\n  email: {\n    from: string;\n    to: string;\n    subject: string;\n    react: React.ReactElement;\n    text: string;\n    headers: {\n      \"X-Entity-Ref-ID\": string;\n      \"List-Unsubscribe\": string;\n    };\n  };\n  jobId: string;\n  teamId: string;\n  userId: string;\n};\n\nfunction msToMinutes(ms: number): number {\n  return Math.ceil(ms / 60000);\n}\n\nexport async function processEmailQueue() {\n  if (!resend) {\n    console.log(\"❌ Resend client not initialized\");\n    return;\n  }\n\n  const jobs = await prisma.yearInReview.findMany({\n    where: {\n      AND: [\n        { status: \"pending\" },\n        { attempts: { lt: MAX_ATTEMPTS } },\n        { stats: { path: [\"totalViews\"], gt: 1 } },\n      ],\n    },\n    take: BATCH_SIZE,\n    orderBy: { createdAt: \"asc\" },\n  });\n\n  if (jobs.length === 0) {\n    console.log(\"ℹ️ No jobs to process\");\n    return;\n  }\n\n  console.log(\n    `📬 Processing ${jobs.length} jobs:`,\n    jobs.map((job) => job.id),\n  );\n\n  try {\n    // Fetch team data for all jobs\n    const teamsData = await prisma.$transaction(async (tx) => {\n      return Promise.all(\n        jobs.map(async (job) => {\n          const team = await tx.team.findUnique({\n            where: { id: job.teamId },\n            include: {\n              users: {\n                where: { role: { in: [\"ADMIN\", \"MANAGER\"] } },\n                include: {\n                  user: {\n                    select: {\n                      email: true,\n                    },\n                  },\n                },\n              },\n            },\n          });\n\n          return {\n            job,\n            team,\n          };\n        }),\n      );\n    });\n    console.log(`📋 Found ${teamsData.length} teams with valid data`);\n\n    // Prepare batch of emails\n    const emailsWithMetadata: EmailWithMetadata[] = (\n      await Promise.all(\n        teamsData\n          .filter(({ team }) => team !== null) // Filter out teams that weren't found\n          .flatMap(async ({ job, team }) => {\n            const stats = job.stats as YearReviewStats;\n\n            return Promise.all(\n              team!.users\n                .filter((userTeam) => userTeam.user.email)\n                .map(async (userTeam) => {\n                  const unsubscribeUrl = generateUnsubscribeUrl({\n                    viewerId: userTeam.userId,\n                    teamId: team!.id,\n                  });\n\n                  const react = YearInReviewEmail({\n                    year: 2024,\n                    minutesSpentOnDocs: msToMinutes(stats.totalDuration),\n                    uploadedDocuments: stats.totalDocuments,\n                    sharedLinks: stats.totalLinks,\n                    receivedViews: stats.totalViews,\n                    topDocumentName: stats.mostViewedDocument.documentName,\n                    topDocumentViews: stats.mostViewedDocument.viewCount,\n                    mostActiveMonth: stats.mostActiveMonth.month,\n                    mostActiveMonthViews: stats.mostActiveMonth.viewCount,\n                    sharerPercentile: stats.sharerPercentile,\n                    viewingLocations: stats.uniqueCountries,\n                    unsubscribeUrl,\n                  });\n\n                  const plainText = await render(react, { plainText: true });\n\n                  return {\n                    email: {\n                      from: \"Papermark <system@papermark.com>\",\n                      to: userTeam.user.email || \"delivered@resend.dev\",\n                      subject: \"2024 in Review: Your Year with Papermark\",\n                      react,\n                      text: plainText,\n                      headers: {\n                        \"X-Entity-Ref-ID\": nanoid(),\n                        \"List-Unsubscribe\": unsubscribeUrl,\n                      },\n                    },\n                    jobId: job.id,\n                    teamId: team!.id,\n                    userId: userTeam.userId,\n                  };\n                }),\n            );\n          }),\n      )\n    ).flat();\n    console.log(`📧 Preparing to send ${emailsWithMetadata.length} emails`);\n\n    // Process emails in batches\n    for (let i = 0; i < emailsWithMetadata.length; i += BATCH_SIZE) {\n      const batch = emailsWithMetadata.slice(i, i + BATCH_SIZE);\n      console.log(\n        `\\n🚀 Sending batch ${Math.floor(i / BATCH_SIZE) + 1} of ${Math.ceil(emailsWithMetadata.length / BATCH_SIZE)}`,\n      );\n      console.log(\n        `📨 Recipients:`,\n        batch.map((b) => b.email.to),\n      );\n\n      try {\n        const emailBatch = batch.map((item) => item.email);\n        const { data, error } = await resend.batch.send(emailBatch);\n\n        if (error) {\n          console.log(`❌ Batch send failed:`, error);\n        } else {\n          console.log(`✅ Batch sent successfully:`, {\n            sent: data?.data.length,\n            total: batch.length,\n          });\n        }\n\n        // Track success/failure counts by job\n        const jobCounts = new Map<\n          string,\n          { success: number; failed: number }\n        >();\n\n        // Match results with metadata using array indices\n        if (data) {\n          data.data.forEach((result, index) => {\n            const metadata = batch[index];\n            const counts = jobCounts.get(metadata.jobId) || {\n              success: 0,\n              failed: 0,\n            };\n            counts.success++;\n            jobCounts.set(metadata.jobId, counts);\n          });\n        } else if (error) {\n          batch.forEach((metadata) => {\n            const counts = jobCounts.get(metadata.jobId) || {\n              success: 0,\n              failed: 0,\n            };\n            counts.failed++;\n            log({\n              message: `Failed to send email to ${metadata.email.to}: ${error.message}`,\n              type: \"error\",\n              mention: true,\n            });\n            jobCounts.set(metadata.jobId, counts);\n          });\n        }\n\n        // Update job statuses\n        for (const [jobId, counts] of jobCounts.entries()) {\n          const totalExpectedEmails =\n            teamsData\n              .find(({ job }) => job.id === jobId)\n              ?.team?.users.filter((ut) => ut.user.email).length || 0;\n\n          const job = jobs.find((j) => j.id === jobId);\n          if (!job) continue;\n\n          const status =\n            counts.failed === 0 && counts.success === totalExpectedEmails\n              ? \"completed\"\n              : job.attempts >= MAX_ATTEMPTS - 1\n                ? \"failed\"\n                : \"pending\";\n\n          await prisma.yearInReview.update({\n            where: { id: jobId },\n            data: {\n              status,\n              error:\n                counts.failed > 0\n                  ? `Failed to send ${counts.failed} out of ${totalExpectedEmails} emails`\n                  : null,\n            },\n          });\n        }\n\n        // Respect rate limit between batches\n        if (i + BATCH_SIZE < emailsWithMetadata.length) {\n          console.log(\n            `⏳ Waiting ${RATE_LIMIT_DELAY / 1000}s before next batch...`,\n          );\n          await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY));\n        }\n      } catch (error) {\n        console.log(`❌ Error processing batch:`, error);\n      }\n    }\n  } catch (error) {\n    console.log(`❌ Fatal error processing email queue:`, error);\n  }\n}\n"
  },
  {
    "path": "lib/zod/schemas/folders.ts",
    "content": "import { z } from \"zod\";\n\n/**\n * Schema to validate folder path parameters in catch-all routes.\n * Protects against both type confusion and path traversal attacks.\n */\nexport const folderPathSchema = z\n  .array(\n    z\n      .string()\n      .min(1, \"Folder name cannot be empty\")\n      .refine(\n        (name) => !name.includes(\"..\"),\n        \"Path traversal attempts are not allowed\",\n      )\n      .refine(\n        (name) => !name.includes(\"/\"),\n        \"Folder names cannot contain forward slashes\",\n      )\n      .refine(\n        (name) => !name.includes(\"\\\\\"),\n        \"Folder names cannot contain backslashes\",\n      ),\n  )\n  .min(1, \"Folder path cannot be empty\");\n\n/**\n * Type for validated folder path\n */\nexport type FolderPath = z.infer<typeof folderPathSchema>;\n"
  },
  {
    "path": "lib/zod/schemas/multipart.ts",
    "content": "import { z } from \"zod\";\n\n// Schema for multipart upload part\nconst MultipartPartSchema = z.object({\n  ETag: z.string().min(1, \"ETag is required\"),\n  PartNumber: z.number().int().min(1, \"PartNumber must be a positive integer\"),\n});\n\n// Base schema for common fields\nconst MultipartBaseSchema = z.object({\n  fileName: z.string().min(1, \"fileName is required\"),\n  contentType: z.string().min(1, \"contentType is required\"),\n  teamId: z.string().min(1, \"teamId is required\"),\n  docId: z.string().min(1, \"docId is required\"),\n});\n\n// Schema for initiate action\nconst MultipartInitiateSchema = MultipartBaseSchema.extend({\n  action: z.literal(\"initiate\"),\n});\n\n// Schema for get-part-urls action\nconst MultipartGetPartUrlsSchema = MultipartBaseSchema.extend({\n  action: z.literal(\"get-part-urls\"),\n  uploadId: z.string().min(1, \"uploadId is required for get-part-urls action\"),\n  fileSize: z.number().int().min(1, \"fileSize must be a positive integer\"),\n  partSize: z\n    .number()\n    .int()\n    .min(5 * 1024 * 1024, \"partSize must be at least 5MB\")\n    .default(10 * 1024 * 1024),\n});\n\n// Schema for complete action\nconst MultipartCompleteSchema = MultipartBaseSchema.extend({\n  action: z.literal(\"complete\"),\n  uploadId: z.string().min(1, \"uploadId is required for complete action\"),\n  parts: z.array(MultipartPartSchema).min(1, \"At least one part is required\"),\n});\n\n// Union schema for all multipart actions\nexport const MultipartUploadSchema = z.discriminatedUnion(\"action\", [\n  MultipartInitiateSchema,\n  MultipartGetPartUrlsSchema,\n  MultipartCompleteSchema,\n]);\n\n// Type exports\nexport type MultipartUploadRequest = z.infer<typeof MultipartUploadSchema>;\nexport type MultipartPart = z.infer<typeof MultipartPartSchema>;\nexport type MultipartInitiateRequest = z.infer<typeof MultipartInitiateSchema>;\nexport type MultipartGetPartUrlsRequest = z.infer<\n  typeof MultipartGetPartUrlsSchema\n>;\nexport type MultipartCompleteRequest = z.infer<typeof MultipartCompleteSchema>;\n"
  },
  {
    "path": "lib/zod/schemas/notifications.ts",
    "content": "import { z } from \"zod\";\n\nexport const NotificationFrequency = z.enum([\"instant\", \"daily\", \"weekly\"]);\nexport type NotificationFrequency = z.infer<typeof NotificationFrequency>;\n\nexport const ZViewerNotificationPreferencesSchema = z\n  .object({\n    dataroom: z.record(\n      z.object({\n        enabled: z.boolean(),\n        frequency: NotificationFrequency.optional().default(\"instant\"),\n      }),\n    ),\n  })\n  .optional()\n  .default({ dataroom: {} });\n\nexport const ZUserNotificationPreferencesSchema = z\n  .object({\n    yearInReview: z.object({\n      enabled: z.boolean(),\n    }),\n  })\n  .optional()\n  .default({ yearInReview: { enabled: true } });\n"
  },
  {
    "path": "lib/zod/schemas/presets.ts",
    "content": "import { z } from \"zod\";\n\nexport const customFieldDataSchema = z.object({\n  type: z.enum([\n    \"SHORT_TEXT\",\n    \"LONG_TEXT\",\n    \"NUMBER\",\n    \"PHONE_NUMBER\",\n    \"URL\",\n    \"CHECKBOX\",\n  ]),\n  identifier: z.string(),\n  label: z.string(),\n  placeholder: z.string().nullable().optional(),\n  required: z.boolean().default(false),\n  disabled: z.boolean().default(false),\n  orderIndex: z.number().default(0),\n});\n\nexport const watermarkConfigSchema = z\n  .object({\n    text: z.string(),\n    isTiled: z.boolean(),\n    color: z.string(),\n    fontSize: z.number(),\n    opacity: z.number(),\n    rotation: z.number(),\n    position: z.string(),\n  })\n  .nullable();\n\nexport const presetDataSchema = z.object({\n  name: z.string(),\n  // Social Media Card\n  enableCustomMetaTag: z.boolean(),\n  metaFavicon: z.string().nullable(),\n  metaImage: z.string().nullable(),\n  metaTitle: z.string().nullable(),\n  metaDescription: z.string().nullable(),\n\n  // // Custom Fields\n  enableCustomFields: z.boolean().optional(),\n  customFields: z.array(customFieldDataSchema).nullable().optional(),\n\n  // Watermark\n  enableWatermark: z.boolean(),\n  watermarkConfig: watermarkConfigSchema,\n\n  // Viewer Access Control\n  enableAllowList: z.boolean(),\n  allowList: z.array(z.string()),\n  enableDenyList: z.boolean(),\n  denyList: z.array(z.string()),\n\n  // Email Protection\n  emailProtected: z.boolean(),\n  emailAuthenticated: z.boolean().optional(),\n\n  // Additional Options\n  enableNotification: z.boolean(),\n  allowDownload: z.boolean().optional(),\n  enablePassword: z.boolean(),\n  password: z.string().nullable(),\n  expiresAt: z.string().nullable(),\n  expiresIn: z.number().nullable().optional(),\n  enableScreenshotProtection: z.boolean().optional(),\n\n  // Agreement\n  enableAgreement: z.boolean().optional(),\n  agreementId: z.string().nullable().optional(),\n\n  // Banner\n  showBanner: z.boolean().optional(),\n});\n\nexport type PresetDataSchema = z.infer<typeof presetDataSchema>;\n"
  },
  {
    "path": "lib/zod/schemas/webhooks.ts",
    "content": "import { z } from \"zod\";\n\nimport { WEBHOOK_TRIGGERS } from \"@/lib/webhook/constants\";\n\nexport const createWebhookSchema = z.object({\n  name: z.string().min(1).max(40),\n  url: z.string().url().max(190),\n  secret: z.string().startsWith(\"whsec_\"),\n  triggers: z.array(z.enum(WEBHOOK_TRIGGERS)),\n});\n\nexport const updateWebhookSchema = createWebhookSchema.partial();\n\n// Base event schema\nconst baseEventSchema = z.object({\n  id: z.string().startsWith(\"evt_\"),\n  event: z.enum(WEBHOOK_TRIGGERS),\n  createdAt: z.string().datetime(),\n});\n\n// View Event schema\nconst viewEventSchema = z.object({\n  viewedAt: z.string().datetime(),\n  viewId: z.string(),\n  email: z.string().email().nullable(),\n  emailVerified: z.boolean().default(false),\n  country: z.string().nullable(),\n  city: z.string().nullable(),\n  device: z.string().nullable(),\n  browser: z.string().nullable(),\n  os: z.string().nullable(),\n  ua: z.string().nullable(),\n  referer: z.string().nullable(),\n});\n\n// Link Event schema\nconst linkEventSchema = z.object({\n  id: z.string(),\n  url: z\n    .string()\n    .describe(\n      \"This is the full URL of the link e.g. https://www.papermark.com/view/1234566\",\n    ),\n  name: z.string().nullable(),\n  domain: z.string(),\n  key: z.string(),\n  expiresAt: z.string().datetime().nullable(),\n  hasPassword: z.boolean().default(false),\n  allowList: z.array(z.string()),\n  denyList: z.array(z.string()),\n  enabledEmailProtection: z.boolean().default(false),\n  enabledEmailVerification: z.boolean().default(false),\n  allowDownload: z.boolean().default(false),\n  isArchived: z.boolean().default(false),\n  enabledNotification: z.boolean().default(true),\n  enabledFeedback: z.boolean().default(false),\n  enabledQuestion: z.boolean().default(false),\n  enabledScreenshotProtection: z.boolean().default(false),\n  enabledAgreement: z.boolean().default(false),\n  enabledWatermark: z.boolean().default(false),\n\n  metaTitle: z.string().nullable(),\n  metaDescription: z.string().nullable(),\n  metaImage: z.string().nullable(),\n  metaFavicon: z.string().nullable(),\n\n  documentId: z.string().nullable(),\n  dataroomId: z.string().nullable(),\n  groupId: z.string().nullable(),\n\n  linkType: z.enum([\"DOCUMENT_LINK\", \"DATAROOM_LINK\", \"WORKFLOW_LINK\"]),\n  teamId: z.string(),\n  createdAt: z.string().datetime(),\n  updatedAt: z.string().datetime(),\n});\n\n// Document Event schema\nconst documentEventSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  contentType: z.string().nullable(),\n  teamId: z.string(),\n  createdAt: z.string().datetime(),\n});\n\n// Dataroom Event schema\nconst dataroomEventSchema = z.object({\n  id: z.string(),\n  name: z.string(),\n  teamId: z.string(),\n  createdAt: z.string().datetime(),\n});\n\n// link.created\nexport const linkCreatedWebhookSchema = z.object({\n  id: z.string().startsWith(\"evt_\"),\n  event: z.literal(\"link.created\"),\n  createdAt: z.string().datetime(),\n  data: z.object({\n    link: linkEventSchema,\n    document: documentEventSchema.optional(),\n    dataroom: dataroomEventSchema.optional(),\n  }),\n});\n\n// document.created\nexport const documentCreatedWebhookSchema = z.object({\n  id: z.string().startsWith(\"evt_\"),\n  event: z.literal(\"document.created\"),\n  createdAt: z.string().datetime(),\n  data: z.object({\n    document: documentEventSchema,\n  }),\n});\n\n// dataroom.created\nexport const dataroomCreatedWebhookSchema = z.object({\n  id: z.string().startsWith(\"evt_\"),\n  event: z.literal(\"dataroom.created\"),\n  createdAt: z.string().datetime(),\n  data: z.object({\n    dataroom: dataroomEventSchema,\n  }),\n});\n\n// link.viewed\nexport const linkViewedWebhookSchema = z.object({\n  id: z.string().startsWith(\"evt_\"),\n  event: z.literal(\"link.viewed\"),\n  createdAt: z.string().datetime(),\n  data: z.object({\n    view: viewEventSchema,\n    link: linkEventSchema,\n    document: documentEventSchema.optional(),\n    dataroom: dataroomEventSchema.optional(),\n  }),\n});\n\nexport const webhookPayloadSchema = z.discriminatedUnion(\"event\", [\n  linkCreatedWebhookSchema,\n  documentCreatedWebhookSchema,\n  dataroomCreatedWebhookSchema,\n  linkViewedWebhookSchema,\n]);\n\nexport type WebhookPayload = z.infer<typeof webhookPayloadSchema>;\nexport type LinkCreatedWebhookPayload = z.infer<\n  typeof linkCreatedWebhookSchema\n>;\nexport type DocumentCreatedWebhookPayload = z.infer<\n  typeof documentCreatedWebhookSchema\n>;\nexport type DataroomCreatedWebhookPayload = z.infer<\n  typeof dataroomCreatedWebhookSchema\n>;\n\n// Schema of response sent to the webhook callback URL by QStash\nexport const webhookCallbackSchema = z.object({\n  status: z.number(),\n  url: z.string(),\n  createdAt: z.number(),\n  sourceMessageId: z.string(),\n  body: z.string().optional().default(\"\"), // Response from the original webhook URL\n  sourceBody: z.string(), // Original request payload from Papermark\n});\n"
  },
  {
    "path": "lib/zod/url-validation.ts",
    "content": "import { parsePageId } from \"notion-utils\";\nimport { z } from \"zod\";\n\nimport {\n  SUPPORTED_DOCUMENT_MIME_TYPES,\n  SUPPORTED_DOCUMENT_SIMPLE_TYPES,\n} from \"@/lib/constants\";\nimport {\n  getNotionPageIdFromSlug,\n  isCustomNotionDomain,\n} from \"@/lib/notion/utils\";\nimport { sanitizePlainText } from \"@/lib/utils/sanitize-html\";\nimport { getSupportedContentType } from \"@/lib/utils/get-content-type\";\n\n/**\n * Validates basic security aspects of paths and URLs\n * Prevents directory traversal, null byte injection, and double encoding attacks\n */\nexport const validatePathSecurity = (pathOrUrl: string): boolean => {\n  // Prevent directory traversal attacks\n  if (pathOrUrl.includes(\"../\") || pathOrUrl.includes(\"..\\\\\")) {\n    return false;\n  }\n\n  // Prevent null byte attacks\n  if (pathOrUrl.includes(\"\\0\")) {\n    return false;\n  }\n\n  // Prevent double encoding attacks\n  if (pathOrUrl.includes(\"%2E%2E\") || pathOrUrl.includes(\"%2F%2F\")) {\n    return false;\n  }\n\n  return true;\n};\n\n/**\n * Validates URL for SSRF protection\n * Blocks internal/private IP ranges, localhost, and link-local addresses\n */\nexport const validateUrlSSRFProtection = (url: string): boolean => {\n  try {\n    const urlObj = new URL(url);\n    const hostname = urlObj.hostname;\n\n    // Block localhost/loopback\n    if (\n      hostname === \"localhost\" ||\n      hostname === \"127.0.0.1\" ||\n      hostname === \"::1\"\n    ) {\n      return false;\n    }\n\n    // Block private IP ranges (simplified check)\n    if (hostname.match(/^10\\.|^172\\.(1[6-9]|2[0-9]|3[01])\\.|^192\\.168\\./)) {\n      return false;\n    }\n\n    // Block link-local addresses\n    if (hostname.match(/^169\\.254\\./)) {\n      return false;\n    }\n\n    // Block IPv6 link-local addresses (fe80::/10)\n    if (hostname.toLowerCase().startsWith(\"fe80:\")) {\n      return false;\n    }\n\n    return true;\n  } catch {\n    return false;\n  }\n};\n\n/**\n * Comprehensive security validation for URLs\n * Combines path security and SSRF protection\n */\nexport const validateUrlSecurity = (url: string): boolean => {\n  return validatePathSecurity(url) && validateUrlSSRFProtection(url);\n};\n\n// Custom validator for file paths - either Notion URLs or S3 storage paths\n// Note: General URLs for link type are validated at the documentUploadSchema level\nconst createFilePathValidator = () => {\n  return z\n    .string()\n    .min(1, \"File path is required\")\n    .refine(\n      async (path) => {\n        // Case 1: Notion URLs - must start with notion domains\n        if (path.startsWith(\"https://\")) {\n          try {\n            const urlObj = new URL(path);\n            const hostname = urlObj.hostname;\n\n            // Valid notion domains\n            const validNotionDomains = [\"www.notion.so\", \"notion.so\"];\n\n            // Check for notion.site subdomains (e.g., example-something.notion.site)\n            const isNotionSite = hostname.endsWith(\".notion.site\");\n            const isValidNotionDomain = validNotionDomains.includes(hostname);\n\n            // Check for vercel blob storage\n            let isVercelBlob = false;\n            if (process.env.VERCEL_BLOB_HOST) {\n              isVercelBlob = hostname.startsWith(process.env.VERCEL_BLOB_HOST);\n            }\n\n            // If it's not a standard Notion domain or Vercel blob, check if it's a custom Notion domain\n            if (!isNotionSite && !isValidNotionDomain && !isVercelBlob) {\n              try {\n                let pageId = parsePageId(path);\n                if (!pageId) {\n                  const pageIdFromSlug = await getNotionPageIdFromSlug(path);\n                  if (pageIdFromSlug) {\n                    pageId = pageIdFromSlug;\n                  }\n                }\n                return !!pageId;\n              } catch {\n                return false;\n              }\n            }\n\n            return isNotionSite || isValidNotionDomain || isVercelBlob;\n          } catch {\n            return false;\n          }\n        }\n\n        // Case 2: file storage paths - must match pattern: <id>/doc_<someId>/<name>.<ext>\n        const s3PathPattern =\n          /^[a-zA-Z0-9_-]+\\/doc_[a-zA-Z0-9_-]+\\/[a-zA-Z0-9_.-]+\\.[a-zA-Z0-9]+$/;\n        return s3PathPattern.test(path);\n      },\n      {\n        message:\n          \"File path must be either a Notion URL or an file storage path\",\n      },\n    )\n    .refine(\n      (path) => {\n        // Additional security checks for all paths\n        return validatePathSecurity(path);\n      },\n      {\n        message: \"File path contains invalid or malicious characters\",\n      },\n    );\n};\n\n// File path validation schema\nexport const filePathSchema = createFilePathValidator();\n\n// Dedicated Notion URL validation schema for URL updates\nexport const notionUrlUpdateSchema = z\n  .string()\n  .url(\"Invalid URL format\")\n  .refine((url) => url.startsWith(\"https://\"), {\n    message: \"Notion URL must use HTTPS\",\n  })\n  .refine(\n    async (url) => {\n      try {\n        const urlObj = new URL(url);\n        const hostname = urlObj.hostname;\n\n        // Valid notion domains\n        const validNotionDomains = [\"www.notion.so\", \"notion.so\"];\n        const isNotionSite = hostname.endsWith(\".notion.site\");\n        const isValidNotionDomain = validNotionDomains.includes(hostname);\n\n        // If it's a standard Notion domain, try to extract page ID\n        if (isNotionSite || isValidNotionDomain) {\n          let pageId = parsePageId(url);\n          if (!pageId) {\n            try {\n              const pageIdFromSlug = await getNotionPageIdFromSlug(url);\n              pageId = pageIdFromSlug || undefined;\n            } catch {\n              return false;\n            }\n          }\n          return !!pageId;\n        }\n\n        // For custom domains, try to extract and validate page ID\n        try {\n          let pageId = parsePageId(url);\n          if (!pageId) {\n            const pageIdFromSlug = await getNotionPageIdFromSlug(url);\n            pageId = pageIdFromSlug || undefined;\n          }\n          return !!pageId;\n        } catch {\n          return false;\n        }\n      } catch {\n        return false;\n      }\n    },\n    {\n      message:\n        \"Must be a valid Notion URL (supports notion.so, notion.site, and custom domains)\",\n    },\n  )\n  .refine(\n    (url) => {\n      // Additional security checks\n      return validatePathSecurity(url) && validateUrlSSRFProtection(url);\n    },\n    {\n      message: \"URL contains invalid characters or targets internal resources\",\n    },\n  );\n\n// Document upload validation schema with comprehensive type and content validation\nexport const documentUploadSchema = z\n  .object({\n    name: z\n      .string()\n      .max(10000, \"Document name too long\")\n      .transform((value) => sanitizePlainText(value))\n      .pipe(\n        z\n          .string()\n          .min(1, \"Document name is required\")\n          .max(255, \"Document name too long\"),\n      ),\n    url: z.string().min(1, \"URL is required\"), // Use string for now, will validate based on type\n    storageType: z\n      .enum([\"S3_PATH\", \"VERCEL_BLOB\"], {\n        errorMap: () => ({ message: \"Invalid storage type\" }),\n      })\n      .default(\"VERCEL_BLOB\")\n      .optional(),\n    numPages: z.number().int().positive().optional(),\n    type: z.enum(\n      SUPPORTED_DOCUMENT_SIMPLE_TYPES as unknown as readonly [\n        string,\n        ...string[],\n      ],\n      {\n        errorMap: () => ({\n          message: `File type must be one of: ${SUPPORTED_DOCUMENT_SIMPLE_TYPES.join(\", \")}`,\n        }),\n      },\n    ),\n    folderPathName: z.string().optional(),\n    contentType: z\n      .enum(\n        SUPPORTED_DOCUMENT_MIME_TYPES as unknown as readonly [\n          string,\n          ...string[],\n        ],\n        {\n          errorMap: () => ({ message: \"Unsupported content type\" }),\n        },\n      )\n      .or(z.literal(\"text/html\")) // Allow text/html for Notion documents\n      .nullish(), // Make contentType optional for Notion files and links\n    createLink: z.boolean().optional(),\n    fileSize: z.number().int().positive().optional(),\n  })\n  .refine(\n    async (data) => {\n      // For link type, validate URL format and security\n      if (data.type === \"link\") {\n        if (!data.url.startsWith(\"https://\")) {\n          return false;\n        }\n        try {\n          new URL(data.url); // Validate URL format\n          return validateUrlSecurity(data.url);\n        } catch {\n          return false;\n        }\n      }\n      // For other types, use the file path validator\n      return await filePathSchema.safeParseAsync(data.url).then((r) => r.success);\n    },\n    {\n      message: \"URL must be a valid HTTPS URL for link type, or a valid file path for other types\",\n      path: [\"url\"],\n    },\n  )\n  .refine(\n    (data) => {\n      // Skip content type validation if it's not provided (e.g., for Notion files or links)\n      if (!data.contentType) {\n        return data.type === \"notion\" || data.type === \"link\";\n      }\n\n      // For link type, content type is optional\n      if (data.type === \"link\") {\n        return true;\n      }\n\n      // Validate that content type matches the declared file type\n      const expectedType = getSupportedContentType(data.contentType);\n\n      // Special case for Notion documents\n      if (data.contentType === \"text/html\" && data.type === \"notion\") {\n        return true;\n      }\n\n      return expectedType === data.type;\n    },\n    {\n      message: \"Content type does not match the declared file type\",\n      path: [\"contentType\"], // This will highlight the contentType field in errors\n    },\n  )\n  .refine(\n    async (data) => {\n      // For link type, storage type is not required and URL validation is handled elsewhere\n      if (data.type === \"link\") {\n        return true;\n      }\n\n      // Skip storage type validation if not provided (e.g., for Notion files)\n      if (!data.storageType) {\n        // For Notion URLs, storage type is not required\n        if (data.url.startsWith(\"https://\")) {\n          try {\n            const urlObj = new URL(data.url);\n            const hostname = urlObj.hostname;\n            const isStandardNotion =\n              hostname === \"www.notion.so\" ||\n              hostname === \"notion.so\" ||\n              hostname.endsWith(\".notion.site\");\n\n            if (isStandardNotion) {\n              return true;\n            }\n\n            // Check if it's a custom Notion domain\n            try {\n              return await isCustomNotionDomain(data.url);\n            } catch {\n              return false;\n            }\n          } catch {\n            return false;\n          }\n        }\n        // For file paths without storage type, this is invalid\n        return false;\n      }\n\n      // Validate storage type consistency with path format\n      if (data.storageType === \"S3_PATH\") {\n        // S3_PATH should use file paths, not URLs\n        return !data.url.startsWith(\"https://\");\n      } else if (data.storageType === \"VERCEL_BLOB\") {\n        // VERCEL_BLOB can use either Notion URLs or S3 paths (for migration)\n        if (data.url.startsWith(\"https://\")) {\n          // Must be a Notion URL for VERCEL_BLOB\n          try {\n            const urlObj = new URL(data.url);\n            const hostname = urlObj.hostname;\n            const isStandardNotion =\n              hostname === \"www.notion.so\" ||\n              hostname === \"notion.so\" ||\n              hostname.endsWith(\".notion.site\");\n\n            if (isStandardNotion) {\n              return true;\n            }\n\n            // Check if it's a custom Notion domain\n            try {\n              return await isCustomNotionDomain(data.url);\n            } catch {\n              return false;\n            }\n          } catch {\n            return false;\n          }\n        }\n        // Or an S3 path (allowed for migration)\n        return /^[a-zA-Z0-9_-]+\\/doc_[a-zA-Z0-9_-]+\\/[a-zA-Z0-9_.-]+\\.[a-zA-Z0-9]+$/.test(\n          data.url,\n        );\n      }\n      return false;\n    },\n    {\n      message:\n        \"Storage type does not match the URL/path format, or missing storage type for non-Notion files\",\n      path: [\"storageType\"],\n    },\n  );\n\n// Link URL update validation schema\nexport const linkUrlUpdateSchema = z\n  .string()\n  .url(\"Invalid URL format\")\n  .refine((url) => url.startsWith(\"https://\"), {\n    message: \"Link URL must use HTTPS\",\n  })\n  .refine(\n    (url) => {\n      // Security validation including SSRF protection\n      return validateUrlSecurity(url);\n    },\n    {\n      message: \"URL contains invalid characters or targets internal resources\",\n    },\n  );\n\n// Webhook file URL validator - only allows trusted external sources for webhooks\nexport const webhookFileUrlSchema = z\n  .string()\n  .url(\"Invalid URL format\")\n  .refine(\n    (url) => {\n      // Must use HTTPS\n      return url.startsWith(\"https://\");\n    },\n    {\n      message: \"Webhook file URL must use HTTPS protocol\",\n    },\n  )\n  .refine(\n    (url) => {\n      // Comprehensive security validation including SSRF protection\n      return validateUrlSecurity(url);\n    },\n    {\n      message:\n        \"Webhook file URL contains invalid characters or targets internal resources\",\n    },\n  );\n"
  },
  {
    "path": "middleware.ts",
    "content": "import { NextFetchEvent, NextRequest, NextResponse } from \"next/server\";\n\nimport AppMiddleware from \"@/lib/middleware/app\";\nimport DomainMiddleware from \"@/lib/middleware/domain\";\n\nimport { BLOCKED_PATHNAMES } from \"./lib/constants\";\nimport IncomingWebhookMiddleware, {\n  isWebhookPath,\n} from \"./lib/middleware/incoming-webhooks\";\nimport PostHogMiddleware from \"./lib/middleware/posthog\";\n\nfunction isAnalyticsPath(path: string) {\n  // Create a regular expression\n  // ^ - asserts position at start of the line\n  // /ingest/ - matches the literal string \"/ingest/\"\n  // .* - matches any character (except for line terminators) 0 or more times\n  const pattern = /^\\/ingest\\/.*/;\n\n  return pattern.test(path);\n}\n\nfunction isCustomDomain(host: string) {\n  return (\n    (process.env.NODE_ENV === \"development\" &&\n      (host?.includes(\".local\") || host?.includes(\"papermark.dev\"))) ||\n    (process.env.NODE_ENV !== \"development\" &&\n      !(\n        host?.includes(\"localhost\") ||\n        host?.includes(\"papermark.io\") ||\n        host?.includes(\"papermark.com\") ||\n        host?.endsWith(\".vercel.app\")\n      ))\n  );\n}\n\nexport const config = {\n  matcher: [\n    /*\n     * Match all paths except for:\n     * 1. /api/ routes\n     * 2. /_next/ (Next.js internals)\n     * 3. /_static (inside /public)\n     * 4. /_vercel (Vercel internals)\n     * 5. /favicon.ico, /sitemap.xml, /robots.txt (static files)\n     */\n    \"/((?!api/|_next/|_static|vendor|_icons|_vercel|favicon.ico|sitemap.xml|robots.txt).*)\",\n  ],\n};\n\nexport default async function middleware(req: NextRequest, ev: NextFetchEvent) {\n  const path = req.nextUrl.pathname;\n  const host = req.headers.get(\"host\");\n\n  if (isAnalyticsPath(path)) {\n    return PostHogMiddleware(req);\n  }\n\n  // Handle incoming webhooks\n  if (isWebhookPath(host)) {\n    return IncomingWebhookMiddleware(req);\n  }\n\n  // For custom domains, we need to handle them differently\n  if (isCustomDomain(host || \"\")) {\n    return DomainMiddleware(req);\n  }\n\n  // Handle standard papermark.com paths\n  if (\n    !path.startsWith(\"/view/\") &&\n    !path.startsWith(\"/verify\") &&\n    !path.startsWith(\"/unsubscribe\") &&\n    !path.startsWith(\"/notification-preferences\") &&\n    !path.startsWith(\"/auth/email\")\n  ) {\n    return AppMiddleware(req);\n  }\n\n  // Check for blocked pathnames in view routes\n  if (\n    path.startsWith(\"/view/\") &&\n    (BLOCKED_PATHNAMES.some((blockedPath) => path.includes(blockedPath)) ||\n      path.includes(\".\"))\n  ) {\n    const url = req.nextUrl.clone();\n    url.pathname = \"/404\";\n    return NextResponse.rewrite(url, { status: 404 });\n  }\n\n  return NextResponse.next();\n}\n"
  },
  {
    "path": "next.config.mjs",
    "content": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  reactStrictMode: true,\n  pageExtensions: [\"js\", \"jsx\", \"ts\", \"tsx\", \"mdx\"],\n  transpilePackages: [\"@boxyhq/saml-jackson\"],\n  images: {\n    minimumCacheTTL: 2592000, // 30 days\n    remotePatterns: prepareRemotePatterns(),\n  },\n  skipTrailingSlashRedirect: true,\n  assetPrefix:\n    process.env.NODE_ENV === \"production\" &&\n    process.env.VERCEL_ENV === \"production\"\n      ? process.env.NEXT_PUBLIC_BASE_URL\n      : undefined,\n  async redirects() {\n    return [\n      {\n        source: \"/\",\n        destination: \"/dashboard\",\n        permanent: false,\n        has: [\n          {\n            type: \"host\",\n            value: process.env.NEXT_PUBLIC_APP_BASE_HOST,\n          },\n        ],\n      },\n      {\n        // temporary redirect set on 2025-10-22\n        source: \"/view/cmdn06aw00001ju04jgsf8h4f\",\n        destination: \"/view/cmh0uiv6t001mjm04sk10ecc8\",\n        permanent: false,\n      },\n      {\n        source: \"/settings\",\n        destination: \"/settings/general\",\n        permanent: false,\n      },\n    ];\n  },\n  async headers() {\n    const isDev = process.env.NODE_ENV === \"development\";\n\n    return [\n      {\n        // Default headers for all routes\n        source: \"/:path*\",\n        headers: [\n          {\n            key: \"Referrer-Policy\",\n            value: \"no-referrer-when-downgrade\",\n          },\n          {\n            key: \"X-DNS-Prefetch-Control\",\n            value: \"on\",\n          },\n          {\n            key: \"X-Frame-Options\",\n            value: \"SAMEORIGIN\",\n          },\n          {\n            key: \"Report-To\",\n            value: JSON.stringify({\n              group: \"csp-endpoint\",\n              max_age: 10886400,\n              endpoints: [{ url: \"/api/csp-report\" }],\n            }),\n          },\n          {\n            key: \"Content-Security-Policy-Report-Only\",\n            value:\n              `default-src 'self' https: ${isDev ? \"http:\" : \"\"}; ` +\n              `script-src 'self' 'unsafe-inline' 'unsafe-eval' https: ${isDev ? \"http:\" : \"\"}; ` +\n              `style-src 'self' 'unsafe-inline' https: ${isDev ? \"http:\" : \"\"}; ` +\n              `img-src 'self' data: blob: https: ${isDev ? \"http:\" : \"\"}; ` +\n              `font-src 'self' data: https: ${isDev ? \"http:\" : \"\"}; ` +\n              `frame-ancestors 'none'; ` +\n              `connect-src 'self' https: ${isDev ? \"http: ws: wss:\" : \"\"}; ` + // Add WebSocket for hot reload\n              `${isDev ? \"\" : \"upgrade-insecure-requests;\"} ` +\n              \"report-to csp-endpoint;\",\n          },\n        ],\n      },\n      {\n        source: \"/view/:path*\",\n        headers: [\n          {\n            key: \"X-Robots-Tag\",\n            value: \"noindex\",\n          },\n        ],\n      },\n      {\n        source: \"/login\",\n        has: [\n          {\n            type: \"query\",\n            key: \"next\",\n          },\n        ],\n        headers: [\n          {\n            key: \"X-Robots-Tag\",\n            value: \"noindex, nofollow\",\n          },\n        ],\n      },\n      {\n        // Embed routes - allow iframe embedding\n        source: \"/view/:path*/embed\",\n        headers: [\n          {\n            key: \"Content-Security-Policy\",\n            value:\n              `default-src 'self' https: ${isDev ? \"http:\" : \"\"}; ` +\n              `script-src 'self' 'unsafe-inline' 'unsafe-eval' https: ${isDev ? \"http:\" : \"\"}; ` +\n              `style-src 'self' 'unsafe-inline' https: ${isDev ? \"http:\" : \"\"}; ` +\n              `img-src 'self' data: blob: https: ${isDev ? \"http:\" : \"\"}; ` +\n              `font-src 'self' data: https: ${isDev ? \"http:\" : \"\"}; ` +\n              \"frame-ancestors *; \" + // This allows iframe embedding\n              `connect-src 'self' https: ${isDev ? \"http: ws: wss:\" : \"\"}; ` + // Add WebSocket for hot reload\n              `${isDev ? \"\" : \"upgrade-insecure-requests;\"}`,\n          },\n          {\n            key: \"X-Robots-Tag\",\n            value: \"noindex\",\n          },\n        ],\n      },\n      {\n        source: \"/services/:path*\",\n        has: [\n          {\n            type: \"host\",\n            value: process.env.NEXT_PUBLIC_WEBHOOK_BASE_HOST,\n          },\n        ],\n        headers: [\n          {\n            key: \"X-Robots-Tag\",\n            value: \"noindex\",\n          },\n        ],\n      },\n      {\n        source: \"/api/webhooks/services/:path*\",\n        headers: [\n          {\n            key: \"X-Robots-Tag\",\n            value: \"noindex\",\n          },\n        ],\n      },\n      {\n        source: \"/unsubscribe\",\n        headers: [\n          {\n            key: \"X-Robots-Tag\",\n            value: \"noindex\",\n          },\n        ],\n      },\n    ];\n  },\n  experimental: {\n    outputFileTracingIncludes: {\n      \"/api/mupdf/*\": [\"./node_modules/mupdf/dist/*.wasm\"],\n      // Jackson SAML routes need jose + openid-client for crypto\n      \"/api/auth/saml/token\": [\n        \"./node_modules/jose/**/*\",\n        \"./node_modules/openid-client/**/*\",\n      ],\n      \"/api/auth/saml/userinfo\": [\n        \"./node_modules/jose/**/*\",\n        \"./node_modules/openid-client/**/*\",\n      ],\n    },\n    missingSuspenseWithCSRBailout: false,\n  },\n  webpack: (config) => {\n    // Stub out @google-cloud/kms - it's an optional dependency of @libpdf/core\n    // that we don't use (only needed for KMS-based PDF encryption)\n    config.resolve.alias = {\n      ...config.resolve.alias,\n      \"@google-cloud/kms\": false,\n      \"@google-cloud/secret-manager\": false,\n      // Jackson pulls TypeORM/Mongo optional drivers we don't use (Postgres-only setup).\n      // Aliasing prevents noisy module resolution warnings in dev/build.\n      mysql: false,\n      \"react-native-sqlite-storage\": false,\n      aws4: false,\n      \"@sap/hana-client\": false,\n      \"@sap/hana-client/extension/Stream\": false,\n      \"hdb-pool\": false,\n    };\n\n    // Suppress critical dependency warnings from Jackson's dynamic requires\n    config.module = {\n      ...config.module,\n      exprContextCritical: false,\n    };\n\n    return config;\n  },\n};\n\nfunction prepareRemotePatterns() {\n  let patterns = [\n    // static images and videos\n    { protocol: \"https\", hostname: \"assets.papermark.io\" },\n    { protocol: \"https\", hostname: \"cdn.papermarkassets.com\" },\n    { protocol: \"https\", hostname: \"d2kgph70pw5d9n.cloudfront.net\" },\n    // twitter img\n    { protocol: \"https\", hostname: \"pbs.twimg.com\" },\n    // linkedin img\n    { protocol: \"https\", hostname: \"media.licdn.com\" },\n    // google img\n    { protocol: \"https\", hostname: \"lh3.googleusercontent.com\" },\n    // papermark img\n    { protocol: \"https\", hostname: \"www.papermark.io\" },\n    { protocol: \"https\", hostname: \"app.papermark.io\" },\n    { protocol: \"https\", hostname: \"www.papermark.com\" },\n    { protocol: \"https\", hostname: \"app.papermark.com\" },\n    // useragent img\n    { protocol: \"https\", hostname: \"faisalman.github.io\" },\n    // special document pages\n    { protocol: \"https\", hostname: \"d36r2enbzam0iu.cloudfront.net\" },\n    // us special storage\n    { protocol: \"https\", hostname: \"d35vw2hoyyl88.cloudfront.net\" },\n  ];\n\n  // Default region patterns\n  if (process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_HOST) {\n    patterns.push({\n      protocol: \"https\",\n      hostname: process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_HOST,\n    });\n  }\n\n  if (process.env.NEXT_PRIVATE_ADVANCED_UPLOAD_DISTRIBUTION_HOST) {\n    patterns.push({\n      protocol: \"https\",\n      hostname: process.env.NEXT_PRIVATE_ADVANCED_UPLOAD_DISTRIBUTION_HOST,\n    });\n  }\n\n  // US region patterns\n  if (process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_HOST_US) {\n    patterns.push({\n      protocol: \"https\",\n      hostname: process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_HOST_US,\n    });\n  }\n\n  if (process.env.NEXT_PRIVATE_ADVANCED_UPLOAD_DISTRIBUTION_HOST_US) {\n    patterns.push({\n      protocol: \"https\",\n      hostname: process.env.NEXT_PRIVATE_ADVANCED_UPLOAD_DISTRIBUTION_HOST_US,\n    });\n  }\n\n  if (process.env.VERCEL_ENV === \"production\") {\n    patterns.push({\n      // production vercel blob\n      protocol: \"https\",\n      hostname: \"yoywvlh29jppecbh.public.blob.vercel-storage.com\",\n    });\n  }\n\n  if (\n    process.env.VERCEL_ENV === \"preview\" ||\n    process.env.NODE_ENV === \"development\"\n  ) {\n    patterns.push({\n      // staging vercel blob\n      protocol: \"https\",\n      hostname: \"36so9a8uzykxknsu.public.blob.vercel-storage.com\",\n    });\n  }\n\n  return patterns;\n}\n\nexport default nextConfig;\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"papermark\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"engines\": {\n    \"node\": \">=22\"\n  },\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\",\n    \"postinstall\": \"prisma generate\",\n    \"vercel-build\": \"prisma migrate deploy && next build\",\n    \"email\": \"email dev --dir ./components/emails --port 3001\",\n    \"trigger:v3:dev\": \"npx trigger.dev@3 dev\",\n    \"trigger:v3:deploy\": \"npx trigger.dev@3 deploy\",\n    \"stripe:webhook\": \"pkgx stripe listen --forward-to localhost:3000/api/stripe/webhook\",\n    \"format\": \"prettier --write \\\"**/*.{js,jsx,ts,tsx,mdx}\\\"\",\n    \"dev:prisma\": \"npx prisma generate && npx prisma migrate deploy\"\n  },\n  \"dependencies\": {\n    \"@ai-sdk/google-vertex\": \"^4.0.79\",\n    \"@ai-sdk/openai\": \"^3.0.41\",\n    \"@ai-sdk/react\": \"^3.0.118\",\n    \"@aws-sdk/client-lambda\": \"^3.1002.0\",\n    \"@aws-sdk/client-s3\": \"^3.1002.0\",\n    \"@aws-sdk/cloudfront-signer\": \"^3.1001.0\",\n    \"@aws-sdk/lib-storage\": \"^3.1002.0\",\n    \"@aws-sdk/s3-request-presigner\": \"^3.1002.0\",\n    \"@boxyhq/saml-jackson\": \"1.52.2\",\n    \"@calcom/embed-react\": \"^1.5.3\",\n    \"@chronark/zod-bird\": \"^0.3.10\",\n    \"@dnd-kit/core\": \"^6.3.1\",\n    \"@dnd-kit/sortable\": \"^10.0.0\",\n    \"@dnd-kit/utilities\": \"^3.2.2\",\n    \"@github/webauthn-json\": \"^2.1.1\",\n    \"@hookform/resolvers\": \"^5.2.2\",\n    \"@jitsu/js\": \"^1.10.4\",\n    \"@libpdf/core\": \"^0.2.11\",\n    \"@next-auth/prisma-adapter\": \"^1.0.7\",\n    \"@next/third-parties\": \"^16.1.6\",\n    \"@pdf-lib/fontkit\": \"^1.1.1\",\n    \"@prisma/client\": \"6.5.0\",\n    \"@radix-ui/react-accordion\": \"^1.2.12\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-avatar\": \"^1.1.11\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-collapsible\": \"^1.1.12\",\n    \"@radix-ui/react-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-hover-card\": \"^1.1.15\",\n    \"@radix-ui/react-label\": \"^2.1.8\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-portal\": \"^1.1.10\",\n    \"@radix-ui/react-progress\": \"^1.1.8\",\n    \"@radix-ui/react-radio-group\": \"^1.3.8\",\n    \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n    \"@radix-ui/react-select\": \"^2.2.6\",\n    \"@radix-ui/react-separator\": \"^1.1.8\",\n    \"@radix-ui/react-slot\": \"^1.2.4\",\n    \"@radix-ui/react-switch\": \"^1.2.6\",\n    \"@radix-ui/react-tabs\": \"^1.1.13\",\n    \"@radix-ui/react-toggle\": \"^1.1.10\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.8\",\n    \"@radix-ui/react-use-controllable-state\": \"^1.2.2\",\n    \"@react-email/components\": \"^1.0.8\",\n    \"@react-email/render\": \"^2.0.4\",\n    \"@sindresorhus/slugify\": \"^3.0.0\",\n    \"@stripe/stripe-js\": \"^4.10.0\",\n    \"@tailwindcss/typography\": \"^0.5.19\",\n    \"@tanstack/react-table\": \"^8.21.3\",\n    \"@teamhanko/passkeys-next-auth-provider\": \"^0.3.1\",\n    \"@tiptap/extension-image\": \"^3.20.0\",\n    \"@tiptap/extension-placeholder\": \"^3.20.0\",\n    \"@tiptap/extension-youtube\": \"^3.20.0\",\n    \"@tiptap/react\": \"^3.20.0\",\n    \"@tiptap/starter-kit\": \"^3.20.0\",\n    \"@tremor/react\": \"^3.18.7\",\n    \"@trigger.dev/python\": \"^3.3.17\",\n    \"@trigger.dev/react-hooks\": \"^3.3.17\",\n    \"@trigger.dev/sdk\": \"^3.3.17\",\n    \"@tus/s3-store\": \"^1.9.1\",\n    \"@tus/server\": \"^1.10.2\",\n    \"@tus/utils\": \"^0.5.1\",\n    \"@upstash/qstash\": \"^2.9.0\",\n    \"@upstash/ratelimit\": \"^2.0.8\",\n    \"@upstash/redis\": \"^1.36.3\",\n    \"@vercel/blob\": \"^2.0.1\",\n    \"@vercel/edge-config\": \"^1.4.3\",\n    \"@vercel/functions\": \"^3.4.3\",\n    \"ai\": \"^6.0.116\",\n    \"autoprefixer\": \"^10.4.27\",\n    \"base-x\": \"^5.0.1\",\n    \"bcryptjs\": \"^3.0.3\",\n    \"bottleneck\": \"^2.19.5\",\n    \"chrono-node\": \"^2.9.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"date-fns\": \"^3.6.0\",\n    \"dub\": \"^0.71.5\",\n    \"embla-carousel-react\": \"^8.6.0\",\n    \"eslint\": \"8.57.0\",\n    \"eslint-config-next\": \"^14.2.35\",\n    \"exceljs\": \"^4.4.0\",\n    \"fluent-ffmpeg\": \"^2.1.3\",\n    \"html2canvas\": \"^1.4.1\",\n    \"input-otp\": \"^1.4.2\",\n    \"js-cookie\": \"^3.0.5\",\n    \"jsonwebtoken\": \"^9.0.3\",\n    \"lucide-react\": \"^0.577.0\",\n    \"mime-types\": \"^3.0.2\",\n    \"motion\": \"^12.35.0\",\n    \"ms\": \"^2.1.3\",\n    \"mupdf\": \"^1.27.0\",\n    \"nanoid\": \"^5.1.6\",\n    \"next\": \"^14.2.35\",\n    \"next-auth\": \"^4.24.13\",\n    \"next-themes\": \"^0.4.6\",\n    \"nodemailer\": \"^7.0.13\",\n    \"notion-client\": \"^7.7.3\",\n    \"notion-utils\": \"^7.7.3\",\n    \"nuqs\": \"^2.8.9\",\n    \"openai\": \"^6.25.0\",\n    \"pdf-lib\": \"^1.17.1\",\n    \"postcss\": \"^8.5.8\",\n    \"posthog-js\": \"^1.358.1\",\n    \"react\": \"^18.3.1\",\n    \"react-colorful\": \"^5.6.1\",\n    \"react-day-picker\": \"^8.10.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"react-draggable\": \"^4.5.0\",\n    \"react-dropzone\": \"^14.4.1\",\n    \"react-email\": \"^5.2.9\",\n    \"react-hook-form\": \"^7.71.2\",\n    \"react-hotkeys-hook\": \"^5.2.4\",\n    \"react-intersection-observer\": \"^9.16.0\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-notion-x\": \"^7.7.3\",\n    \"react-pdf\": \"^8.0.2\",\n    \"react-phone-number-input\": \"^3.4.16\",\n    \"react-resizable-panels\": \"^3.0.6\",\n    \"react-textarea-autosize\": \"^8.5.9\",\n    \"react-zoom-pan-pinch\": \"^3.7.0\",\n    \"resend\": \"^6.9.3\",\n    \"sanitize-html\": \"^2.17.1\",\n    \"shiki\": \"^3.23.0\",\n    \"sonner\": \"^2.0.7\",\n    \"streamdown\": \"^2.3.0\",\n    \"stripe\": \"^16.12.0\",\n    \"swr\": \"^2.4.1\",\n    \"tailwind-merge\": \"^2.6.1\",\n    \"tailwind-scrollbar-hide\": \"^2.0.0\",\n    \"tailwindcss\": \"^3.4.19\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"tokenlens\": \"^1.3.1\",\n    \"transliteration\": \"^2.6.1\",\n    \"ts-pattern\": \"^5.9.0\",\n    \"tus-js-client\": \"^4.3.1\",\n    \"ua-parser-js\": \"^1.0.41\",\n    \"unsend\": \"^1.5.1\",\n    \"use-debounce\": \"^10.1.0\",\n    \"use-stick-to-bottom\": \"^1.1.3\",\n    \"vaul\": \"^1.1.2\",\n    \"xlsx\": \"https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz\",\n    \"zod\": \"^3.25.76\"\n  },\n  \"devDependencies\": {\n    \"@react-email/preview-server\": \"^5.2.9\",\n    \"@tailwindcss/forms\": \"^0.5.11\",\n    \"@trigger.dev/build\": \"^3.3.17\",\n    \"@trivago/prettier-plugin-sort-imports\": \"^6.0.2\",\n    \"@types/bcryptjs\": \"^2.4.6\",\n    \"@types/cookie\": \"^0.6.0\",\n    \"@types/fluent-ffmpeg\": \"^2.1.27\",\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"@types/jsonwebtoken\": \"^9.0.7\",\n    \"@types/mime-types\": \"^3.0.1\",\n    \"@types/ms\": \"^2.1.0\",\n    \"@types/node\": \"^22.13.5\",\n    \"@types/react\": \"^18\",\n    \"@types/react-dom\": \"^18\",\n    \"@types/sanitize-html\": \"^2.13.0\",\n    \"@types/ua-parser-js\": \"^0.7.39\",\n    \"patch-package\": \"^8.0.1\",\n    \"prettier\": \"^3.8.1\",\n    \"prettier-plugin-tailwindcss\": \"^0.7.2\",\n    \"prisma\": \"6.5.0\",\n    \"typescript\": \"^5\"\n  },\n  \"overrides\": {\n    \"react-notion-x\": {\n      \"react-pdf\": \"8.0.2\"\n    },\n    \"@boxyhq/saml-jackson\": {\n      \"fast-xml-parser\": \"4.5.4\",\n      \"node-forge\": \"1.3.3\",\n      \"axios\": \"1.13.6\"\n    },\n    \"tar\": \"7.5.10\"\n  }\n}\n"
  },
  {
    "path": "pages/404.tsx",
    "content": "import Link from \"next/link\";\n\nexport default function NotFound({ message }: { message?: string }) {\n  return (\n    <>\n      <div className=\"flex min-h-screen flex-col pb-12 pt-16\">\n        <main className=\"mx-auto flex w-full max-w-7xl flex-grow flex-col justify-center px-4 sm:px-6 lg:px-8\">\n          <div className=\"py-16\">\n            <div className=\"text-center\">\n              <p className=\"text-sm font-semibold uppercase tracking-wide text-indigo-600\">\n                404 error\n              </p>\n              <h1 className=\"mt-2 text-4xl font-extrabold tracking-tight text-slate-950 dark:text-gray-100 sm:text-5xl\">\n                Page not found.\n              </h1>\n              <p className=\"mt-2 text-base text-gray-600\">\n                {message ||\n                  \"Sorry, we couldn’t find the page you’re looking for.\"}\n              </p>\n              <div className=\"mt-6\">\n                <Link\n                  href=\"/\"\n                  className=\"text-base font-medium text-indigo-600 hover:text-indigo-500\"\n                >\n                  Go back home <span aria-hidden=\"true\"> &rarr;</span>\n                </Link>\n              </div>\n            </div>\n          </div>\n        </main>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "pages/_app.tsx",
    "content": "import type { AppProps } from \"next/app\";\nimport { Inter } from \"next/font/google\";\nimport Head from \"next/head\";\n\nimport { TeamProvider } from \"@/context/team-context\";\nimport type { Session } from \"next-auth\";\nimport { SessionProvider } from \"next-auth/react\";\nimport { NuqsAdapter } from \"nuqs/adapters/next/pages\";\n\nimport { EXCLUDED_PATHS } from \"@/lib/constants\";\n\nimport { PostHogCustomProvider } from \"@/components/providers/posthog-provider\";\nimport { DealflowPopup } from \"@/components/shared/dealflow-popup\";\nimport { ThemeProvider } from \"@/components/theme-provider\";\nimport { Toaster } from \"@/components/ui/sonner\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\n\nimport \"@/styles/globals.css\";\n\nconst inter = Inter({ subsets: [\"latin\"] });\n\nexport default function App({\n  Component,\n  pageProps: { session, ...pageProps },\n  router,\n}: AppProps<{ session: Session }>) {\n  return (\n    <>\n      <Head>\n        <title>Papermark | The Open Source DocSend Alternative</title>\n        <meta name=\"theme-color\" content=\"#000000\" />\n        <meta\n          name=\"description\"\n          content=\"Papermark is an open-source document sharing alternative to DocSend with built-in analytics.\"\n          key=\"description\"\n        />\n        <meta\n          property=\"og:title\"\n          content=\"Papermark | The Open Source DocSend Alternative\"\n          key=\"og-title\"\n        />\n        <meta\n          property=\"og:description\"\n          content=\"Papermark is an open-source document sharing alternative to DocSend with built-in analytics.\"\n          key=\"og-description\"\n        />\n        <meta\n          property=\"og:image\"\n          content=\"https://www.papermark.com/_static/meta-image.png\"\n          key=\"og-image\"\n        />\n        <meta\n          property=\"og:url\"\n          content=\"https://www.papermark.com\"\n          key=\"og-url\"\n        />\n        <meta property=\"og:type\" content=\"website\" />\n        <meta name=\"twitter:card\" content=\"summary_large_image\" />\n        <meta name=\"twitter:site\" content=\"@papermarkio\" />\n        <meta name=\"twitter:creator\" content=\"@papermarkio\" />\n        <meta name=\"twitter:title\" content=\"Papermark\" key=\"tw-title\" />\n        <meta\n          name=\"twitter:description\"\n          content=\"Papermark is an open-source document sharing alternative to DocSend with built-in analytics.\"\n          key=\"tw-description\"\n        />\n        <meta\n          name=\"twitter:image\"\n          content=\"https://www.papermark.com/_static/meta-image.png\"\n          key=\"tw-image\"\n        />\n        <link rel=\"icon\" href=\"/favicon.ico\" key=\"favicon\" />\n      </Head>\n      <SessionProvider session={session}>\n        <PostHogCustomProvider>\n          <ThemeProvider attribute=\"class\" defaultTheme=\"light\" enableSystem>\n            <NuqsAdapter>\n              <main className={inter.className}>\n                <Toaster closeButton />\n                <TooltipProvider delayDuration={100}>\n                  {EXCLUDED_PATHS.includes(router.pathname) ? (\n                    <Component {...pageProps} />\n                  ) : (\n                    <TeamProvider>\n                      <Component {...pageProps} />\n                      <DealflowPopup />\n                    </TeamProvider>\n                  )}\n                </TooltipProvider>\n              </main>\n            </NuqsAdapter>\n          </ThemeProvider>\n        </PostHogCustomProvider>\n      </SessionProvider>\n    </>\n  );\n}\n"
  },
  {
    "path": "pages/_document.tsx",
    "content": "import { Head, Html, Main, NextScript } from \"next/document\";\n\nexport default function Document() {\n  return (\n    <Html lang=\"en\" className=\"bg-background\" suppressHydrationWarning>\n      <Head />\n      <body className=\"\">\n        <Main />\n        <NextScript />\n      </body>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "pages/account/general.tsx",
    "content": "import { NextPage } from \"next\";\n\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\n\nimport { AccountHeader } from \"@/components/account/account-header\";\nimport { UpdateMailSubscribe } from \"@/components/account/update-subscription\";\nimport UploadAvatar from \"@/components/account/upload-avatar\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Form } from \"@/components/ui/form\";\n\nimport { validateEmail } from \"@/lib/utils/validate-email\";\n\nconst ProfilePage: NextPage = () => {\n  const { data: session, update } = useSession();\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <AccountHeader />\n        <div className=\"space-y-6\">\n          <Form\n            title=\"Your Name\"\n            description=\"This will be your display name on Papermark.\"\n            inputAttrs={{\n              name: \"name\",\n              placeholder: \"Dino Hems\",\n              maxLength: 32,\n            }}\n            defaultValue={session?.user?.name ?? \"\"}\n            helpText=\"Max 32 characters.\"\n            handleSubmit={(data) =>\n              fetch(\"/api/account\", {\n                method: \"PATCH\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify(data),\n              }).then(async (res) => {\n                if (res.status === 200) {\n                  update();\n                  toast.success(\"Successfully updated your name!\");\n                } else {\n                  const { error } = await res.json();\n                  toast.error(error?.message);\n                }\n              })\n            }\n          />\n          <Form\n            title=\"Your Email\"\n            description=\"This will be the email you use to log in to Papermark and receive notifications. A confirmation is required for changes.\"\n            inputAttrs={{\n              name: \"email\",\n              placeholder: \"name@example.com\",\n              maxLength: 52,\n              type: \"email\",\n            }}\n            defaultValue={session?.user?.email ?? \"\"}\n            validate={validateEmail}\n            helpText={<UpdateMailSubscribe />}\n            handleSubmit={(data) =>\n              fetch(\"/api/account\", {\n                method: \"PATCH\",\n                headers: {\n                  \"Content-Type\": \"application/json\",\n                },\n                body: JSON.stringify(data),\n              }).then(async (res) => {\n                if (res.status === 200) {\n                  toast.success(\n                    `A confirmation email has been sent to ${session?.user?.email}.`,\n                  );\n                } else {\n                  const { error } = await res.json();\n                  toast.error(error);\n                }\n              })\n            }\n          />\n          <UploadAvatar\n            title=\"Your Avatar\"\n            description=\"This is your avatar image on Papermark.\"\n            helpText=\"Square image recommended. Accepted file types: .png, .jpg. Max file\n          size: 2MB.\"\n          />\n        </div>\n      </main>\n    </AppLayout>\n  );\n};\n\nexport default ProfilePage;\n"
  },
  {
    "path": "pages/account/security.tsx",
    "content": "import { NextPage } from \"next\";\n\nimport { useState } from \"react\";\n\nimport {\n  type CredentialCreationOptionsJSON,\n  create,\n} from \"@github/webauthn-json\";\nimport { Trash2 } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { usePasskeys } from \"@/lib/swr/use-passkeys\";\n\nimport { AccountHeader } from \"@/components/account/account-header\";\nimport AppLayout from \"@/components/layouts/app\";\nimport Passkey from \"@/components/shared/icons/passkey\";\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n  AlertDialogTrigger,\n} from \"@/components/ui/alert-dialog\";\nimport { Button } from \"@/components/ui/button\";\n\nconst ProfilePage: NextPage = () => {\n  const [isLoading, setIsLoading] = useState(false);\n  const { passkeys, loading: isLoadingPasskeys, mutate } = usePasskeys();\n\n  async function registerPasskey() {\n    setIsLoading(true);\n    const createOptionsResponse = await fetch(\"/api/passkeys/register\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ start: true, finish: false, credential: null }),\n    });\n\n    const { createOptions } = await createOptionsResponse.json();\n\n    // Open \"register passkey\" dialog\n    const credential = await create(\n      createOptions as CredentialCreationOptionsJSON,\n    );\n\n    const response = await fetch(\"/api/passkeys/register\", {\n      method: \"POST\",\n      headers: { \"Content-Type\": \"application/json\" },\n      body: JSON.stringify({ start: false, finish: true, credential }),\n    });\n\n    if (response.ok) {\n      toast.success(\"Registered passkey successfully!\");\n      mutate(); // Refresh the list\n      setIsLoading(false);\n      return;\n    }\n    // Now the user has registered their passkey and can use it to log in.\n    setIsLoading(false);\n  }\n\n  async function removePasskey(credentialId: string) {\n    try {\n      const response = await fetch(\"/api/account/passkeys\", {\n        method: \"DELETE\",\n        headers: { \"Content-Type\": \"application/json\" },\n        body: JSON.stringify({ credentialId }),\n      });\n\n      if (response.ok) {\n        toast.success(\"Passkey removed successfully!\");\n        mutate(); // Refresh the list\n      } else {\n        toast.error(\"Failed to remove passkey\");\n      }\n    } catch (error) {\n      console.error(\"Error removing passkey:\", error);\n      toast.error(\"Failed to remove passkey\");\n    }\n  }\n\n  function formatDate(dateString: string) {\n    return new Date(dateString).toLocaleDateString(\"en-US\", {\n      year: \"numeric\",\n      month: \"short\",\n      day: \"numeric\",\n      hour: \"2-digit\",\n      minute: \"2-digit\",\n    });\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <AccountHeader />\n        <div className=\"space-y-6\">\n          {/* Register Passkey Section */}\n          <div className=\"rounded-lg border border-muted p-10\">\n            <div className=\"space-y-6\">\n              <div className=\"space-y-3\">\n                <h2 className=\"text-xl font-medium\">Register a passkey</h2>\n                <p className=\"mt-3 text-sm text-muted-foreground\">\n                  Never use a password or oauth again. Register a passkey to\n                  make logging in easy.\n                </p>\n              </div>\n              <Button\n                onClick={() => registerPasskey()}\n                className=\"flex items-center justify-center space-x-2\"\n                disabled={isLoading}\n              >\n                <Passkey className=\"h-4 w-4\" />\n                <span>Register a new passkey</span>\n              </Button>\n            </div>\n          </div>\n\n          {/* Existing Passkeys Section */}\n          <div className=\"rounded-lg border border-muted p-10\">\n            <div className=\"space-y-6\">\n              <div className=\"space-y-3\">\n                <h2 className=\"text-xl font-medium\">Your passkeys</h2>\n                <p className=\"mt-3 text-sm text-muted-foreground\">\n                  Manage your registered passkeys. You can remove passkeys you\n                  no longer use.\n                </p>\n              </div>\n\n              {isLoadingPasskeys ? (\n                <div className=\"flex items-center justify-center py-8\">\n                  <div className=\"text-sm text-muted-foreground\">\n                    Loading passkeys...\n                  </div>\n                </div>\n              ) : passkeys.length === 0 ? (\n                <div className=\"flex items-center justify-center py-8\">\n                  <div className=\"text-sm text-muted-foreground\">\n                    No passkeys registered yet.\n                  </div>\n                </div>\n              ) : (\n                <div className=\"space-y-4\">\n                  {passkeys.map((passkey) => (\n                    <div\n                      key={passkey.id}\n                      className=\"flex items-center justify-between rounded-lg border p-4\"\n                    >\n                      <div className=\"flex items-center space-x-4\">\n                        <Passkey className=\"h-5 w-5 text-muted-foreground\" />\n                        <div className=\"space-y-1\">\n                          <div className=\"text-sm font-medium\">\n                            {passkey.name || \"Unnamed Passkey\"}\n                          </div>\n                          <div className=\"text-xs text-muted-foreground\">\n                            Created: {formatDate(passkey.created_at)}\n                            {passkey.last_used_at && (\n                              <span className=\"ml-4\">\n                                Last used: {formatDate(passkey.last_used_at)}\n                              </span>\n                            )}\n                          </div>\n                          {passkey.transports &&\n                            passkey.transports.length > 0 && (\n                              <div className=\"text-xs text-muted-foreground\">\n                                Transports: {passkey.transports.join(\", \")}\n                              </div>\n                            )}\n                        </div>\n                      </div>\n                      <AlertDialog>\n                        <AlertDialogTrigger asChild>\n                          <Button\n                            variant=\"ghost\"\n                            size=\"sm\"\n                            className=\"text-destructive hover:text-destructive\"\n                          >\n                            <Trash2 className=\"h-4 w-4\" />\n                          </Button>\n                        </AlertDialogTrigger>\n                        <AlertDialogContent>\n                          <AlertDialogHeader>\n                            <AlertDialogTitle>Remove passkey</AlertDialogTitle>\n                            <AlertDialogDescription>\n                              Are you sure you want to remove this passkey? This\n                              action cannot be undone. You will need to register\n                              a new passkey to continue using passwordless\n                              authentication.\n                            </AlertDialogDescription>\n                          </AlertDialogHeader>\n                          <AlertDialogFooter>\n                            <AlertDialogCancel>Cancel</AlertDialogCancel>\n                            <AlertDialogAction\n                              onClick={() => removePasskey(passkey.id)}\n                              className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n                            >\n                              Remove\n                            </AlertDialogAction>\n                          </AlertDialogFooter>\n                        </AlertDialogContent>\n                      </AlertDialog>\n                    </div>\n                  ))}\n                </div>\n              )}\n            </div>\n          </div>\n        </div>\n      </main>\n    </AppLayout>\n  );\n};\n\nexport default ProfilePage;\n"
  },
  {
    "path": "pages/api/account/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { waitUntil } from \"@vercel/functions\";\nimport { randomBytes } from \"crypto\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport { sendEmailChangeVerificationRequestEmail } from \"@/lib/emails/send-mail-verification\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit, redis } from \"@/lib/redis\";\nimport { CustomUser } from \"@/lib/types\";\nimport { trim } from \"@/lib/utils\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nconst updateUserSchema = z.object({\n  name: z.preprocess(trim, z.string().min(1).max(64)).optional(),\n  email: z.preprocess(trim, z.string().email()).optional(),\n  image: z.string().url().optional(),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // POST /api/account\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      throw new Error(\"Unauthorized\");\n    }\n    const sessionUser = session.user as CustomUser;\n    const { email, image, name } = await updateUserSchema.parseAsync(\n      await req.body,\n    );\n\n    try {\n      if (email && email !== sessionUser.email) {\n        const userWithEmail = await prisma.user.findUnique({\n          where: {\n            email,\n          },\n        });\n        if (userWithEmail) {\n          throw new Error(\"Email is already in use.\");\n        }\n        const { success } = await ratelimit(6, \"6 h\").limit(\n          `email-change-request:${sessionUser.id}`,\n        );\n        if (!success) {\n          throw new Error(\n            \"You've requested too many email change requests. Please try again later.\",\n          );\n        }\n        const token = randomBytes(32).toString(\"hex\");\n        const expiresIn = 15 * 60 * 1000;\n\n        await prisma.verificationToken.create({\n          data: {\n            identifier: sessionUser.id,\n            token: hashToken(token),\n            expires: new Date(Date.now() + expiresIn),\n          },\n        });\n\n        await redis.set(\n          `email-change-request:user:${sessionUser.id}`,\n          {\n            email: sessionUser.email,\n            newEmail: email,\n          },\n          {\n            px: expiresIn,\n          },\n        );\n\n        waitUntil(\n          sendEmailChangeVerificationRequestEmail({\n            email: sessionUser.email as string,\n            newEmail: email,\n            url: `${process.env.NEXTAUTH_URL}/auth/confirm-email-change/${token}`,\n          }),\n        );\n\n        return res.status(200).json({ message: \"success\" });\n      }\n\n      const response = await prisma.user.update({\n        where: {\n          id: sessionUser.id,\n        },\n        data: {\n          ...(name && { name }),\n          ...(image && { image }),\n        },\n      });\n\n      return res.status(200).json({ message: \"success\" });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/account/passkeys.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { listUserPasskeys, removeUserPasskey } from \"@/lib/api/auth/passkey\";\nimport { errorhandler } from \"@/lib/errorHandler\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  try {\n    if (req.method === \"GET\") {\n      // List passkeys\n      const passkeys = await listUserPasskeys({ session });\n      res.status(200).json({ passkeys });\n      return;\n    }\n\n    if (req.method === \"DELETE\") {\n      // Remove passkey\n      const { credentialId } = req.body as { credentialId: string };\n\n      if (!credentialId) {\n        return res.status(400).json({ error: \"Credential ID is required\" });\n      }\n\n      await removeUserPasskey({ credentialId, session });\n      res.status(204).end();\n      return;\n    }\n\n    return res.status(405).json({ error: \"Method not allowed\" });\n  } catch (error) {\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/analytics/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { addDays } from \"date-fns\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport {\n  getTotalDocumentDuration,\n  getTotalLinkDuration,\n  getTotalViewerDuration,\n  getViewPageDuration,\n} from \"@/lib/tinybird/pipes\";\nimport { CustomUser } from \"@/lib/types\";\nimport { durationFormat } from \"@/lib/utils\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nconst analyticsQuerySchema = z.object({\n  interval: z.enum([\"24h\", \"7d\", \"30d\", \"custom\"]),\n  type: z.enum([\"overview\", \"links\", \"documents\", \"visitors\", \"views\"]),\n  teamId: z.string(),\n  startDate: z.string().optional(),\n  endDate: z.string().optional(),\n});\n\nconst INTERVALS = {\n  \"24h\": 24 * 60 * 60 * 1000, // 24 hours in ms\n  \"7d\": 7 * 24 * 60 * 60 * 1000, // 7 days in ms\n  \"30d\": 30 * 24 * 60 * 60 * 1000, // 30 days in ms\n} as const;\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  try {\n    const result = analyticsQuerySchema.safeParse(req.query);\n\n    if (!result.success) {\n      return res\n        .status(400)\n        .json({ error: `Invalid body: ${result.error.message}` });\n    }\n\n    const {\n      interval,\n      type,\n      teamId,\n      startDate: startStr,\n      endDate: endStr,\n    } = result.data;\n\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: (session.user as CustomUser).id,\n          },\n        },\n      },\n      select: {\n        id: true,\n        plan: true,\n        pauseStartsAt: true,\n        timezone: true,\n      },\n    });\n\n    if (!team) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    // Get pause date filter if team is paused\n    const pauseStartsAt = team.pauseStartsAt;\n    // Get team timezone for analytics display (defaults to UTC)\n    const timezone = team.timezone || \"Etc/UTC\";\n\n    // Check if free plan user is trying to access data beyond 30 days\n    if (interval === \"custom\" && team.plan.includes(\"free\")) {\n      const thirtyDaysAgo = new Date();\n      thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);\n      thirtyDaysAgo.setHours(0, 0, 0, 0);\n\n      // For custom range, check the provided start date\n      if (startStr && new Date(startStr) < thirtyDaysAgo) {\n        return res.status(401).json({\n          error: \"Free plan users can only access data from the last 30 days\",\n        });\n      }\n    }\n\n    // get the start date for the interval\n    const now = new Date();\n    let startDate: Date;\n    let endDate: Date = now;\n\n    switch (interval) {\n      case \"24h\":\n        // Get start of the hour 23 hours ago plus current hour = 24h\n        startDate = new Date(now);\n        startDate.setHours(startDate.getHours() - 23);\n        startDate.setMinutes(0, 0, 0);\n        break;\n      case \"7d\":\n        // Get start of the day 6 days ago plus current day = 7d\n        startDate = new Date(now);\n        startDate.setDate(startDate.getDate() - 6);\n        startDate.setHours(0, 0, 0, 0);\n        break;\n      case \"30d\":\n        // Get start of the day 29 days ago plus current day = 30d\n        startDate = new Date(now);\n        startDate.setDate(startDate.getDate() - 29);\n        startDate.setHours(0, 0, 0, 0);\n        break;\n      case \"custom\":\n        startDate = new Date(startStr || addDays(new Date(), -6));\n        startDate.setHours(0, 0, 0, 0);\n        endDate = new Date(endStr || now);\n\n        if (startDate > endDate) {\n          return res\n            .status(400)\n            .json({ error: \"The 'From' date must be before the 'To' date.\" });\n        }\n        break;\n      default:\n        startDate = new Date(now);\n        startDate.setDate(startDate.getDate() - 6);\n    }\n\n    // Create the interval filter for the query\n    const intervalFilter: any = { gte: startDate, lte: endDate };\n\n    let since: number;\n\n    if (interval === \"custom\") {\n      const startTimestamp = startStr ? new Date(startStr).getTime() : NaN;\n\n      if (isNaN(startTimestamp)) {\n        since = Date.now();\n      } else {\n        since = startTimestamp;\n      }\n    } else {\n      since = Date.now() - INTERVALS[interval];\n    }\n\n    switch (type) {\n      case \"overview\": {\n        const [viewStats, graphData] = await Promise.all([\n          // Get view stats with relational counts\n          prisma.view.findMany({\n            where: {\n              teamId,\n              viewedAt: intervalFilter,\n              isArchived: false,\n              viewType: \"DOCUMENT_VIEW\",\n            },\n            select: {\n              id: true,\n              viewerEmail: true,\n              linkId: true,\n              documentId: true,\n              viewerId: true,\n            },\n          }),\n          // Get views data for graph grouped by day\n          // Note: We use timezone-aware date truncation to ensure dates are grouped\n          // correctly based on the team's timezone setting, avoiding one-day offset issues\n          interval === \"24h\"\n            ? prisma.$queryRaw`\n                SELECT \n                  DATE_TRUNC('hour', \"viewedAt\" AT TIME ZONE 'UTC' AT TIME ZONE ${timezone}) as date,\n                  COUNT(*) as views\n                FROM \"View\"\n                WHERE \n                  \"teamId\" = ${teamId}\n                  AND \"viewedAt\" >= ${startDate}\n                  AND \"isArchived\" = false\n                  AND \"viewType\" = 'DOCUMENT_VIEW'\n                GROUP BY 1\n                ORDER BY date ASC\n              `\n            : interval === \"custom\"\n              ? prisma.$queryRaw`\n                SELECT \n                  DATE_TRUNC('day', \"viewedAt\" AT TIME ZONE 'UTC' AT TIME ZONE ${timezone}) as date,\n                  COUNT(*) as views\n                FROM \"View\"\n                WHERE \n                  \"teamId\" = ${teamId}\n                  AND \"viewedAt\" >= ${startDate}\n                  AND \"viewedAt\" <= ${endDate}\n                  AND \"isArchived\" = false\n                  AND \"viewType\" = 'DOCUMENT_VIEW'\n                GROUP BY 1\n                ORDER BY date ASC\n              `\n              : prisma.$queryRaw`\n                SELECT \n                  DATE_TRUNC('day', \"viewedAt\" AT TIME ZONE 'UTC' AT TIME ZONE ${timezone}) as date,\n                  COUNT(*) as views\n                FROM \"View\"\n                WHERE \n                  \"teamId\" = ${teamId}\n                  AND \"viewedAt\" >= ${startDate}\n                  AND \"isArchived\" = false\n                  AND \"viewType\" = 'DOCUMENT_VIEW'\n                GROUP BY 1\n                ORDER BY date ASC\n              `,\n        ]);\n\n        // Calculate counts from viewStats\n        const uniqueLinks = new Set(viewStats.map((view) => view.linkId));\n        const uniqueDocuments = new Set(\n          viewStats.map((view) => view.documentId),\n        );\n        const uniqueVisitors = new Set(viewStats.map((view) => view.viewerId));\n\n        return res.status(200).json({\n          counts: {\n            links: uniqueLinks.size,\n            documents: uniqueDocuments.size,\n            visitors: uniqueVisitors.size,\n            views: viewStats.length,\n          },\n          graph: (graphData as { date: Date; views: bigint }[]).map(\n            (point) => ({\n              date: point.date,\n              views: Number(point.views),\n            }),\n          ),\n          timezone, // Include timezone for frontend reference\n        });\n      }\n\n      case \"links\": {\n        const links = await prisma.link.findMany({\n          where: {\n            teamId,\n            views: {\n              some: {\n                viewedAt: intervalFilter,\n                viewType: \"DOCUMENT_VIEW\",\n                isArchived: false,\n              },\n            },\n            deletedAt: null,\n          },\n          select: {\n            id: true,\n            name: true,\n            slug: true,\n            domainSlug: true,\n            domainId: true,\n            documentId: true,\n            _count: {\n              select: {\n                views: {\n                  where: {\n                    viewedAt: intervalFilter,\n                    viewType: \"DOCUMENT_VIEW\",\n                    isArchived: false,\n                  },\n                },\n              },\n            },\n            views: {\n              where: {\n                viewedAt: intervalFilter,\n                viewType: \"DOCUMENT_VIEW\",\n                isArchived: false,\n              },\n              orderBy: {\n                viewedAt: \"desc\",\n              },\n              take: 1,\n              select: {\n                viewedAt: true,\n              },\n            },\n            document: {\n              select: {\n                name: true,\n                versions: {\n                  orderBy: {\n                    createdAt: \"desc\",\n                  },\n                  take: 1,\n                  select: {\n                    numPages: true,\n                  },\n                },\n              },\n            },\n          },\n          orderBy: {\n            createdAt: \"desc\",\n          },\n        });\n\n        // Transform the data to match the table requirements\n        const transformedLinks = await Promise.all(\n          links.map(async (link) => {\n            let avgDuration = \"0s\";\n\n            if (link.documentId) {\n              try {\n                const durationData = await getTotalLinkDuration({\n                  linkId: link.id,\n                  documentId: link.documentId,\n                  excludedViewIds: \"\", // Include all views\n                  since,\n                  until: endStr\n                    ? new Date(endStr).getTime()\n                    : new Date().getTime(),\n                });\n\n                if (durationData.data && durationData.data[0]) {\n                  const totalDuration = durationData.data[0].sum_duration;\n                  const viewCount = durationData.data[0].view_count;\n                  const avgDurationMs = totalDuration / viewCount;\n                  avgDuration = durationFormat(avgDurationMs);\n                }\n              } catch (error) {\n                console.error(\"Error fetching Tinybird data:\", error);\n              }\n            }\n\n            return {\n              id: link.id,\n              name: link.name || `Link #${link.id.slice(-5)}`,\n              url: link.domainId\n                ? `https://${link.domainSlug}/${link.slug}`\n                : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`,\n              documentName: link.document?.name || \"Unknown\",\n              documentId: link.documentId,\n              views: link._count.views,\n              avgDuration,\n              lastViewed: link.views[0]?.viewedAt || null,\n            };\n          }),\n        );\n\n        return res.status(200).json(transformedLinks);\n      }\n\n      case \"documents\": {\n        const documents = await prisma.document.findMany({\n          where: {\n            teamId,\n            views: {\n              some: {\n                viewedAt: intervalFilter,\n                viewType: \"DOCUMENT_VIEW\",\n                isArchived: false,\n              },\n            },\n          },\n          select: {\n            id: true,\n            name: true,\n            _count: {\n              select: {\n                views: {\n                  where: {\n                    viewedAt: intervalFilter,\n                    viewType: \"DOCUMENT_VIEW\",\n                    isArchived: false,\n                  },\n                },\n              },\n            },\n            views: {\n              where: {\n                viewedAt: intervalFilter,\n                viewType: \"DOCUMENT_VIEW\",\n                isArchived: false,\n              },\n              orderBy: {\n                viewedAt: \"desc\",\n              },\n              take: 1,\n              select: {\n                viewedAt: true,\n              },\n            },\n          },\n          orderBy: {\n            createdAt: \"desc\",\n          },\n        });\n\n        // Transform the data to match the table requirements\n        const transformedDocuments = await Promise.all(\n          documents.map(async (doc) => {\n            let avgDuration = \"0s\";\n            try {\n              const durationData = await getTotalDocumentDuration({\n                documentId: doc.id,\n                excludedLinkIds: \"\", // Include all links\n                excludedViewIds: \"\", // Include all views\n                since,\n                until: endStr\n                  ? new Date(endStr).getTime()\n                  : new Date().getTime(),\n              });\n\n              if (durationData.data && durationData.data[0]) {\n                const totalDuration = durationData.data[0].sum_duration;\n                const avgDurationMs = totalDuration / doc._count.views;\n                avgDuration = durationFormat(avgDurationMs);\n              }\n            } catch (error) {\n              console.error(\"Error fetching Tinybird data:\", error);\n            }\n\n            return {\n              id: doc.id,\n              name: doc.name,\n              views: doc._count.views,\n              avgDuration,\n              lastViewed: doc.views[0]?.viewedAt || null,\n            };\n          }),\n        );\n\n        return res.status(200).json(transformedDocuments);\n      }\n\n      case \"visitors\": {\n        // Build interval filter that respects pause date\n        // Use the earlier of endDate or pauseStartsAt as the effective upper bound\n        const effectiveVisitorsEndDate =\n          pauseStartsAt && pauseStartsAt < endDate ? pauseStartsAt : endDate;\n        const visitorsIntervalFilter: any = {\n          gte: startDate,\n          ...(pauseStartsAt && pauseStartsAt < endDate\n            ? { lt: effectiveVisitorsEndDate }\n            : { lte: effectiveVisitorsEndDate }),\n        };\n\n        const viewers = await prisma.viewer.findMany({\n          where: {\n            teamId,\n            views: {\n              some: {\n                viewedAt: visitorsIntervalFilter,\n                isArchived: false,\n                viewType: \"DOCUMENT_VIEW\",\n              },\n            },\n          },\n          include: {\n            views: {\n              orderBy: {\n                viewedAt: \"desc\",\n              },\n              where: {\n                viewType: \"DOCUMENT_VIEW\",\n                viewedAt: visitorsIntervalFilter,\n                isArchived: false,\n              },\n            },\n          },\n        });\n\n        // Count hidden views from pause (clamp pauseStartsAt to startDate if it's earlier)\n        const hiddenFromPause = pauseStartsAt\n          ? await prisma.view.count({\n              where: {\n                teamId,\n                viewedAt: {\n                  gte: pauseStartsAt < startDate ? startDate : pauseStartsAt,\n                  lte: endDate,\n                },\n                isArchived: false,\n                viewType: \"DOCUMENT_VIEW\",\n              },\n            })\n          : 0;\n\n        // Transform the data to match the table requirements\n        const transformedVisitors = await Promise.all(\n          viewers.map(async (viewer) => {\n            // Get unique documents viewed\n            const uniqueDocuments = new Set(\n              viewer.views.map((view) => view.documentId),\n            );\n\n            let totalDuration = 0;\n            try {\n              const viewIds = viewer.views.map((view) => view.id).join(\",\");\n              const durationData = await getTotalViewerDuration({\n                viewIds,\n                since,\n                until: endStr\n                  ? new Date(endStr).getTime()\n                  : new Date().getTime(),\n              });\n\n              if (durationData.data && durationData.data[0]) {\n                totalDuration = durationData.data[0].sum_duration;\n              }\n            } catch (error) {\n              console.error(\"Error fetching Tinybird data:\", error);\n            }\n\n            // Get the name from the most recent view that has a name\n            const viewerName = viewer.views.find(\n              (v) => v.viewerName,\n            )?.viewerName;\n\n            return {\n              email: viewer.email,\n              viewerId: viewer.id,\n              totalViews: viewer.views.length,\n              lastActive: viewer.views[0]?.viewedAt || new Date(),\n              uniqueDocuments: uniqueDocuments.size,\n              verified: viewer.verified,\n              totalDuration,\n              viewerName: viewerName || null,\n            };\n          }),\n        );\n\n        return res.status(200).json({\n          visitors: transformedVisitors,\n          hiddenFromPause,\n        });\n      }\n\n      case \"views\": {\n        // Build interval filter that respects pause date\n        // Use the earlier of endDate or pauseStartsAt as the effective upper bound\n        const effectiveViewsEndDate =\n          pauseStartsAt && pauseStartsAt < endDate ? pauseStartsAt : endDate;\n        const viewsIntervalFilter: any = {\n          gte: startDate,\n          ...(pauseStartsAt && pauseStartsAt < endDate\n            ? { lt: effectiveViewsEndDate }\n            : { lte: effectiveViewsEndDate }),\n        };\n\n        const views = await prisma.view.findMany({\n          where: {\n            teamId,\n            viewedAt: viewsIntervalFilter,\n            isArchived: false,\n            viewType: \"DOCUMENT_VIEW\",\n          },\n          include: {\n            document: {\n              select: {\n                id: true,\n                name: true,\n                versions: {\n                  orderBy: {\n                    createdAt: \"desc\",\n                  },\n                  take: 1,\n                  select: {\n                    createdAt: true,\n                    numPages: true,\n                  },\n                },\n              },\n            },\n            link: {\n              select: {\n                id: true,\n                name: true,\n              },\n            },\n          },\n          orderBy: {\n            viewedAt: \"desc\",\n          },\n        });\n\n        // Count hidden views from pause (clamp pauseStartsAt to startDate if it's earlier)\n        const hiddenFromPause = pauseStartsAt\n          ? await prisma.view.count({\n              where: {\n                teamId,\n                viewedAt: {\n                  gte: pauseStartsAt < startDate ? startDate : pauseStartsAt,\n                  lte: endDate,\n                },\n                isArchived: false,\n                viewType: \"DOCUMENT_VIEW\",\n              },\n            })\n          : 0;\n\n        // Transform the data to match the table requirements\n        const transformedViews = await Promise.all(\n          views.map(async (view) => {\n            let totalDuration = 0;\n            let completionRate = 0;\n\n            if (view.document?.id) {\n              try {\n                const pageData = await getViewPageDuration({\n                  documentId: view.document.id,\n                  viewId: view.id,\n                  since,\n                  until: endStr\n                    ? new Date(endStr).getTime()\n                    : new Date().getTime(),\n                });\n\n                if (pageData.data && pageData.data.length > 0) {\n                  // Calculate total duration from all pages\n                  totalDuration = pageData.data.reduce(\n                    (sum, page) => sum + page.sum_duration,\n                    0,\n                  );\n\n                  // Calculate completion rate based on pages with any duration\n                  const numPages = view.document.versions[0]?.numPages || 0;\n                  completionRate = numPages\n                    ? (pageData.data.length / numPages) * 100\n                    : 0;\n                }\n              } catch (error) {\n                console.error(\"Error fetching Tinybird data:\", error);\n              }\n            }\n\n            return {\n              id: view.id,\n              viewerEmail: view.viewerEmail,\n              documentName:\n                view.document?.name ||\n                `Document #${view.document?.id.slice(-5)}`,\n              linkName: view.link?.name || `Link #${view.link?.id.slice(-5)}`,\n              viewedAt: view.viewedAt,\n              totalDuration,\n              completionRate: Math.round(completionRate),\n              verified: view.verified || false,\n              documentId: view.document?.id,\n              teamId,\n            };\n          }),\n        );\n\n        return res.status(200).json({\n          views: transformedViews,\n          hiddenFromPause,\n        });\n      }\n\n      default: {\n        return res.status(400).json({ error: \"Invalid type\" });\n      }\n    }\n  } catch (error) {\n    console.error(error);\n    if (error instanceof z.ZodError) {\n      return res.status(400).json({ error: error.issues });\n    }\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/auth/[...nextauth].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { checkRateLimit, rateLimiters } from \"@/ee/features/security\";\nimport { isSamlEnforcedForEmailDomain } from \"@/lib/api/teams/is-saml-enforced-for-email-domain\";\nimport { PrismaAdapter } from \"@next-auth/prisma-adapter\";\nimport PasskeyProvider from \"@teamhanko/passkeys-next-auth-provider\";\nimport NextAuth, { type NextAuthOptions } from \"next-auth\";\nimport CredentialsProvider from \"next-auth/providers/credentials\";\nimport EmailProvider from \"next-auth/providers/email\";\nimport GoogleProvider from \"next-auth/providers/google\";\nimport LinkedInProvider from \"next-auth/providers/linkedin\";\n\nimport { identifyUser, trackAnalytics } from \"@/lib/analytics\";\nimport { qstash } from \"@/lib/cron\";\nimport { dub } from \"@/lib/dub\";\nimport { isBlacklistedEmail } from \"@/lib/edge-config/blacklist\";\nimport { sendVerificationRequestEmail } from \"@/lib/emails/send-verification-request\";\nimport hanko from \"@/lib/hanko\";\nimport { jackson } from \"@/lib/jackson\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\nconst VERCEL_DEPLOYMENT = !!process.env.VERCEL_URL;\n\nfunction getMainDomainUrl(): string {\n  if (process.env.NODE_ENV === \"development\") {\n    return process.env.NEXTAUTH_URL || \"http://localhost:3000\";\n  }\n  return process.env.NEXTAUTH_URL || \"https://app.papermark.com\";\n}\n\n// This function can run for a maximum of 180 seconds\nexport const config = {\n  maxDuration: 180,\n};\n\nexport const authOptions: NextAuthOptions = {\n  pages: {\n    error: \"/login\",\n  },\n  providers: [\n    GoogleProvider({\n      clientId: process.env.GOOGLE_CLIENT_ID as string,\n      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,\n      allowDangerousEmailAccountLinking: true,\n    }),\n    LinkedInProvider({\n      clientId: process.env.LINKEDIN_CLIENT_ID as string,\n      clientSecret: process.env.LINKEDIN_CLIENT_SECRET as string,\n      authorization: {\n        params: { scope: \"openid profile email\" },\n      },\n      issuer: \"https://www.linkedin.com/oauth\",\n      jwks_endpoint: \"https://www.linkedin.com/oauth/openid/jwks\",\n      profile(profile, tokens) {\n        const defaultImage =\n          \"https://cdn-icons-png.flaticon.com/512/174/174857.png\";\n        return {\n          id: profile.sub,\n          name: profile.name,\n          email: profile.email,\n          image: profile.picture ?? defaultImage,\n        };\n      },\n      allowDangerousEmailAccountLinking: true,\n    }),\n    EmailProvider({\n      async sendVerificationRequest({ identifier, url }) {\n        const hasValidNextAuthUrl = !!process.env.NEXTAUTH_URL;\n        let finalUrl = url;\n\n        if (!hasValidNextAuthUrl) {\n          const mainDomainUrl = getMainDomainUrl();\n          const urlObj = new URL(url);\n          const mainDomainObj = new URL(mainDomainUrl);\n          urlObj.hostname = mainDomainObj.hostname;\n          urlObj.protocol = mainDomainObj.protocol;\n          urlObj.port = mainDomainObj.port || \"\";\n\n          finalUrl = urlObj.toString();\n        }\n\n        // In development, send the email but also log the URL\n        if (process.env.NODE_ENV === \"development\") {\n          await sendVerificationRequestEmail({\n            url: finalUrl,\n            email: identifier,\n          });\n          console.log(\"[Login Email Sent] Check your inbox for:\", identifier);\n        } else {\n          await sendVerificationRequestEmail({\n            url: finalUrl,\n            email: identifier,\n          });\n        }\n      },\n    }),\n    PasskeyProvider({\n      tenant: hanko,\n      async authorize({ userId }) {\n        const user = await prisma.user.findUnique({ where: { id: userId } });\n        if (!user) return null;\n        return user;\n      },\n    }),\n    // ─── SP-Initiated SAML SSO (OAuth flow with PKCE + state) ───\n    // Used when user clicks \"Continue with SSO\" on the login page.\n    // NextAuth handles PKCE and state validation automatically.\n    {\n      id: \"saml\",\n      name: \"BoxyHQ SAML\",\n      type: \"oauth\",\n      version: \"2.0\",\n      checks: [\"pkce\", \"state\"],\n      authorization: {\n        url: `${process.env.NEXTAUTH_URL}/api/auth/saml/authorize`,\n        params: {\n          scope: \"\",\n          response_type: \"code\",\n          provider: \"saml\",\n        },\n      },\n      token: {\n        url: `${process.env.NEXTAUTH_URL}/api/auth/saml/token`,\n        params: { grant_type: \"authorization_code\" },\n      },\n      userinfo: `${process.env.NEXTAUTH_URL}/api/auth/saml/userinfo`,\n      profile: async (profile) => {\n        // Return the normalized profile and let PrismaAdapter.createUser\n        // handle user creation so the createUser event fires correctly\n        // (welcome emails, analytics, etc.)\n        const name =\n          `${profile.firstName || \"\"} ${profile.lastName || \"\"}`.trim() ||\n          null;\n\n        return {\n          id: profile.id || profile.email,\n          name,\n          email: profile.email,\n          image: null,\n        };\n      },\n      options: {\n        clientId: \"dummy\",\n        clientSecret: process.env.NEXTAUTH_SECRET as string,\n      },\n      allowDangerousEmailAccountLinking: true,\n    },\n    // ─── IdP-Initiated SAML SSO (Credentials provider) ───\n    // Used when user clicks the app tile in their IdP dashboard.\n    // Jackson redirects with a code to /auth/saml, which then calls signIn(\"saml-idp\", { code }).\n    CredentialsProvider({\n      id: \"saml-idp\",\n      name: \"IdP Login\",\n      credentials: {\n        code: { type: \"text\" },\n      },\n      async authorize(credentials) {\n        if (!credentials?.code) return null;\n\n        try {\n          const { oauthController } = await jackson();\n\n          const { access_token } = await oauthController.token({\n            code: credentials.code,\n            grant_type: \"authorization_code\",\n            redirect_uri: process.env.NEXTAUTH_URL!,\n            client_id: \"dummy\",\n            client_secret: process.env.NEXTAUTH_SECRET!,\n          });\n\n          if (!access_token) return null;\n\n          const userInfo = await oauthController.userInfo(access_token);\n          if (!userInfo) return null;\n\n          const { email, firstName, lastName, requested } = userInfo as any;\n          if (!email) return null;\n\n          const name = [firstName, lastName].filter(Boolean).join(\" \") || email;\n\n          const user = await prisma.user.upsert({\n            where: { email },\n            create: { email, name },\n            update: { name: name || undefined },\n          });\n\n          return {\n            id: user.id,\n            email: user.email,\n            name: user.name,\n            // Pass profile for signIn callback to access tenant\n            profile: userInfo,\n          } as any;\n        } catch (error) {\n          console.error(\"[SAML] Error during SAML authorization:\", error);\n          return null;\n        }\n      },\n    }),\n  ],\n  adapter: PrismaAdapter(prisma),\n  session: { strategy: \"jwt\" },\n  cookies: {\n    sessionToken: {\n      name: `${VERCEL_DEPLOYMENT ? \"__Secure-\" : \"\"}next-auth.session-token`,\n      options: {\n        httpOnly: true,\n        sameSite: \"lax\",\n        path: \"/\",\n        domain: VERCEL_DEPLOYMENT ? \".papermark.com\" : undefined,\n        secure: VERCEL_DEPLOYMENT,\n      },\n    },\n  },\n  callbacks: {\n    jwt: async (params) => {\n      const { token, user, trigger, account } = params;\n      if (!token.email) {\n        return {};\n      }\n      if (user) {\n        token.user = user;\n      }\n      // Track SAML provider on the token\n      if (\n        (account?.provider === \"saml\" || account?.provider === \"saml-idp\") &&\n        user\n      ) {\n        token.provider = \"saml\";\n      }\n      // refresh the user data\n      if (trigger === \"update\") {\n        const user = token?.user as CustomUser;\n        const refreshedUser = await prisma.user.findUnique({\n          where: { id: user.id },\n        });\n        if (refreshedUser) {\n          token.user = refreshedUser;\n        } else {\n          return {};\n        }\n\n        if (refreshedUser?.email !== user.email) {\n          if (user.id && refreshedUser.email) {\n            await prisma.account.deleteMany({\n              where: { userId: user.id },\n            });\n          }\n        }\n      }\n      return token;\n    },\n    session: async ({ session, token }) => {\n      (session.user as CustomUser) = {\n        id: token.sub,\n        // @ts-ignore\n        ...(token || session).user,\n      };\n      return session;\n    },\n  },\n  events: {\n    async createUser(message) {\n      await identifyUser(message.user.email ?? message.user.id);\n      await trackAnalytics({\n        event: \"User Signed Up\",\n        email: message.user.email,\n        userId: message.user.id,\n      });\n\n      await qstash.publishJSON({\n        url: `${process.env.NEXT_PUBLIC_BASE_URL}/api/cron/welcome-user`,\n        body: {\n          userId: message.user.id,\n        },\n        delay: 15 * 60,\n      });\n    },\n  },\n};\n\nconst getAuthOptions = (req: NextApiRequest): NextAuthOptions => {\n  // ─── Shared state for the current auth request ───\n  // The signIn callback runs BEFORE the user is created in the DB (for new\n  // OAuth users), so `user.id` there may not be a valid database ID.\n  // We capture the SAML tenant in the callback (where we have the raw\n  // OAuthProfile with `requested.tenant`) and use it in the signIn event\n  // (where `user.id` is guaranteed to be the real database ID).\n  let samlTenant: string | null = null;\n  let samlUserEmail: string | null = null;\n\n  return {\n    ...authOptions,\n    callbacks: {\n      ...authOptions.callbacks,\n      signIn: async ({ user, account, profile }) => {\n        if (!user.email || (await isBlacklistedEmail(user.email))) {\n          await identifyUser(user.email ?? user.id);\n          await trackAnalytics({\n            event: \"User Sign In Attempted\",\n            email: user.email ?? undefined,\n            userId: user.id,\n          });\n          return false;\n        }\n\n        // ─── SSO Enforcement ───\n        // If user is NOT signing in via SAML, check if their domain requires SSO\n        if (\n          account?.provider !== \"saml\" &&\n          account?.provider !== \"saml-idp\"\n        ) {\n          const ssoEnforced = await isSamlEnforcedForEmailDomain(user.email);\n          if (ssoEnforced) {\n            throw new Error(\"require-saml-sso\");\n          }\n        }\n\n        // ─── SAML user → email domain validation ───\n        if (\n          account?.provider === \"saml\" ||\n          account?.provider === \"saml-idp\"\n        ) {\n          // Get the SAML profile — comes from different places depending on provider\n          let samlProfile: any;\n          if (account.provider === \"saml-idp\") {\n            // IdP-initiated: we attached the Jackson userInfo to user.profile\n            samlProfile = (user as any).profile;\n          } else {\n            // SP-initiated OAuth: NextAuth passes the raw Jackson userInfo as `profile`\n            samlProfile = profile;\n          }\n\n          const tenant = samlProfile?.requested?.tenant;\n          if (tenant) {\n            // ─── Email domain validation ───\n            // Verify the SAML user's email domain matches the team's ssoEmailDomain.\n            // This prevents a misconfigured IdP from injecting users from unexpected domains.\n            const team = await prisma.team.findUnique({\n              where: { id: tenant },\n              select: { ssoEmailDomain: true, id: true },\n            });\n\n            if (team?.ssoEmailDomain) {\n              const userEmailDomain = user.email\n                .split(\"@\")[1]\n                ?.toLowerCase();\n              if (\n                userEmailDomain !==\n                team.ssoEmailDomain.toLowerCase()\n              ) {\n                console.warn(\n                  `[SAML] Rejected: user ${user.email} domain does not match team ssoEmailDomain ${team.ssoEmailDomain}`,\n                );\n                return false;\n              }\n            }\n\n            // Store tenant for the signIn event to handle auto-join.\n            // We can't reliably do the userTeam upsert here because for\n            // new users (or first-time SSO users), user.id is not yet a\n            // valid database ID — NextAuth creates the user AFTER this\n            // callback returns true.\n            samlTenant = tenant;\n            samlUserEmail = user.email;\n          }\n        }\n\n        // Apply rate limiting for signin attempts\n        try {\n          if (req) {\n            const clientIP = getIpAddress(req.headers);\n            const rateLimitResult = await checkRateLimit(\n              rateLimiters.auth,\n              clientIP,\n            );\n\n            if (!rateLimitResult.success) {\n              log({\n                message: `Rate limit exceeded for IP ${clientIP} during signin attempt`,\n                type: \"error\",\n              });\n              return false;\n            }\n          }\n        } catch (error) {}\n\n        return true;\n      },\n    },\n    events: {\n      ...authOptions.events,\n      signIn: async (message) => {\n        await Promise.allSettled([\n          identifyUser(message.user.email ?? message.user.id),\n          trackAnalytics({\n            event: \"User Signed In\",\n            email: message.user.email,\n          }),\n        ]);\n\n        // ─── SAML: Auto-join workspace + clean up invitations ───\n        // This runs AFTER the user is created in the DB, so message.user.id\n        // is guaranteed to be the real database user ID.\n        if (samlTenant) {\n          const tenant = samlTenant;\n          const userEmail = samlUserEmail;\n\n          try {\n            await prisma.userTeam.upsert({\n              where: {\n                userId_teamId: {\n                  userId: message.user.id,\n                  teamId: tenant,\n                },\n              },\n              update: {},\n              create: {\n                userId: message.user.id,\n                teamId: tenant,\n                role: \"MEMBER\",\n              },\n            });\n          } catch (error) {\n            console.error(\n              `[SAML] Failed to upsert userTeam for user ${message.user.id} in team ${tenant}:`,\n              error,\n            );\n          }\n\n          // Clean up any pending invitations for this user\n          if (userEmail) {\n            await prisma.invitation\n              .deleteMany({\n                where: {\n                  email: userEmail,\n                  teamId: tenant,\n                },\n              })\n              .catch(() => {\n                // No invitation to clean up\n              });\n          }\n        }\n\n        if (message.isNewUser) {\n          const { dub_id } = req.cookies;\n          if (dub_id && process.env.DUB_API_KEY) {\n            try {\n              await dub.track.lead({\n                clickId: dub_id,\n                eventName: \"Sign Up\",\n                customerExternalId: message.user.id,\n                customerName: message.user.name,\n                customerEmail: message.user.email,\n                customerAvatar: message.user.image ?? undefined,\n              });\n            } catch (err) {\n              console.error(\"dub.track.lead failed\", err);\n            }\n          }\n        }\n      },\n    },\n  };\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return NextAuth(req, res, getAuthOptions(req));\n}\n"
  },
  {
    "path": "pages/api/conversations/[[...conversations]].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/conversations/api/conversations-route\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/feedback/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/feedback\n    const { answer, feedbackId, viewId } = req.body as {\n      answer: string;\n      feedbackId: string;\n      viewId: string;\n    };\n\n    try {\n      const feedback = await prisma.feedback.findUnique({\n        where: {\n          id: feedbackId,\n        },\n        select: {\n          linkId: true,\n          data: true,\n        },\n      });\n\n      // if feedback does not exist, we should not record any response\n      if (!feedback) {\n        return res.status(404).json({ error: \"Feedback not found\" });\n      }\n\n      const view = await prisma.view.findUnique({\n        where: {\n          id: viewId,\n          linkId: feedback.linkId,\n        },\n      });\n\n      // if view does not exist, we should not record any response\n      if (!view) {\n        return res.status(404).json({ error: \"View not found\" });\n      }\n\n      // create a feedback response\n      await prisma.feedbackResponse.create({\n        data: {\n          feedbackId: feedbackId,\n          viewId: viewId,\n          data: {\n            ...(feedback.data as { question: string; type: string }),\n            answer: answer,\n          },\n        },\n      });\n\n      return res.status(200).json({ message: \"Feedback response recorded\" });\n    } catch (error) {\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n  }\n\n  // We only allow POST requests\n  res.setHeader(\"Allow\", [\"POST\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/file/browser-upload.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { type HandleUploadBody, handleUpload } from \"@vercel/blob/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const body = req.body as HandleUploadBody;\n\n  try {\n    const jsonResponse = await handleUpload({\n      body,\n      request: req,\n      onBeforeGenerateToken: async (pathname: string) => {\n        // Generate a client token for the browser to upload the file\n\n        const session = await getServerSession(req, res, authOptions);\n        if (!session) {\n          res.status(401).end(\"Unauthorized\");\n          throw new Error(\"Unauthorized\");\n        }\n\n        const userId = (session.user as CustomUser).id;\n        const team = await prisma.team.findFirst({\n          where: {\n            users: {\n              some: {\n                userId,\n              },\n            },\n          },\n          select: {\n            plan: true,\n          },\n        });\n\n        let maxSize = 30 * 1024 * 1024; // 30 MB\n        const stripedTeamPlan = team?.plan.replace(\"+old\", \"\");\n        if (\n          stripedTeamPlan &&\n          [\"business\", \"datarooms\", \"datarooms-plus\", \"datarooms-premium\"].includes(stripedTeamPlan)\n        ) {\n          maxSize = 100 * 1024 * 1024; // 100 MB\n        }\n\n        return {\n          addRandomSuffix: true,\n          allowedContentTypes: [\n            \"application/pdf\",\n            \"application/vnd.ms-excel\",\n            \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n          ],\n          maximumSizeInBytes: maxSize, // 30 MB\n          metadata: JSON.stringify({\n            // optional, sent to your server on upload completion\n            userId: (session.user as CustomUser).id,\n          }),\n        };\n      },\n      onUploadCompleted: async ({ blob, tokenPayload }) => {\n        // Get notified of browser upload completion\n        // ⚠️ This will not work on `localhost` websites,\n        // Use ngrok or similar to get the full upload flow\n\n        try {\n          // Run any logic after the file upload completed\n          // const { userId } = JSON.parse(tokenPayload);\n          // await db.update({ avatar: blob.url, userId });\n        } catch (error) {\n          // throw new Error(\"Could not update user\");\n        }\n      },\n    });\n\n    return res.status(200).json(jsonResponse);\n  } catch (error) {\n    // The webhook will retry 5 times waiting for a 200\n    return res.status(400).json({ error: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/file/image-upload.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { type HandleUploadBody, handleUpload } from \"@vercel/blob/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nconst uploadConfig = {\n  profile: {\n    allowedContentTypes: [\"image/png\", \"image/jpg\"],\n    maximumSizeInBytes: 2 * 1024 * 1024, // 2MB\n  },\n  assets: {\n    allowedContentTypes: [\n      \"image/png\",\n      \"image/jpeg\",\n      \"image/jpg\",\n      \"image/svg+xml\",\n      \"image/x-icon\",\n      \"image/ico\",\n    ],\n    maximumSizeInBytes: 5 * 1024 * 1024, // 5MB\n  },\n};\n\n// logo-upload/?type= \"profile\" | \"assets\"\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const body = req.body as HandleUploadBody;\n  const type = Array.isArray(req.query.type)\n    ? req.query.type[0]\n    : req.query.type;\n\n  if (!type || !(type in uploadConfig)) {\n    return res.status(400).json({ error: \"Invalid upload type specified.\" });\n  }\n\n  try {\n    const jsonResponse = await handleUpload({\n      body,\n      request: req,\n      onBeforeGenerateToken: async (pathname: string) => {\n        // Generate a client token for the browser to upload the file\n\n        const session = await getServerSession(req, res, authOptions);\n        if (!session) {\n          res.status(401).end(\"Unauthorized\");\n          throw new Error(\"Unauthorized\");\n        }\n\n        return {\n          addRandomSuffix: true,\n          allowedContentTypes:\n            uploadConfig[type as keyof typeof uploadConfig].allowedContentTypes,\n          maximumSizeInBytes:\n            uploadConfig[type as keyof typeof uploadConfig].maximumSizeInBytes,\n          metadata: JSON.stringify({\n            // optional, sent to your server on upload completion\n            userId: (session.user as CustomUser).id,\n          }),\n        };\n      },\n      onUploadCompleted: async ({ blob, tokenPayload }) => {\n        // Get notified of browser upload completion\n        // ⚠️ This will not work on `localhost` websites,\n        // Use ngrok or similar to get the full upload flow\n\n        console.log(\"blob upload completed\", blob, tokenPayload);\n\n        try {\n          // Run any logic after the file upload completed\n          // const { userId } = JSON.parse(tokenPayload);\n          // await db.update({ avatar: blob.url, userId });\n        } catch (error) {\n          // throw new Error(\"Could not update user\");\n        }\n      },\n    });\n\n    return res.status(200).json(jsonResponse);\n  } catch (error) {\n    // The webhook will retry 5 times waiting for a 200\n    return res.status(400).json({ error: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/file/notion/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport notion from \"@/lib/notion\";\nimport {\n  addSignedUrls,\n  fetchMissingPageReferences,\n  normalizeRecordMap,\n} from \"@/lib/notion/utils\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    await log({\n      message: `Method Not Allowed: ${req.method}`,\n      type: \"error\",\n    });\n\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  // POST /api/file/notion\n\n  const { pageId } = req.body as { pageId: string };\n\n  try {\n    const recordMap = await notion.getPage(pageId, { signFileUrls: false });\n\n    if (!recordMap) {\n      res.status(500).json({ message: \"Internal Server Error\" });\n      return;\n    }\n\n    // Fetch missing page references that are embedded in rich text (e.g., table cells with multiple page links)\n    await fetchMissingPageReferences(recordMap);\n\n    // Normalize double-nested block structures from the Notion API\n    normalizeRecordMap(recordMap);\n\n    // TODO: separately sign the file urls until PR merged and published; ref: https://github.com/NotionX/react-notion-x/issues/580#issuecomment-2542823817\n    await addSignedUrls({ recordMap });\n\n    res.status(200).json(recordMap);\n    return;\n  } catch (error) {\n    res.status(500).json({ message: \"Internal Server Error\" });\n    return;\n  }\n}\n"
  },
  {
    "path": "pages/api/file/s3/get-presigned-get-url-proxy.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).end(\"Method Not Allowed\");\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const { key } = req.body as { key: string };\n\n  if (!key) {\n    return res.status(400).json({ message: \"Key is required\" });\n  }\n\n  // Extract teamId from key (format: teamId/docId/filename)\n  const teamId = key.split(\"/\")[0];\n  if (!teamId) {\n    return res.status(400).json({ message: \"Invalid key format\" });\n  }\n\n  // Check if user belongs to the team that owns the file\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: (session.user as CustomUser).id,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res\n        .status(403)\n        .json({ message: \"Forbidden: You are not a member of this team\" });\n    }\n  } catch (error) {\n    return errorhandler(error, res);\n  }\n\n  try {\n    const response = await fetch(\n      `${process.env.NEXTAUTH_URL}/api/file/s3/get-presigned-get-url`,\n      {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n        },\n        body: JSON.stringify({ key: key }),\n      },\n    );\n\n    if (!response.ok) {\n      const contentType = response.headers.get(\"content-type\");\n      let error: any;\n\n      if (contentType && contentType.includes(\"application/json\")) {\n        try {\n          error = await response.json();\n        } catch (parseError) {\n          error = {\n            message:\n              (await response.text()) ||\n              `Request failed with status ${response.status}`,\n          };\n        }\n      } else {\n        const textError = await response.text();\n        error = {\n          message: textError || `Request failed with status ${response.status}`,\n        };\n      }\n\n      return res.status(response.status).json(error);\n    }\n\n    const data = await response.json();\n    return res.status(200).json(data);\n  } catch (error) {\n    console.error(\"Proxy error:\", error);\n    return res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/file/s3/get-presigned-get-url.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { GetObjectCommand } from \"@aws-sdk/client-s3\";\nimport { getSignedUrl as getCloudfrontSignedUrl } from \"@aws-sdk/cloudfront-signer\";\nimport { getSignedUrl as getS3SignedUrl } from \"@aws-sdk/s3-request-presigner\";\n\nimport { ONE_SECOND, TWO_MINUTES } from \"@/lib/constants\";\nimport { getTeamS3ClientAndConfig } from \"@/lib/files/aws-client\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).end(\"Method Not Allowed\");\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  if (!authHeader || !authHeader.startsWith(\"Bearer \")) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n  const token = authHeader.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  if (!token) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  // Check if the API Key matches\n  if (!process.env.INTERNAL_API_KEY) {\n    log({\n      message: \"INTERNAL_API_KEY environment variable is not set\",\n      type: \"error\",\n    });\n    return res.status(500).json({ message: \"Server configuration error\" });\n  }\n  if (token !== process.env.INTERNAL_API_KEY) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const { key } = req.body as { key: string };\n\n  try {\n    // Extract teamId from key (format: teamId/docId/filename)\n    const teamId = key.split(\"/\")[0];\n    if (!teamId) {\n      log({\n        message: `Invalid key format: ${key}`,\n        type: \"error\",\n      });\n      return res.status(400).json({ error: \"Invalid key format\" });\n    }\n\n    const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n    if (config.distributionHost) {\n      const distributionUrl = new URL(\n        key,\n        `https://${config.distributionHost}`,\n      );\n\n      const url = getCloudfrontSignedUrl({\n        url: distributionUrl.toString(),\n        keyPairId: `${config.distributionKeyId}`,\n        privateKey: `${config.distributionKeyContents}`,\n        dateLessThan: new Date(Date.now() + TWO_MINUTES).toISOString(),\n      });\n\n      return res.status(200).json({ url });\n    }\n\n    const getObjectCommand = new GetObjectCommand({\n      Bucket: config.bucket,\n      Key: key,\n    });\n\n    const url = await getS3SignedUrl(client, getObjectCommand, {\n      expiresIn: TWO_MINUTES / ONE_SECOND,\n    });\n\n    return res.status(200).json({ url });\n  } catch (error) {\n    log({\n      message: `Error getting presigned get url for ${key} \\n\\n ${error}`,\n      type: \"error\",\n    });\n    return res\n      .status(500)\n      .json({ error: \"AWS Cloudfront Signed URL Error\", message: error });\n  }\n}\n"
  },
  {
    "path": "pages/api/file/s3/get-presigned-post-url.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { PutObjectCommand } from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport { getServerSession } from \"next-auth\";\nimport path from \"node:path\";\n\nimport { ONE_HOUR, ONE_SECOND } from \"@/lib/constants\";\nimport { getTeamS3ClientAndConfig } from \"@/lib/files/aws-client\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).end(\"Method Not Allowed\");\n  }\n\n  const { fileName, contentType, teamId, docId } = req.body as {\n    fileName: string;\n    contentType: string;\n    teamId: string;\n    docId: string;\n  };\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const team = await prisma.team.findUnique({\n    where: {\n      id: teamId,\n      users: {\n        some: {\n          userId: (session.user as CustomUser).id,\n        },\n      },\n    },\n    select: { id: true },\n  });\n\n  if (!team) {\n    return res.status(403).end(\"Unauthorized to access this team\");\n  }\n\n  try {\n    // Get the basename and extension for the file\n    const { name, ext } = path.parse(fileName);\n\n    const slugifiedName = safeSlugify(name) + ext;\n    const originalFileName = `${name}${ext}`;\n    const key = `${team.id}/${docId}/${slugifiedName}`;\n    const contentDisposition = `attachment; filename=\"${slugifiedName}\"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`;\n\n    const { client, config } = await getTeamS3ClientAndConfig(team.id);\n\n    const putObjectCommand = new PutObjectCommand({\n      Bucket: config.bucket,\n      Key: key,\n      ContentType: contentType,\n      ContentDisposition: contentDisposition,\n    });\n\n    const url = await getSignedUrl(client, putObjectCommand, {\n      expiresIn: ONE_HOUR / ONE_SECOND,\n    });\n\n    return res\n      .status(200)\n      .json({ url, key, docId, fileName: slugifiedName, contentDisposition });\n  } catch (error) {\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/file/s3/multipart.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport {\n  AbortMultipartUploadCommand,\n  CompleteMultipartUploadCommand,\n  CreateMultipartUploadCommand,\n  UploadPartCommand,\n} from \"@aws-sdk/client-s3\";\nimport { getSignedUrl } from \"@aws-sdk/s3-request-presigner\";\nimport { getServerSession } from \"next-auth\";\nimport path from \"node:path\";\n\nimport { ONE_HOUR, ONE_SECOND } from \"@/lib/constants\";\nimport { getTeamS3ClientAndConfig } from \"@/lib/files/aws-client\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { MultipartUploadSchema } from \"@/lib/zod/schemas/multipart\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).end(\"Method Not Allowed\");\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  try {\n    // Validate request body with Zod\n    const validationResult = MultipartUploadSchema.safeParse(req.body);\n\n    if (!validationResult.success) {\n      return res.status(400).json({\n        error: \"Invalid request body\",\n        details: validationResult.error.issues.map((issue) => ({\n          path: issue.path.join(\".\"),\n          message: issue.message,\n        })),\n      });\n    }\n\n    const data = validationResult.data;\n    const { action, fileName, contentType, teamId, docId } = data;\n\n    // Verify team access\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: (session.user as CustomUser).id,\n          },\n        },\n      },\n      select: { id: true },\n    });\n\n    if (!team) {\n      return res.status(403).end(\"Unauthorized to access this team\");\n    }\n\n    // Get the basename and extension for the file\n    const { name, ext } = path.parse(fileName);\n    const slugifiedName = safeSlugify(name) + ext;\n    const originalFileName = `${name}${ext}`;\n    const key = `${team.id}/${docId}/${slugifiedName}`;\n\n    const { client, config } = await getTeamS3ClientAndConfig(team.id);\n\n    switch (action) {\n      case \"initiate\": {\n        // Step 1: Start multipart upload\n        const createCommand = new CreateMultipartUploadCommand({\n          Bucket: config.bucket,\n          Key: key,\n          ContentType: contentType,\n          ContentDisposition: `attachment; filename=\"${slugifiedName}\"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`,\n        });\n\n        const createResponse = await client.send(createCommand);\n\n        return res.status(200).json({\n          uploadId: createResponse.UploadId,\n          key,\n          fileName: slugifiedName,\n        });\n      }\n\n      case \"get-part-urls\": {\n        // Step 2: Generate pre-signed URLs for each part\n        if (data.action !== \"get-part-urls\") {\n          return res.status(400).json({ error: \"Invalid action\" });\n        }\n\n        const { uploadId, fileSize, partSize } = data;\n\n        const numParts = Math.ceil(fileSize / partSize);\n        const urls = await Promise.all(\n          Array.from({ length: numParts }, async (_, index) => {\n            const partNumber = index + 1;\n            const command = new UploadPartCommand({\n              Bucket: config.bucket,\n              Key: key,\n              PartNumber: partNumber,\n              UploadId: uploadId,\n            });\n\n            const url = await getSignedUrl(client, command, {\n              expiresIn: ONE_HOUR / ONE_SECOND,\n            });\n\n            return { partNumber, url };\n          }),\n        );\n\n        return res.status(200).json({ urls });\n      }\n\n      case \"complete\": {\n        // Step 3: Complete multipart upload\n        if (data.action !== \"complete\") {\n          return res.status(400).json({ error: \"Invalid action\" });\n        }\n\n        const { uploadId, parts } = data;\n\n        const completeCommand = new CompleteMultipartUploadCommand({\n          Bucket: config.bucket,\n          Key: key,\n          UploadId: uploadId,\n          MultipartUpload: {\n            Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),\n          },\n        });\n\n        try {\n          await client.send(completeCommand);\n\n          return res.status(200).json({\n            success: true,\n            key,\n            fileName: slugifiedName,\n          });\n        } catch (completeError) {\n          console.error(\"Failed to complete multipart upload:\", completeError);\n\n          // Cleanup: Abort the multipart upload to prevent storage costs\n          try {\n            const abortCommand = new AbortMultipartUploadCommand({\n              Bucket: config.bucket,\n              Key: key,\n              UploadId: uploadId,\n            });\n\n            await client.send(abortCommand);\n            console.log(`Successfully aborted multipart upload: ${uploadId}`);\n          } catch (abortError) {\n            console.error(\"Failed to abort multipart upload:\", abortError);\n            // Log but don't fail the request - the upload already failed\n          }\n\n          return res.status(500).json({\n            error: \"Failed to complete multipart upload\",\n            details:\n              completeError instanceof Error\n                ? completeError.message\n                : \"Unknown error\",\n          });\n        }\n      }\n\n      default:\n        return res.status(400).json({ error: \"Invalid action\" });\n    }\n  } catch (error) {\n    console.error(\"Multipart upload error:\", error);\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/file/tus/[[...file]].ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { getLimits } from \"@/ee/limits/server\";\nimport { MultiRegionS3Store } from \"@/ee/features/storage/s3-store\";\nimport { CopyObjectCommand } from \"@aws-sdk/client-s3\";\nimport { Server } from \"@tus/server\";\nimport { getServerSession } from \"next-auth/next\";\nimport path from \"node:path\";\n\nimport { getTeamS3ClientAndConfig } from \"@/lib/files/aws-client\";\nimport { RedisLocker } from \"@/lib/files/tus-redis-locker\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { lockerRedisClient } from \"@/lib/redis\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log, safeSlugify } from \"@/lib/utils\";\nimport {\n  getFileSizeLimit,\n  getFileSizeLimits,\n} from \"@/lib/utils/get-file-size-limits\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport const config = {\n  maxDuration: 60,\n  api: {\n    bodyParser: false,\n  },\n};\n\nconst locker = new RedisLocker({\n  redisClient: lockerRedisClient,\n});\n\nconst FREE_PLAN = \"free\";\nconst FREE_TRIAL_PLAN = \"free+drtrial\";\nconst BYTES_PER_MEGABYTE = 1024 * 1024;\ntype TusErrorResponse = { status_code: number; body: string };\n\ntype TusAuthenticatedRequest = NextApiRequest & {\n  papermarkUserId?: string;\n};\n\nconst tusServer = new Server({\n  // `path` needs to match the route declared by the next file router\n  path: \"/api/file/tus\",\n  maxSize: 1024 * 1024 * 1024 * 2, // 2 GiB\n  respectForwardedHeaders: true,\n  locker,\n  datastore: new MultiRegionS3Store(),\n  namingFunction(req, metadata) {\n    const { teamId, fileName } = metadata as {\n      teamId: string;\n      fileName: string;\n    };\n    const docId = newId(\"doc\");\n    const { name, ext } = path.parse(fileName);\n    const newName = `${teamId}/${docId}/${safeSlugify(name)}${ext}`;\n    return newName;\n  },\n  generateUrl(req, { proto, host, path, id }) {\n    // Encode the ID to be URL safe\n    id = Buffer.from(id, \"utf-8\").toString(\"base64url\");\n    return `${proto}://${host}${path}/${id}`;\n  },\n  getFileIdFromRequest(req) {\n    // Extract the ID from the URL\n    const id = (req.url as string).split(\"/api/file/tus/\")[1];\n    return Buffer.from(id, \"base64url\").toString(\"utf-8\");\n  },\n  onResponseError(req, res, err) {\n    if (typeof err === \"object\" && err !== null) {\n      const tusError = err as { status_code?: unknown; body?: unknown };\n      if (\n        typeof tusError.status_code === \"number\" &&\n        typeof tusError.body === \"string\"\n      ) {\n        const errorResponse: TusErrorResponse = {\n          status_code: tusError.status_code,\n          body: tusError.body,\n        };\n        return errorResponse;\n      }\n    }\n\n    log({\n      message: \"Error uploading a file. Error: \\n\\n\" + err,\n      type: \"error\",\n    });\n    return { status_code: 500, body: \"Internal Server Error\" };\n  },\n  async onIncomingRequest(req, res, uploadId) {\n    const userId = (req as TusAuthenticatedRequest).papermarkUserId;\n    if (!userId) {\n      throw { status_code: 401, body: \"Unauthorized\" };\n    }\n\n    // Upload creation is validated in onUploadCreate; here we protect follow-up\n    // requests (HEAD/PATCH/DELETE) so only team members can touch an upload URL.\n    if (!uploadId || req.method === \"POST\") {\n      return;\n    }\n\n    const decodedUploadId = uploadId.includes(\"/\")\n      ? uploadId\n      : Buffer.from(uploadId, \"base64url\").toString(\"utf-8\");\n    const uploadTeamId = decodedUploadId.split(\"/\")[0];\n\n    if (!uploadTeamId) {\n      throw { status_code: 400, body: \"Invalid upload id\" };\n    }\n\n    const hasTeamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId: uploadTeamId,\n        },\n      },\n      select: {\n        userId: true,\n      },\n    });\n\n    if (!hasTeamAccess) {\n      throw { status_code: 403, body: \"Unauthorized to access this team\" };\n    }\n  },\n  async onUploadCreate(req, res, upload) {\n    const userId = (req as TusAuthenticatedRequest).papermarkUserId;\n    if (!userId) {\n      throw { status_code: 401, body: \"Unauthorized\" };\n    }\n\n    const metadata = upload.metadata || {};\n    const teamId = metadata.teamId;\n    const fileName = metadata.fileName;\n    const contentType = metadata.contentType || \"application/octet-stream\";\n\n    if (!teamId || !fileName) {\n      throw { status_code: 400, body: \"Missing required upload metadata\" };\n    }\n\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId,\n          },\n        },\n      },\n      select: {\n        plan: true,\n      },\n    });\n\n    if (!team) {\n      throw { status_code: 403, body: \"Unauthorized to access this team\" };\n    }\n\n    const [limits, teamIsPaused] = await Promise.all([\n      getLimits({ teamId, userId }),\n      isTeamPausedById(teamId),\n    ]);\n\n    if (teamIsPaused) {\n      throw {\n        status_code: 403,\n        body: \"Team is currently paused. New document uploads are not available.\",\n      };\n    }\n\n    const documentLimit = limits.documents;\n    if (\n      typeof documentLimit === \"number\" &&\n      Number.isFinite(documentLimit) &&\n      limits.usage.documents >= documentLimit\n    ) {\n      throw {\n        status_code: 403,\n        body: \"You have reached the team document limit\",\n      };\n    }\n\n    const uploadSize = upload.size;\n    if (\n      typeof uploadSize !== \"number\" ||\n      !Number.isFinite(uploadSize) ||\n      uploadSize <= 0\n    ) {\n      throw { status_code: 400, body: \"Missing or invalid upload length\" };\n    }\n\n    const isFree = team.plan === FREE_PLAN || team.plan === FREE_TRIAL_PLAN;\n    const isTrial = team.plan.includes(\"drtrial\");\n    const teamFileSizeLimitConfig: Parameters<typeof getFileSizeLimits>[0][\"limits\"] =\n      \"fileSizeLimits\" in limits &&\n      typeof limits.fileSizeLimits === \"object\" &&\n      limits.fileSizeLimits !== null\n        ? {\n            fileSizeLimits: limits.fileSizeLimits as Record<\n              string,\n              number | undefined\n            >,\n          }\n        : undefined;\n    const fileSizeLimits = getFileSizeLimits({\n      limits: teamFileSizeLimitConfig,\n      isFree,\n      isTrial,\n    });\n    const fileSizeLimitMb = getFileSizeLimit(contentType, fileSizeLimits);\n    const fileSizeLimitBytes = fileSizeLimitMb * BYTES_PER_MEGABYTE;\n\n    if (uploadSize > fileSizeLimitBytes) {\n      throw {\n        status_code: 413,\n        body: `File size too big for ${contentType} (max. ${fileSizeLimitMb} MB)`,\n      };\n    }\n\n    return res;\n  },\n  async onUploadFinish(req, res, upload) {\n    try {\n      const metadata = upload.metadata || {};\n      const contentType = metadata.contentType || \"application/octet-stream\";\n      const { name, ext } = path.parse(metadata.fileName!);\n      const originalFileName = `${name}${ext}`;\n      const contentDisposition = `attachment; filename=\"${safeSlugify(name)}${ext}\"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`;\n\n      // The Key (object path) where the file was uploaded\n      const objectKey = upload.id;\n\n      // Extract teamId from the object key (format: teamId/docId/filename)\n      const teamId = objectKey.split(\"/\")[0];\n      if (!teamId) {\n        throw { status_code: 500, body: \"Invalid object key format\" };\n      }\n\n      // Get team-specific S3 client and config\n      const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n      // Copy the object onto itself, replacing the metadata\n      const params = {\n        Bucket: config.bucket,\n        CopySource: `${config.bucket}/${objectKey}`,\n        Key: objectKey,\n        ContentType: contentType,\n        ContentDisposition: contentDisposition,\n        MetadataDirective: \"REPLACE\" as const,\n      };\n\n      const copyCommand = new CopyObjectCommand(params);\n      await client.send(copyCommand);\n\n      return res;\n    } catch (error) {\n      throw { status_code: 500, body: \"Error updating metadata\" };\n    }\n  },\n});\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // Get the session\n  const session = await getServerSession(req, res, authOptions);\n  const userId = (session?.user as CustomUser | undefined)?.id;\n  if (!userId) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  (req as TusAuthenticatedRequest).papermarkUserId = userId;\n\n  return tusServer.handle(req, res);\n}\n"
  },
  {
    "path": "pages/api/file/tus-viewer/[[...file]].ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { MultiRegionS3Store } from \"@/ee/features/storage/s3-store\";\nimport { CopyObjectCommand } from \"@aws-sdk/client-s3\";\nimport { Server } from \"@tus/server\";\nimport path from \"node:path\";\n\nimport { verifyDataroomSessionInPagesRouter } from \"@/lib/auth/dataroom-auth\";\nimport { getTeamS3ClientAndConfig } from \"@/lib/files/aws-client\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { RedisLocker } from \"@/lib/files/tus-redis-locker\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { lockerRedisClient } from \"@/lib/redis\";\nimport { log } from \"@/lib/utils\";\n\nexport const config = {\n  maxDuration: 60,\n  api: {\n    bodyParser: false,\n  },\n};\n\nconst locker = new RedisLocker({\n  redisClient: lockerRedisClient,\n});\n\nconst tusServer = new Server({\n  // `path` needs to match the route declared by the next file router\n  path: \"/api/file/tus-viewer\",\n  maxSize: 1024 * 1024 * 1024 * 2, // 2 GiB\n  respectForwardedHeaders: true,\n  locker,\n  datastore: new MultiRegionS3Store(),\n  async namingFunction(req, metadata) {\n    // Extract viewer data from metadata\n    const { teamId, fileName, viewerId, linkId, dataroomId } = metadata as {\n      teamId: string;\n      fileName: string;\n      viewerId: string;\n      linkId: string;\n      dataroomId: string;\n    };\n\n    // Validate the viewer exists and has permission\n    let teamIdToUse = teamId;\n    try {\n      if (teamId !== \"visitor-upload\") {\n        throw new Error(\"Unauthorized to access this team\");\n      }\n\n      const link = await prisma.link.findUnique({\n        where: {\n          id: linkId,\n          dataroomId: dataroomId || null,\n        },\n        select: { teamId: true, enableUpload: true },\n      });\n\n      if (!link || !link.enableUpload || !link.teamId) {\n        throw new Error(\"Upload not allowed\");\n      }\n\n      const viewer = await prisma.viewer.findUnique({\n        where: { id: viewerId },\n        select: { teamId: true },\n      });\n\n      if (!viewer || viewer.teamId !== link.teamId) {\n        throw new Error(\"Unauthorized to access this team\");\n      }\n\n      teamIdToUse = link.teamId;\n    } catch (error) {\n      console.error(\"Error validating viewer:\", error);\n      throw new Error(\"Unauthorized\");\n    }\n\n    const docId = newId(\"doc\");\n    const { name, ext } = path.parse(fileName);\n    const newName = `${teamIdToUse}/${docId}/${safeSlugify(name)}${ext}`;\n    return newName;\n  },\n  generateUrl(req, { proto, host, path, id }) {\n    // Encode the ID to be URL safe\n    id = Buffer.from(id, \"utf-8\").toString(\"base64url\");\n    return `${proto}://${host}${path}/${id}`;\n  },\n  getFileIdFromRequest(req) {\n    // Extract the ID from the URL\n    const id = (req.url as string).split(\"/api/file/tus-viewer/\")[1];\n    return Buffer.from(id, \"base64url\").toString(\"utf-8\");\n  },\n  onResponseError(req, res, err) {\n    log({\n      message: \"Error uploading a file via viewer. Error: \\n\\n\" + err,\n      type: \"error\",\n    });\n    return { status_code: 500, body: \"Internal Server Error\" };\n  },\n  async onUploadCreate(req, res, upload) {\n    // Extract viewer data from metadata\n    const { teamId, fileName, viewerId, linkId, dataroomId } =\n      upload.metadata as {\n        teamId: string;\n        fileName: string;\n        viewerId: string;\n        dataroomId: string;\n        linkId: string;\n      };\n\n    // Validate the viewer exists and has permission\n    try {\n      if (teamId !== \"visitor-upload\") {\n        throw new Error(\"Unauthorized to access this team\");\n      }\n\n      const link = await prisma.link.findUnique({\n        where: {\n          id: linkId,\n          dataroomId: dataroomId || null,\n        },\n        select: { teamId: true, enableUpload: true },\n      });\n\n      if (!link || !link.enableUpload || !link.teamId) {\n        throw new Error(\"Upload not allowed\");\n      }\n\n      const viewer = await prisma.viewer.findUnique({\n        where: { id: viewerId },\n        select: { teamId: true },\n      });\n\n      if (!viewer || viewer.teamId !== link.teamId) {\n        throw new Error(\"Unauthorized to access this team\");\n      }\n\n      return res;\n    } catch (error) {\n      console.error(\"Error validating viewer:\", error);\n      throw new Error(\"Unauthorized\");\n    }\n  },\n  async onUploadFinish(req, res, upload) {\n    try {\n      const metadata = upload.metadata || {};\n      const contentType = metadata.contentType || \"application/octet-stream\";\n      const { name, ext } = path.parse(metadata.fileName!);\n      const originalFileName = `${name}${ext}`;\n      const contentDisposition = `attachment; filename=\"${safeSlugify(name)}${ext}\"; filename*=UTF-8''${encodeURIComponent(originalFileName)}`;\n\n      // The Key (object path) where the file was uploaded\n      const objectKey = upload.id;\n\n      // Extract teamId from the object key (format: teamId/docId/filename)\n      const teamId = objectKey.split(\"/\")[0];\n      if (!teamId) {\n        throw { status_code: 500, body: \"Invalid object key format\" };\n      }\n\n      // Get team-specific S3 client and config\n      const { client, config } = await getTeamS3ClientAndConfig(teamId);\n\n      // Copy the object onto itself, replacing the metadata\n      const params = {\n        Bucket: config.bucket,\n        CopySource: `${config.bucket}/${objectKey}`,\n        Key: objectKey,\n        ContentType: contentType,\n        ContentDisposition: contentDisposition,\n        MetadataDirective: \"REPLACE\" as const,\n      };\n\n      const copyCommand = new CopyObjectCommand(params);\n      await client.send(copyCommand);\n\n      return res;\n    } catch (error) {\n      throw { status_code: 500, body: \"Error updating metadata\" };\n    }\n  },\n  async onIncomingRequest(req, res, uploadId) {\n    // Check if this is a new upload or continuation\n    if (req.method === \"POST\" && !uploadId) {\n      // For new uploads, we need to parse the Upload-Metadata header to get linkId and dataroomId\n      const metadataHeader = req.headers[\"upload-metadata\"];\n\n      if (!metadataHeader) {\n        throw { status_code: 403, body: \"Missing upload metadata\" };\n      }\n\n      // Parse TUS metadata (format: key base64value,key2 base64value2)\n      const metadata: Record<string, string> = {};\n      const headerString = Array.isArray(metadataHeader)\n        ? metadataHeader[0]\n        : metadataHeader;\n      headerString.split(\",\").forEach((item: string) => {\n        const [key, value] = item.trim().split(\" \");\n        if (key && value) {\n          metadata[key] = Buffer.from(value, \"base64\").toString();\n        }\n      });\n\n      const { linkId, dataroomId, viewerId } = metadata;\n\n      if (!linkId || !dataroomId) {\n        throw { status_code: 403, body: \"Missing required metadata\" };\n      }\n\n      // Verify the session\n      const session = await verifyDataroomSessionInPagesRouter(\n        req as NextApiRequest,\n        linkId,\n        dataroomId,\n      );\n\n      if (!session) {\n        throw { status_code: 403, body: \"Unauthorized\" };\n      }\n\n      // Optional: Verify that the viewerId in the request matches the session\n      if (viewerId && session.viewerId && viewerId !== session.viewerId) {\n        throw { status_code: 403, body: \"Invalid viewer\" };\n      }\n    }\n  },\n});\n\n// CORS headers to allow custom domains\nconst setCorsHeaders = (req: NextApiRequest, res: NextApiResponse) => {\n  // Set CORS headers\n  res.setHeader(\"Access-Control-Allow-Credentials\", \"true\");\n  res.setHeader(\"Access-Control-Allow-Origin\", req.headers.origin || \"*\");\n  res.setHeader(\n    \"Access-Control-Allow-Methods\",\n    \"POST, GET, OPTIONS, DELETE, PATCH, HEAD\",\n  );\n  res.setHeader(\n    \"Access-Control-Allow-Headers\",\n    \"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Upload-Length, Upload-Metadata, Upload-Offset, Tus-Resumable, Upload-Defer-Length, Upload-Concat\",\n  );\n  res.setHeader(\n    \"Access-Control-Expose-Headers\",\n    \"Upload-Offset, Location, Upload-Length, Tus-Version, Tus-Resumable, Tus-Max-Size, Tus-Extension, Upload-Metadata, Upload-Defer-Length, Upload-Concat\",\n  );\n};\n\nexport default function handler(req: NextApiRequest, res: NextApiResponse) {\n  // Handle CORS preflight requests\n  if (req.method === \"OPTIONS\") {\n    setCorsHeaders(req, res);\n    return res.status(204).end();\n  }\n\n  // Set CORS headers for all requests\n  setCorsHeaders(req, res);\n\n  // No session check - authentication is handled via viewer metadata\n  return tusServer.handle(req, res);\n}\n"
  },
  {
    "path": "pages/api/health.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\n\nexport default async function handler(\n  _req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  try {\n    await prisma.$queryRaw`SELECT 1`;\n\n    return res.json({\n      status: \"ok\",\n      message: \"All systems operational\",\n    });\n  } catch (err) {\n    console.error(err);\n\n    return res.status(500).json({\n      status: \"error\",\n      message: (err as Error).message,\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/internal/billing/automatic-unpause.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/billing/cancellation/api/automatic-unpause-route\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/jobs/get-thumbnail.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport { getFileForDocumentPage } from \"@/lib/documents/get-file-helper\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { success } = await ratelimit(150, \"1 m\").limit(\n    `get-thumbnail:${(session.user as CustomUser).id}`,\n  );\n  if (!success) {\n    return res.status(429).json({ message: \"Too many requests\" });\n  }\n\n  const { documentId, pageNumber, versionNumber } = req.query as {\n    documentId: string;\n    pageNumber: string;\n    versionNumber: string;\n  };\n\n  try {\n    const imageUrl = await getFileForDocumentPage(\n      Number(pageNumber),\n      documentId,\n      versionNumber === \"undefined\" ? undefined : Number(versionNumber),\n    );\n\n    return res.status(200).json({ imageUrl });\n  } catch (error) {\n    res.status(500).json({ message: (error as Error).message });\n    return;\n  }\n}\n"
  },
  {
    "path": "pages/api/jobs/process-download-batch.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\nimport { InvocationType, InvokeCommand } from \"@aws-sdk/client-lambda\";\n\nimport { getLambdaClientForTeam } from \"@/lib/files/aws-client\";\n\n// Internal API endpoint for processing download batches\n// Called by Trigger.dev task - authenticated via shared secret\nexport const config = {\n  maxDuration: 300, // 5 minutes for Lambda invocation\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  try {\n    const {\n      teamId,\n      sourceBucket,\n      fileKeys,\n      folderStructure,\n      watermarkConfig,\n      zipPartNumber,\n      totalParts,\n      dataroomName,\n      zipFileName,\n      expirationHours,\n    } = req.body;\n\n    if (!teamId || !sourceBucket || !fileKeys || !folderStructure) {\n      return res.status(400).json({ error: \"Missing required parameters\" });\n    }\n\n    // Get Lambda client and storage config using team credentials\n    const [client, storageConfig] = await Promise.all([\n      getLambdaClientForTeam(teamId),\n      getTeamStorageConfigById(teamId),\n    ]);\n\n    const params = {\n      FunctionName: storageConfig.lambdaFunctionName,\n      InvocationType: InvocationType.RequestResponse,\n      Payload: JSON.stringify({\n        sourceBucket,\n        fileKeys,\n        folderStructure,\n        watermarkConfig: watermarkConfig || { enabled: false },\n        zipPartNumber,\n        totalParts,\n        dataroomName,\n        zipFileName,\n        expirationHours,\n      }),\n    };\n\n    const command = new InvokeCommand(params);\n    const response = await client.send(command);\n\n    if (!response.Payload) {\n      throw new Error(\"Lambda response payload is undefined or empty\");\n    }\n\n    const decodedPayload = new TextDecoder().decode(response.Payload);\n    const payload = JSON.parse(decodedPayload);\n\n    // Check for Lambda errors\n    if (payload.errorMessage) {\n      throw new Error(`Lambda error: ${payload.errorMessage}`);\n    }\n\n    const body = JSON.parse(payload.body);\n\n    // Parse the presigned URL to extract S3 key info for on-demand re-signing\n    let s3KeyInfo: { bucket: string; key: string; region: string } | undefined;\n    try {\n      const { parseS3PresignedUrl } = await import(\n        \"@/lib/files/bulk-download-presign\"\n      );\n      s3KeyInfo = parseS3PresignedUrl(body.downloadUrl);\n    } catch {\n      // Non-fatal: fall back to stored presigned URL\n    }\n\n    return res\n      .status(200)\n      .json({ downloadUrl: body.downloadUrl, s3KeyInfo });\n  } catch (error) {\n    console.error(\"Error processing download batch:\", error);\n    return res.status(500).json({\n      error: \"Failed to process download batch\",\n      details: (error as Error).message,\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/jobs/send-conversation-new-message-notification.ts",
    "content": "import defaultHandler from \"@/ee/features/conversations/api/send-conversation-new-message-notification\";\n\nexport default defaultHandler;\n"
  },
  {
    "path": "pages/api/jobs/send-conversation-team-member-notification.ts",
    "content": "// API route: /api/jobs/send-conversation-team-member-notification\nexport { default } from \"@/ee/features/conversations/api/send-conversation-team-member-notification\";"
  },
  {
    "path": "pages/api/jobs/send-dataroom-new-document-notification.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { sendDataroomNotification } from \"@/lib/emails/send-dataroom-notification\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\nimport { generateUnsubscribeUrl } from \"@/lib/utils/unsubscribe\";\n\nexport const config = {\n  maxDuration: 120,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  const {\n    linkUrl,\n    dataroomId,\n    dataroomDocumentId,\n    viewerId,\n    senderUserId,\n    teamId,\n  } = req.body as {\n    linkUrl: string;\n    dataroomId: string;\n    dataroomDocumentId: string;\n    viewerId: string;\n    senderUserId: string;\n    teamId: string;\n  };\n\n  let viewer: { email: string } | null = null;\n\n  try {\n    // Fetch the link to verify the settings\n    viewer = await prisma.viewer.findUnique({\n      where: {\n        id: viewerId,\n        teamId,\n      },\n      select: {\n        email: true,\n      },\n    });\n\n    if (!viewer) {\n      res.status(404).json({ message: \"Viewer not found.\" });\n      return;\n    }\n  } catch (error) {\n    log({\n      message: `Failed to find viewer for viewerId: ${viewerId}. \\n\\n Error: ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    res.status(500).json({ message: (error as Error).message });\n    return;\n  }\n\n  // POST /api/jobs/send-datarooom-notification\n  try {\n    // Fetch the document to verify the settings\n    const document = await prisma.dataroomDocument.findUnique({\n      where: {\n        id: dataroomDocumentId,\n        dataroomId: dataroomId,\n      },\n      select: {\n        document: {\n          select: {\n            name: true,\n          },\n        },\n        dataroom: {\n          select: {\n            name: true,\n          },\n        },\n      },\n    });\n\n    const user = await prisma.user.findUnique({\n      where: { id: senderUserId },\n      select: { email: true },\n    });\n\n    if (!user) {\n      res.status(404).json({ message: \"Sender not found.\" });\n      return;\n    }\n\n    const unsubscribeUrl = generateUnsubscribeUrl({\n      viewerId,\n      dataroomId,\n      teamId,\n    });\n\n    await sendDataroomNotification({\n      dataroomName: document?.dataroom?.name || \"\",\n      documentName: document?.document?.name || \"\",\n      senderEmail: user.email!,\n      to: viewer.email!,\n      url: linkUrl,\n      unsubscribeUrl,\n    });\n\n    res.status(200).json({\n      message: \"Successfully sent dataroom change notification\",\n      viewerId,\n    });\n    return;\n  } catch (error) {\n    log({\n      message: `Failed to send invite email for dataroom ${dataroomId} to viewer: ${viewerId}. \\n\\n Error: ${error} \\n\\n*Metadata*: \\`{dataroomId: ${dataroomId}, viewerId: ${viewerId}}\\``,\n      type: \"error\",\n      mention: true,\n    });\n    return res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/jobs/send-dataroom-upload-notification.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { sendDataroomUploadNotification } from \"@/lib/emails/send-dataroom-upload-notification\";\nimport { log } from \"@/lib/utils\";\n\nexport const config = {\n  maxDuration: 120,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1];\n\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  const {\n    dataroomId,\n    dataroomName,\n    uploaderEmail,\n    documentNames,\n    linkName,\n    ownerEmail,\n    teamMembers,\n  } = req.body as {\n    dataroomId: string;\n    dataroomName: string;\n    uploaderEmail: string | null;\n    documentNames: string[];\n    linkName: string;\n    ownerEmail: string;\n    teamMembers: string[];\n    teamId: string;\n  };\n\n  try {\n    await sendDataroomUploadNotification({\n      ownerEmail,\n      dataroomId,\n      dataroomName,\n      uploaderEmail,\n      documentNames,\n      linkName,\n      teamMembers,\n    });\n\n    res.status(200).json({\n      message: \"Successfully sent dataroom upload notification\",\n    });\n    return;\n  } catch (error) {\n    log({\n      message: `Failed to send dataroom upload notification for dataroom ${dataroomId}. \\n\\n Error: ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    return res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/jobs/send-notification.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\n\nimport { sendViewedDataroomEmail } from \"@/lib/emails/send-viewed-dataroom\";\nimport { sendViewedDataroomPausedEmail } from \"@/lib/emails/send-viewed-dataroom-paused\";\nimport { sendViewedDocumentEmail } from \"@/lib/emails/send-viewed-document\";\nimport { sendViewedDocumentPausedEmail } from \"@/lib/emails/send-viewed-document-paused\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nexport const config = {\n  maxDuration: 60,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  const { viewId, locationData } = req.body as {\n    viewId: string;\n    locationData: {\n      continent: string | null;\n      country: string;\n      region: string;\n      city: string;\n    };\n  };\n\n  let view: {\n    viewType: \"DOCUMENT_VIEW\" | \"DATAROOM_VIEW\";\n    viewerEmail: string | null;\n    linkId: string;\n    link: { name: string | null; ownerId: string | null } | null;\n    document: {\n      teamId: string | null;\n      id: string;\n      name: string;\n      ownerId: string | null;\n    } | null;\n    dataroom: {\n      teamId: string | null;\n      id: string;\n      name: string;\n    } | null;\n    team: {\n      plan: string | null;\n      ignoredDomains: string[] | null;\n      pauseStartsAt: Date | null;\n    } | null;\n  } | null;\n\n  try {\n    // Fetch the view with data\n    view = await prisma.view.findUnique({\n      where: {\n        id: viewId,\n      },\n      select: {\n        viewType: true,\n        viewerEmail: true,\n        linkId: true,\n        link: {\n          select: {\n            name: true,\n            ownerId: true,\n          },\n        },\n        document: {\n          select: {\n            teamId: true,\n            id: true,\n            name: true,\n            ownerId: true,\n          },\n        },\n        dataroom: {\n          select: {\n            teamId: true,\n            id: true,\n            name: true,\n          },\n        },\n        team: {\n          select: {\n            plan: true,\n            ignoredDomains: true,\n            pauseStartsAt: true,\n          },\n        },\n      },\n    });\n\n    if (!view) {\n      res.status(404).json({ message: \"View not found.\" });\n      return;\n    }\n  } catch (error) {\n    log({\n      message: `Failed to find document / dataroom view for viewId: ${viewId}. \\n\\n Error: ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    res.status(500).json({ message: (error as Error).message });\n    return;\n  }\n\n  const teamId =\n    view.viewType === \"DOCUMENT_VIEW\"\n      ? view.document!.teamId!\n      : view.dataroom!.teamId!;\n\n  if (view.viewerEmail) {\n    const viewerDomain = view.viewerEmail.split(\"@\").pop();\n    if (viewerDomain) {\n      if (view?.team?.ignoredDomains) {\n        const ignoredDomainList = view.team.ignoredDomains.map((d) =>\n          d.startsWith(\"@\") ? d.substring(1) : d,\n        );\n\n        if (ignoredDomainList.includes(viewerDomain)) {\n          return res.status(200).json({\n            message: \"Notification skipped for ignored domain.\",\n            viewId,\n          });\n        }\n      }\n    }\n  }\n\n  // Get all active team members who are admins or managers to be notified\n  const users = await prisma.userTeam.findMany({\n    where: {\n      role: { in: [\"ADMIN\", \"MANAGER\"] },\n      status: \"ACTIVE\",\n      teamId: teamId,\n    },\n    select: {\n      role: true,\n      user: {\n        select: {\n          email: true,\n        },\n      },\n    },\n  });\n\n  // Fetch document owner and link owner emails in parallel (async-parallel best practice)\n  const [ownerEmail, linkOwnerEmail] = await Promise.all([\n    // Get the active owner of the document\n    view.document?.ownerId\n      ? prisma.userTeam\n          .findUnique({\n            where: {\n              userId_teamId: {\n                userId: view.document.ownerId,\n                teamId: teamId,\n              },\n              status: \"ACTIVE\",\n            },\n            select: {\n              user: {\n                select: {\n                  email: true,\n                },\n              },\n            },\n          })\n          .then((result) => result?.user.email || null)\n      : null,\n    // Get the active owner of the link\n    view.link?.ownerId\n      ? prisma.userTeam\n          .findUnique({\n            where: {\n              userId_teamId: {\n                userId: view.link.ownerId,\n                teamId: teamId,\n              },\n              status: \"ACTIVE\",\n            },\n            select: {\n              user: {\n                select: {\n                  email: true,\n                },\n              },\n            },\n          })\n          .then((result) => result?.user.email || null)\n      : null,\n  ]);\n\n  const includeLocation =\n    !view.team?.plan?.includes(\"free\") &&\n    !view.team?.plan?.includes(\"starter\") &&\n    !view.team?.plan?.includes(\"pro\");\n\n  const locationString =\n    locationData.country === \"US\"\n      ? `${locationData.city}, ${locationData.region}, ${locationData.country}`\n      : `${locationData.city}, ${locationData.country}`;\n\n  // POST /api/jobs/send-notification\n  try {\n    const adminEmail = users.find((user) => user.role === \"ADMIN\")?.user.email;\n\n    // Guard: ensure we have an admin email to send notifications to\n    if (!adminEmail) {\n      log({\n        message: `No admin email found for team when sending notification. \\n\\n*Metadata*: \\`{teamId: ${teamId}, viewId: ${viewId}}\\``,\n        type: \"error\",\n      });\n      return res.status(400).json({ message: \"No admin email found for team\" });\n    }\n\n    // Check if team is paused\n    const teamIsPaused = await isTeamPausedById(teamId);\n\n    if (view.viewType === \"DOCUMENT_VIEW\") {\n      const teamMembers = users\n        .map((user) => user.user.email!)\n        .filter((email) => email !== adminEmail);\n\n      // Add ownerEmail to teamMembers if it exists and isn't already included\n      if (\n        ownerEmail &&\n        ownerEmail !== adminEmail &&\n        !teamMembers.includes(ownerEmail)\n      ) {\n        teamMembers.push(ownerEmail);\n      }\n\n      // Add linkOwnerEmail to teamMembers if it exists and isn't already included\n      if (\n        linkOwnerEmail &&\n        linkOwnerEmail !== adminEmail &&\n        linkOwnerEmail !== ownerEmail &&\n        !teamMembers.includes(linkOwnerEmail)\n      ) {\n        teamMembers.push(linkOwnerEmail);\n      }\n\n      // send appropriate email based on team pause status\n      if (teamIsPaused) {\n        await sendViewedDocumentPausedEmail({\n          ownerEmail: adminEmail,\n          documentName: view.document!.name,\n          linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,\n          teamMembers,\n        });\n      } else {\n        await sendViewedDocumentEmail({\n          ownerEmail: adminEmail,\n          documentId: view.document!.id,\n          documentName: view.document!.name,\n          linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,\n          viewerEmail: view.viewerEmail,\n          teamMembers,\n          locationString: includeLocation ? locationString : undefined,\n        });\n      }\n    } else {\n      const teamMembers = users\n        .map((user) => user.user.email!)\n        .filter((email) => email !== adminEmail);\n\n      // Add linkOwnerEmail to teamMembers if it exists and isn't already included\n      if (\n        linkOwnerEmail &&\n        linkOwnerEmail !== adminEmail &&\n        !teamMembers.includes(linkOwnerEmail)\n      ) {\n        teamMembers.push(linkOwnerEmail);\n      }\n\n      // send appropriate email based on team pause status\n      if (teamIsPaused) {\n        await sendViewedDataroomPausedEmail({\n          ownerEmail: adminEmail,\n          dataroomName: view.dataroom!.name,\n          linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,\n          teamMembers,\n        });\n      } else {\n        await sendViewedDataroomEmail({\n          ownerEmail: adminEmail,\n          dataroomId: view.dataroom!.id,\n          dataroomName: view.dataroom!.name,\n          viewerEmail: view.viewerEmail,\n          linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,\n          teamMembers,\n          locationString: includeLocation ? locationString : undefined,\n        });\n      }\n    }\n\n    res.status(200).json({ message: \"Successfully sent notification\", viewId });\n    return;\n  } catch (error) {\n    log({\n      message: `Failed to send email in _/api/views_ route for linkId: ${view.linkId}. \\n\\n Error: ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, viewId: ${viewId}}\\``,\n      type: \"error\",\n      mention: true,\n    });\n    return res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/jobs/send-pause-resume-notification.ts",
    "content": "// API route: /api/jobs/send-pause-resume-notification\nexport { default } from \"@/ee/features/billing/cancellation/api/send-pause-resume-notification\";\n"
  },
  {
    "path": "pages/api/links/[id]/annotations.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { AnnotationImage, DocumentAnnotation } from \"@prisma/client\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const { id: linkId, viewId } = req.query as { id: string; viewId: string };\n\n  try {\n    const view = await prisma.view.findUnique({\n      where: { id: viewId, linkId: linkId },\n      include: {\n        link: true,\n        document: {\n          include: {\n            annotations: {\n              where: {\n                isVisible: true, // Only return visible annotations for viewers\n              },\n              include: {\n                images: true,\n              },\n              orderBy: {\n                createdAt: \"desc\",\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!view) {\n      return res.status(404).json({ error: \"Annotation not found\" });\n    }\n\n    if (view.link.deletedAt) {\n      return res.status(404).json({ error: \"Link deleted\" });\n    }\n\n    if (view.viewedAt < new Date(Date.now() - 1000 * 60 * 60 * 23)) {\n      // if view is older than 23 hours, we should not allow the annotations to be accessed\n      return res.status(404).json({ error: \"Annotation not found\" });\n    }\n\n    // Check if annotations feature is enabled for this team\n    const featureFlags = await getFeatureFlags({\n      teamId: view.teamId || undefined,\n    });\n    if (!featureFlags.annotations) {\n      return res.status(200).json([]); // Return empty array if feature is disabled\n    }\n\n    // This endpoint only handles DOCUMENT_LINK types\n    // For DATAROOM_LINK types, use /api/links/[id]/documents/[documentId]/annotations\n    let annotations: (DocumentAnnotation & { images: AnnotationImage[] })[] =\n      [];\n    if (view.link.linkType === \"DOCUMENT_LINK\" && view.document) {\n      annotations = view.document.annotations || [];\n    } else if (view.link.linkType === \"DATAROOM_LINK\") {\n      // For dataroom links, return empty array - they should use the specific document endpoint\n      annotations = [];\n    }\n\n    // Remove sensitive information (don't expose createdBy details to viewers)\n    const sanitizedAnnotations = annotations.map((annotation) => ({\n      id: annotation.id,\n      title: annotation.title,\n      content: annotation.content,\n      pages: annotation.pages,\n      images: annotation.images,\n      createdAt: annotation.createdAt,\n    }));\n\n    return res.status(200).json(sanitizedAnnotations);\n  } catch (error) {\n    log({\n      message: `Failed to get annotations for link: _${linkId}_. \\n\\n ${error}`,\n      type: \"error\",\n    });\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/links/[id]/archive.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PUT\") {\n    // PUT /api/links/:id/archive\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { id } = req.query as { id: string };\n\n    const { isArchived } = req.body;\n\n    try {\n      // Update the link in the database\n      const updatedLink = await prisma.link.update({\n        where: { id: id, deletedAt: null },\n        data: {\n          isArchived: isArchived,\n        },\n        include: {\n          views: {\n            orderBy: {\n              viewedAt: \"desc\",\n            },\n          },\n          _count: {\n            select: { views: true },\n          },\n          tags: {\n            select: {\n              tag: {\n                select: {\n                  id: true,\n                  name: true,\n                  description: true,\n                  color: true,\n                },\n              },\n            },\n          },\n        },\n      });\n      if (!updatedLink) {\n        return res.status(404).json({ error: \"Link not found\" });\n      }\n\n      const { tags, ...rest } = updatedLink;\n      const linkTags = tags.map((t) => t.tag);\n\n      await fetch(\n        `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&linkId=${id}&hasDomain=${updatedLink.domainId ? \"true\" : \"false\"}`,\n      );\n\n      return res.status(200).json({ ...rest, tags: linkTags });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n\n  // We only allow PUT requests\n  res.setHeader(\"Allow\", [\"PUT\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/links/[id]/documents/[documentId]/annotations.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const {\n    id: linkId,\n    documentId: dataroomDocumentId,\n    viewId,\n  } = req.query as {\n    id: string;\n    documentId: string;\n    viewId: string;\n  };\n\n  try {\n    const view = await prisma.view.findUnique({\n      where: { id: viewId, linkId: linkId },\n      select: {\n        id: true,\n        viewedAt: true,\n        link: {\n          select: {\n            id: true,\n            linkType: true,\n            teamId: true,\n            documentId: true,\n            dataroomId: true,\n            deletedAt: true,\n          },\n        },\n      },\n    });\n\n    if (!view) {\n      return res.status(404).json({ error: \"View not found\" });\n    }\n\n    if (view.link.deletedAt) {\n      return res.status(404).json({ error: \"Link deleted\" });\n    }\n\n    // Check TTL - deny access for views older than 23 hours\n    if (view.viewedAt < new Date(Date.now() - 23 * 60 * 60 * 1000)) {\n      return res.status(403).json({ error: \"Access denied\" });\n    }\n\n    // Check if annotations feature is enabled for this team\n    const featureFlags = await getFeatureFlags({\n      teamId: view.link.teamId || undefined,\n    });\n    if (!featureFlags.annotations) {\n      return res.status(200).json([]); // Return empty array if feature is disabled\n    }\n\n    let document = null;\n\n    if (view.link.linkType === \"DOCUMENT_LINK\") {\n      // For document links, get the document directly\n      document = await prisma.document.findUnique({\n        where: { id: view.link.documentId! },\n        include: {\n          annotations: {\n            where: {\n              isVisible: true, // Only return visible annotations for viewers\n            },\n            include: {\n              images: true,\n            },\n            orderBy: {\n              createdAt: \"desc\",\n            },\n          },\n        },\n      });\n    } else if (view.link.linkType === \"DATAROOM_LINK\") {\n      // For dataroom links, get the specific dataroom document\n      const dataroomDocument = await prisma.dataroomDocument.findFirst({\n        where: {\n          id: dataroomDocumentId,\n          dataroomId: view.link.dataroomId!,\n        },\n        include: {\n          document: {\n            include: {\n              annotations: {\n                where: {\n                  isVisible: true, // Only return visible annotations for viewers\n                },\n                include: {\n                  images: true,\n                },\n                orderBy: {\n                  createdAt: \"desc\",\n                },\n              },\n            },\n          },\n        },\n      });\n\n      document = dataroomDocument?.document;\n    }\n\n    if (!document) {\n      return res.status(404).json({ error: \"Document not found\" });\n    }\n\n    const annotations = document.annotations || [];\n\n    // Remove sensitive information (don't expose createdBy details to viewers)\n    const sanitizedAnnotations = annotations.map((annotation) => ({\n      id: annotation.id,\n      title: annotation.title,\n      content: annotation.content,\n      pages: annotation.pages,\n      images: annotation.images,\n      createdAt: annotation.createdAt,\n    }));\n\n    return res.status(200).json(sanitizedAnnotations);\n  } catch (error) {\n    log({\n      message: `Failed to get annotations for link: _${linkId}_ and document: _${dataroomDocumentId}_. \\n\\n ${error}`,\n      type: \"error\",\n    });\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/links/[id]/documents/[documentId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { DataroomBrand, LinkAudienceType } from \"@prisma/client\";\n\nimport { fetchDataroomDocumentLinkData } from \"@/lib/api/links/link-data\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { checkGlobalBlockList } from \"@/lib/utils/global-block-list\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const { id, documentId: dataroomDocumentId } = req.query as {\n    id: string;\n    documentId: string;\n  };\n\n  try {\n    // First fetch the link and verify it's a dataroom link\n    const link = await prisma.link.findUnique({\n      where: { id, linkType: \"DATAROOM_LINK\" },\n      select: {\n        id: true,\n        expiresAt: true,\n        emailProtected: true,\n        emailAuthenticated: true,\n        allowDownload: true,\n        enableFeedback: true,\n        enableScreenshotProtection: true,\n        password: true,\n        isArchived: true,\n        deletedAt: true,\n        enableCustomMetatag: true,\n        metaTitle: true,\n        metaDescription: true,\n        metaImage: true,\n        metaFavicon: true,\n        welcomeMessage: true,\n        enableQuestion: true,\n        linkType: true,\n        feedback: {\n          select: {\n            id: true,\n            data: true,\n          },\n        },\n        enableAgreement: true,\n        agreement: true,\n        showBanner: true,\n        enableWatermark: true,\n        watermarkConfig: true,\n        groupId: true,\n        permissionGroupId: true,\n        audienceType: true,\n        dataroomId: true,\n        teamId: true,\n        team: {\n          select: {\n            plan: true,\n            globalBlockList: true,\n          },\n        },\n        customFields: {\n          select: {\n            id: true,\n            type: true,\n            identifier: true,\n            label: true,\n            placeholder: true,\n            required: true,\n            disabled: true,\n            orderIndex: true,\n          },\n          orderBy: {\n            orderIndex: \"asc\",\n          },\n        },\n      },\n    });\n\n    if (!link) {\n      return res.status(404).json({ error: \"Link not found\" });\n    }\n\n    if (link.deletedAt) {\n      return res.status(404).json({ error: \"Link has been deleted\" });\n    }\n\n    if (link.isArchived) {\n      return res.status(404).json({ error: \"Link is archived\" });\n    }\n\n    const { email } = req.query as { email?: string };\n    const globalBlockCheck = checkGlobalBlockList(\n      email,\n      link.team?.globalBlockList,\n    );\n    if (globalBlockCheck.error) {\n      return res.status(400).json({ message: globalBlockCheck.error });\n    }\n    if (globalBlockCheck.isBlocked) {\n      return res.status(403).json({ message: \"Access denied\" });\n    }\n\n    let brand: Partial<DataroomBrand> | null = null;\n    let linkData: any;\n\n    const data = await fetchDataroomDocumentLinkData({\n      linkId: id,\n      teamId: link.teamId!,\n      dataroomDocumentId: dataroomDocumentId,\n      permissionGroupId: link.permissionGroupId || undefined,\n      ...(link.audienceType === LinkAudienceType.GROUP &&\n        link.groupId && {\n          groupId: link.groupId,\n        }),\n    });\n\n    linkData = data.linkData;\n    brand = data.brand;\n\n    const teamPlan = link.team?.plan || \"free\";\n    const linkType = link.linkType;\n\n    const returnLink = {\n      ...link,\n      dataroomDocument: linkData.dataroom?.documents[0],\n      ...(teamPlan === \"free\" && {\n        customFields: [], // reset custom fields for free plan\n        enableAgreement: false,\n        enableWatermark: false,\n        permissionGroupId: null,\n      }),\n    };\n\n    return res.status(200).json({ linkType, link: returnLink, brand });\n  } catch (error) {\n    console.error(\"Error fetching document:\", error);\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/links/[id]/duplicate.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { sendLinkCreatedWebhook } from \"@/lib/webhook/triggers/link-created\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // PUT /api/links/:id/duplicate\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { id } = req.query as { id: string };\n    const { teamId } = req.body as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New link creation is not available.\",\n        });\n      }\n\n      const link = await prisma.link.findUnique({\n        where: { id, teamId },\n        include: {\n          tags: {\n            select: {\n              tag: {\n                select: {\n                  id: true,\n                },\n              },\n            },\n          },\n          permissionGroup: {\n            include: {\n              accessControls: true,\n            },\n          },\n          customFields: true,\n          visitorGroups: true,\n        },\n      });\n\n      if (!link) {\n        return res.status(404).json({ error: \"Link not found\" });\n      }\n\n      if (link.deletedAt) {\n        return res.status(404).json({ error: \"Link has been deleted\" });\n      }\n\n      const {\n        tags,\n        permissionGroup,\n        permissionGroupId,\n        customFields,\n        visitorGroups,\n        ...rest\n      } = link;\n      const linkTags = tags.map((t) => t.tag.id);\n\n      const newLinkName = link.name\n        ? link.name + \" (Copy)\"\n        : `Link #${link.id.slice(-5)} (Copy)`;\n\n      const newLink = await prisma.$transaction(async (tx) => {\n        // Duplicate permission group if it exists\n        let newPermissionGroupId: string | null = null;\n        if (permissionGroup) {\n          // Create the new permission group\n          const newPermissionGroup = await tx.permissionGroup.create({\n            data: {\n              name: permissionGroup.name + \" (Copy)\",\n              description: permissionGroup.description,\n              dataroomId: permissionGroup.dataroomId,\n              teamId: permissionGroup.teamId,\n            },\n          });\n\n          // Duplicate all access controls\n          if (permissionGroup.accessControls.length > 0) {\n            await tx.permissionGroupAccessControls.createMany({\n              data: permissionGroup.accessControls.map((control) => ({\n                groupId: newPermissionGroup.id,\n                itemId: control.itemId,\n                itemType: control.itemType,\n                canView: control.canView,\n                canDownload: control.canDownload,\n                canDownloadOriginal: control.canDownloadOriginal,\n              })),\n            });\n          }\n\n          newPermissionGroupId = newPermissionGroup.id;\n        }\n\n        const createdLink = await tx.link.create({\n          data: {\n            ...rest,\n            id: undefined,\n            slug: link.slug ? link.slug + \"-copy\" : null,\n            name: newLinkName,\n            watermarkConfig: link.watermarkConfig || Prisma.JsonNull,\n            createdAt: undefined,\n            updatedAt: undefined,\n            permissionGroupId: newPermissionGroupId,\n            ownerId: userId,\n            ...(customFields.length > 0 && {\n              customFields: {\n                createMany: {\n                  data: customFields.map((field) => ({\n                    type: field.type,\n                    identifier: field.identifier,\n                    label: field.label,\n                    placeholder: field.placeholder,\n                    required: field.required,\n                    disabled: field.disabled,\n                    orderIndex: field.orderIndex,\n                  })),\n                },\n              },\n            }),\n            ...(visitorGroups.length > 0 && {\n              visitorGroups: {\n                createMany: {\n                  data: visitorGroups.map((vg) => ({\n                    visitorGroupId: vg.visitorGroupId,\n                  })),\n                },\n              },\n            }),\n          },\n          include: {\n            customFields: true,\n            visitorGroups: true,\n          },\n        });\n\n        if (linkTags?.length) {\n          await tx.tagItem.createMany({\n            data: linkTags.map((tagId: string) => ({\n              tagId,\n              itemType: \"LINK_TAG\",\n              linkId: createdLink.id,\n              taggedBy: (session.user as CustomUser).id,\n            })),\n            skipDuplicates: true,\n          });\n        }\n\n        const tags = linkTags?.length\n          ? await tx.tag.findMany({\n              where: { id: { in: linkTags } },\n              select: { id: true, name: true, color: true, description: true },\n            })\n          : [];\n\n        return { ...createdLink, tags };\n      });\n      const linkWithView = {\n        ...newLink,\n        _count: { views: 0 },\n        views: [],\n      };\n\n      waitUntil(\n        sendLinkCreatedWebhook({\n          teamId,\n          data: {\n            link_id: newLink.id,\n            document_id: newLink.documentId,\n            dataroom_id: newLink.dataroomId,\n          },\n        }),\n      );\n\n      return res.status(201).json(linkWithView);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n\n  // We only allow PUT requests\n  res.setHeader(\"Allow\", [\"POST\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/links/[id]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { Brand, DataroomBrand, LinkAudienceType } from \"@prisma/client\";\nimport { customAlphabet } from \"nanoid\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport {\n  fetchDataroomLinkData,\n  fetchDocumentLinkData,\n} from \"@/lib/api/links/link-data\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser, WatermarkConfigSchema } from \"@/lib/types\";\nimport {\n  decryptEncrpytedPassword,\n  generateEncrpytedPassword,\n} from \"@/lib/utils\";\nimport { checkGlobalBlockList } from \"@/lib/utils/global-block-list\";\n\nimport { DomainObject } from \"..\";\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/links/:id\n    const { id } = req.query as { id: string };\n\n    try {\n      console.time(\"get-link\");\n      const link = await prisma.link.findUnique({\n        where: {\n          id: id,\n        },\n        select: {\n          id: true,\n          expiresAt: true,\n          emailProtected: true,\n          emailAuthenticated: true,\n          allowDownload: true,\n          enableFeedback: true,\n          enableScreenshotProtection: true,\n          password: true,\n          isArchived: true,\n          deletedAt: true,\n          enableIndexFile: true,\n          enableCustomMetatag: true,\n          metaTitle: true,\n          metaDescription: true,\n          metaImage: true,\n          metaFavicon: true,\n          welcomeMessage: true,\n          enableQuestion: true,\n          linkType: true,\n          feedback: {\n            select: {\n              id: true,\n              data: true,\n            },\n          },\n          enableAgreement: true,\n          agreement: true,\n          showBanner: true,\n          enableWatermark: true,\n          watermarkConfig: true,\n          groupId: true,\n          permissionGroupId: true,\n          audienceType: true,\n          dataroomId: true,\n          teamId: true,\n          team: {\n            select: {\n              plan: true,\n              globalBlockList: true,\n            },\n          },\n          customFields: {\n            select: {\n              id: true,\n              type: true,\n              identifier: true,\n              label: true,\n              placeholder: true,\n              required: true,\n              disabled: true,\n              orderIndex: true,\n            },\n            orderBy: {\n              orderIndex: \"asc\",\n            },\n          },\n        },\n      });\n\n      console.timeEnd(\"get-link\");\n\n      if (!link) {\n        return res.status(404).json({ error: \"Link not found\" });\n      }\n\n      if (link.deletedAt) {\n        return res.status(404).json({ error: \"Link has been deleted\" });\n      }\n\n      if (link.isArchived) {\n        return res.status(404).json({ error: \"Link is archived\" });\n      }\n\n      const { email } = req.query as { email?: string };\n      const globalBlockCheck = checkGlobalBlockList(\n        email,\n        link.team?.globalBlockList,\n      );\n      if (globalBlockCheck.error) {\n        return res.status(400).json({ message: globalBlockCheck.error });\n      }\n      if (globalBlockCheck.isBlocked) {\n        return res.status(403).json({ message: \"Access denied\" });\n      }\n\n      const linkType = link.linkType;\n\n      // Handle workflow links separately\n      if (linkType === \"WORKFLOW_LINK\") {\n        // For workflow links, fetch brand if available\n        let brand: Partial<Brand> | null = null;\n        if (link.teamId) {\n          const teamBrand = await prisma.brand.findUnique({\n            where: { teamId: link.teamId },\n            select: {\n              logo: true,\n              brandColor: true,\n              accentColor: true,\n            },\n          });\n          brand = teamBrand;\n        }\n\n        return res.status(200).json({ linkType, brand });\n      }\n\n      let brand: Partial<Brand> | Partial<DataroomBrand> | null = null;\n      let linkData: any;\n\n      if (linkType === \"DOCUMENT_LINK\") {\n        console.time(\"get-document-link-data\");\n        const data = await fetchDocumentLinkData({\n          linkId: id,\n          teamId: link.teamId!,\n        });\n        linkData = data.linkData;\n        brand = data.brand;\n        console.timeEnd(\"get-document-link-data\");\n      } else if (linkType === \"DATAROOM_LINK\") {\n        console.time(\"get-dataroom-link-data\");\n        const data = await fetchDataroomLinkData({\n          linkId: id,\n          dataroomId: link.dataroomId,\n          teamId: link.teamId!,\n          permissionGroupId: link.permissionGroupId || undefined,\n          ...(link.audienceType === LinkAudienceType.GROUP &&\n            link.groupId && {\n              groupId: link.groupId,\n            }),\n        });\n        linkData = data.linkData;\n        brand = data.brand;\n        // Include access controls in the link data for the frontend\n        linkData.accessControls = data.accessControls;\n        console.timeEnd(\"get-dataroom-link-data\");\n      }\n\n      const teamPlan = link.team?.plan || \"free\";\n\n      const returnLink = {\n        ...link,\n        ...linkData,\n        dataroomId: undefined,\n        ...(teamPlan === \"free\" && {\n          customFields: [], // reset custom fields for free plan\n          enableAgreement: false,\n          enableWatermark: false,\n          permissionGroupId: null,\n        }),\n      };\n\n      return res.status(200).json({ linkType, link: returnLink, brand });\n    } catch (error) {\n      console.error(\"Error fetching link data:\", error);\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n  } else if (req.method === \"PUT\") {\n    // PUT /api/links/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { id } = req.query as { id: string };\n    const {\n      targetId,\n      linkType,\n      password,\n      expiresAt,\n      teamId,\n      ...linkDomainData\n    } = req.body;\n\n    const dataroomLink = linkType === \"DATAROOM_LINK\";\n    const documentLink = linkType === \"DOCUMENT_LINK\";\n\n    try {\n      const existingLink = await prisma.link.findUnique({\n        where: {\n          id: id,\n          teamId: teamId,\n          team: {\n            users: {\n              some: { userId },\n            },\n          },\n        },\n      });\n\n      if (!existingLink) {\n        return res\n          .status(404)\n          .json({ error: \"Link not found or unauthorized\" });\n      }\n    } catch (error) {\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n\n    const hashedPassword =\n      password && password.length > 0\n        ? await generateEncrpytedPassword(password)\n        : null;\n    const exat = expiresAt ? new Date(expiresAt) : null;\n\n    let { domain, slug, ...linkData } = linkDomainData;\n\n    // set domain and slug to null if the domain is papermark.com\n    if (domain && domain === \"papermark.com\") {\n      domain = null;\n      slug = null;\n    }\n\n    let domainObj: DomainObject | null;\n\n    if (domain && slug) {\n      domainObj = await prisma.domain.findUnique({\n        where: {\n          slug: domain,\n        },\n      });\n\n      if (!domainObj) {\n        return res.status(400).json({ error: \"Domain not found.\" });\n      }\n\n      const currentLink = await prisma.link.findUnique({\n        where: { id: id },\n        select: {\n          id: true,\n          domainSlug: true,\n          slug: true,\n        },\n      });\n\n      // if the slug or domainSlug has changed, check if the new slug is unique\n      if (currentLink?.slug !== slug || currentLink?.domainSlug !== domain) {\n        const existingLink = await prisma.link.findUnique({\n          where: {\n            domainSlug_slug: {\n              slug: slug,\n              domainSlug: domain,\n            },\n          },\n        });\n\n        if (existingLink) {\n          return res.status(400).json({\n            error: \"The link already exists.\",\n          });\n        }\n      }\n    }\n\n    if (linkData.enableAgreement && !linkData.agreementId) {\n      return res.status(400).json({\n        error: \"No agreement selected.\",\n      });\n    }\n\n    if (linkData.enableWatermark) {\n      if (!linkData.watermarkConfig) {\n        return res.status(400).json({\n          error:\n            \"Watermark configuration is required when watermark is enabled.\",\n        });\n      }\n\n      // Validate the watermark config structure\n      const validation = WatermarkConfigSchema.safeParse(\n        linkData.watermarkConfig,\n      );\n      if (!validation.success) {\n        return res.status(400).json({\n          error: \"Invalid watermark configuration.\",\n          details: validation.error.issues\n            .map((issue) => issue.message)\n            .join(\", \"),\n        });\n      }\n    }\n\n    // Validate visitor group IDs belong to this team\n    if (linkData.visitorGroupIds?.length > 0) {\n      const validGroups = await prisma.visitorGroup.findMany({\n        where: {\n          id: { in: linkData.visitorGroupIds },\n          teamId: teamId,\n        },\n        select: { id: true },\n      });\n\n      if (validGroups.length !== linkData.visitorGroupIds.length) {\n        return res.status(400).json({\n          error: \"One or more visitor group IDs do not belong to this team.\",\n        });\n      }\n    }\n\n    const updatedLink = await prisma.$transaction(async (tx) => {\n      const link = await tx.link.update({\n        where: { id, teamId },\n        data: {\n          documentId: documentLink ? targetId : null,\n          dataroomId: dataroomLink ? targetId : null,\n          password: hashedPassword,\n          name: linkData.name || null,\n          emailProtected:\n            linkData.audienceType === LinkAudienceType.GROUP\n              ? true\n              : linkData.emailProtected,\n          emailAuthenticated: linkData.emailAuthenticated,\n          allowDownload: linkData.allowDownload,\n          allowList: linkData.allowList,\n          denyList: linkData.denyList,\n          expiresAt: exat,\n          domainId: domainObj?.id || null,\n          domainSlug: domain || null,\n          slug: slug || null,\n          enableIndexFile: linkData.enableIndexFile || false,\n          enableNotification: linkData.enableNotification,\n          enableFeedback: linkData.enableFeedback,\n          enableScreenshotProtection: linkData.enableScreenshotProtection,\n          enableCustomMetatag: linkData.enableCustomMetatag,\n          metaTitle: linkData.metaTitle || null,\n          metaDescription: linkData.metaDescription || null,\n          metaImage: linkData.metaImage || null,\n          metaFavicon: linkData.metaFavicon || null,\n          welcomeMessage: linkData.welcomeMessage || null,\n          ...(linkData.customFields && {\n            customFields: {\n              deleteMany: {}, // Delete all existing custom fields\n              createMany: {\n                data: linkData.customFields.map(\n                  (field: any, index: number) => ({\n                    type: field.type,\n                    identifier: field.identifier,\n                    label: field.label,\n                    placeholder: field.placeholder,\n                    required: field.required,\n                    disabled: field.disabled,\n                    orderIndex: index,\n                  }),\n                ),\n                skipDuplicates: true,\n              },\n            },\n          }),\n          enableQuestion: linkData.enableQuestion,\n          ...(linkData.enableQuestion && {\n            feedback: {\n              upsert: {\n                create: {\n                  data: {\n                    question: linkData.questionText,\n                    type: linkData.questionType,\n                  },\n                },\n                update: {\n                  data: {\n                    question: linkData.questionText,\n                    type: linkData.questionType,\n                  },\n                },\n              },\n            },\n          }),\n          enableAgreement: linkData.enableAgreement,\n          agreementId: linkData.agreementId || null,\n          showBanner: linkData.showBanner,\n          enableWatermark: linkData.enableWatermark || false,\n          watermarkConfig: linkData.watermarkConfig || null,\n          groupId: linkData.groupId || null,\n          permissionGroupId: linkData.permissionGroupId || null,\n          audienceType: linkData.audienceType || LinkAudienceType.GENERAL,\n          enableConversation: linkData.enableConversation || false,\n          enableAIAgents: linkData.enableAIAgents || false,\n          enableUpload: linkData.enableUpload || false,\n          isFileRequestOnly: linkData.isFileRequestOnly || false,\n          uploadFolderId: linkData.uploadFolderId || null,\n        },\n        include: {\n          customFields: true,\n          visitorGroups: {\n            select: {\n              visitorGroupId: true,\n            },\n          },\n          views: {\n            orderBy: {\n              viewedAt: \"desc\",\n            },\n            take: 1,\n          },\n          _count: {\n            select: { views: true },\n          },\n        },\n      });\n\n      // Update visitor groups (replace all)\n      if (linkData.visitorGroupIds !== undefined) {\n        // Delete existing visitor group associations\n        await tx.linkVisitorGroup.deleteMany({\n          where: { linkId: id },\n        });\n\n        // Create new associations\n        if (linkData.visitorGroupIds?.length > 0) {\n          await tx.linkVisitorGroup.createMany({\n            data: linkData.visitorGroupIds.map(\n              (visitorGroupId: string) => ({\n                linkId: id,\n                visitorGroupId,\n              }),\n            ),\n            skipDuplicates: true,\n          });\n        }\n      }\n      if (linkData.tags?.length) {\n        // Remove only tags that are not in the new list\n        await tx.tagItem.deleteMany({\n          where: {\n            linkId: id,\n            itemType: \"LINK_TAG\",\n            tagId: { notIn: linkData.tags },\n          },\n        });\n\n        // Add new tags while avoiding duplicates\n        await tx.tagItem.createMany({\n          data: linkData.tags.map((tagId: string) => ({\n            tagId,\n            itemType: \"LINK_TAG\",\n            linkId: id,\n            taggedBy: userId,\n          })),\n          skipDuplicates: true,\n        });\n      } else {\n        // If all tags are removed, delete all tagged items for this link\n        await tx.tagItem.deleteMany({\n          where: {\n            linkId: id,\n            itemType: \"LINK_TAG\",\n          },\n        });\n      }\n\n      const tags = await tx.tag.findMany({\n        where: {\n          items: {\n            some: { linkId: link.id },\n          },\n        },\n        select: {\n          id: true,\n          name: true,\n          color: true,\n          description: true,\n        },\n      });\n\n      // Re-fetch visitor groups to get post-update associations\n      const freshVisitorGroups = await tx.linkVisitorGroup.findMany({\n        where: { linkId: id },\n        select: { visitorGroupId: true },\n      });\n\n      return { ...link, visitorGroups: freshVisitorGroups, tags };\n    });\n\n    if (!updatedLink) {\n      return res.status(404).json({ error: \"Link not found\" });\n    }\n\n    await fetch(\n      `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&linkId=${id}&hasDomain=${updatedLink.domainId ? \"true\" : \"false\"}`,\n    );\n\n    // Decrypt the password for the updated link\n    if (updatedLink.password !== null) {\n      updatedLink.password = decryptEncrpytedPassword(updatedLink.password);\n    }\n\n    return res.status(200).json(updatedLink);\n  } else if (req.method == \"DELETE\") {\n    // DELETE /api/links/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { id } = req.query as { id: string };\n\n    try {\n      const linkToBeDeleted = await prisma.link.findUnique({\n        where: {\n          id: id,\n        },\n        include: {\n          document: {\n            select: {\n              ownerId: true,\n            },\n          },\n          dataroom: {\n            select: {\n              teamId: true,\n            },\n          },\n          team: {\n            select: {\n              plan: true,\n              users: {\n                where: {\n                  userId: userId,\n                },\n                select: {\n                  userId: true,\n                  role: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!linkToBeDeleted) {\n        return res.status(404).json({ error: \"Link not found\" });\n      }\n\n      // Check if team is on free plan\n      if (linkToBeDeleted.team?.plan === \"free\") {\n        return res.status(403).json({\n          error:\n            \"Link deletion is not available on the free plan. Please upgrade to delete links.\",\n        });\n      }\n\n      // Check authorization based on link type\n      let isAuthorized = false;\n\n      if (linkToBeDeleted.documentId && linkToBeDeleted.document) {\n        // Document link - check if user owns the document\n        isAuthorized = linkToBeDeleted.document.ownerId === userId;\n      } else if (linkToBeDeleted.dataroomId && linkToBeDeleted.team) {\n        // Dataroom link - check if user is a member of the team\n        isAuthorized = linkToBeDeleted.team.users.length > 0;\n      }\n\n      if (!isAuthorized) {\n        return res.status(401).end(\"Unauthorized to delete this link\");\n      }\n\n      // Generate a random suffix for the deleted slug to free up the original slug\n      const generateDeletedSuffix = customAlphabet(\n        \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n        6,\n      );\n\n      // Soft delete the link by setting deletedAt and isArchived,\n      // and rename the slug so the original can be reused\n      await prisma.link.update({\n        where: {\n          id: id,\n        },\n        data: {\n          deletedAt: new Date(),\n          isArchived: true,\n          ...(linkToBeDeleted.slug && {\n            slug: `${linkToBeDeleted.slug}-DELETED-${generateDeletedSuffix()}`,\n          }),\n        },\n      });\n\n      res.status(204).end(); // 204 No Content response for successful deletes\n    } catch (error) {\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n  }\n\n  // We only allow GET and PUT requests\n  res.setHeader(\"Allow\", [\"GET\", \"PUT\", \"DELETE\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/links/[id]/preview.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { createPreviewSession } from \"@/lib/auth/preview-auth\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/links/:id/preview\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { id } = req.query as { id: string };\n\n    const previewSession = await createPreviewSession(\n      id,\n      (session.user as CustomUser).id,\n    );\n\n    return res.status(200).json({ previewToken: previewSession.token });\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/links/domains/[...domainSlug].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { Brand, DataroomBrand, LinkAudienceType } from \"@prisma/client\";\n\nimport { fetchDataroomDocumentLinkData } from \"@/lib/api/links/link-data\";\nimport {\n  fetchDataroomLinkData,\n  fetchDocumentLinkData,\n} from \"@/lib/api/links/link-data\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\nimport { checkGlobalBlockList } from \"@/lib/utils/global-block-list\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // Immediately set the Cache-Control header to prevent any form of caching\n  // res.setHeader(\"Cache-Control\", \"no-store, max-age=0, must-revalidate\");\n\n  if (req.method === \"GET\") {\n    // GET /api/links/domains/:domain/:slug\n    const { domainSlug } = req.query as { domainSlug: string[] };\n\n    const domain = domainSlug[0];\n    const slug = domainSlug[1];\n    const documentId = domainSlug[3];\n\n    if (slug === \"404\") {\n      return res.status(404).json({\n        error: \"Link not found\",\n        message: \"link not found\",\n      });\n    }\n\n    try {\n      console.time(\"get-link\");\n      const link = await prisma.link.findUnique({\n        where: {\n          domainSlug_slug: {\n            slug: slug,\n            domainSlug: domain,\n          },\n        },\n        select: {\n          id: true,\n          expiresAt: true,\n          emailProtected: true,\n          allowDownload: true,\n          password: true,\n          isArchived: true,\n          deletedAt: true,\n          enableCustomMetatag: true,\n          enableFeedback: true,\n          enableScreenshotProtection: true,\n          enableIndexFile: true,\n          metaTitle: true,\n          metaDescription: true,\n          metaImage: true,\n          metaFavicon: true,\n          welcomeMessage: true,\n          enableQuestion: true,\n          dataroomId: true,\n          linkType: true,\n          feedback: {\n            select: {\n              id: true,\n              data: true,\n            },\n          },\n          enableAgreement: true,\n          agreement: true,\n          showBanner: true,\n          enableWatermark: true,\n          watermarkConfig: true,\n          groupId: true,\n          permissionGroupId: true,\n          audienceType: true,\n          teamId: true,\n          team: {\n            select: {\n              plan: true,\n              globalBlockList: true,\n            },\n          },\n          customFields: {\n            select: {\n              id: true,\n              type: true,\n              identifier: true,\n              label: true,\n              placeholder: true,\n              required: true,\n              disabled: true,\n              orderIndex: true,\n            },\n            orderBy: {\n              orderIndex: \"asc\",\n            },\n          },\n        },\n      });\n      console.timeEnd(\"get-link\");\n\n      // if link not found, return 404\n      if (!link) {\n        log({\n          message: `Link not found for custom domain _${domain}/${slug}_`,\n          type: \"error\",\n          mention: true,\n        });\n        return res.status(404).json({\n          error: \"Link not found\",\n          message: \"No link found\",\n        });\n      }\n\n      if (link.isArchived) {\n        return res.status(404).json({\n          error: \"Link is archived\",\n          message: \"link is archived\",\n        });\n      }\n\n      if (link.deletedAt) {\n        return res.status(404).json({\n          error: \"Link has been deleted\",\n          message: \"This link has been deleted\",\n        });\n      }\n\n      const { email } = req.query as { email?: string };\n      const globalBlockCheck = checkGlobalBlockList(\n        email,\n        link.team?.globalBlockList,\n      );\n      if (globalBlockCheck.error) {\n        return res.status(400).json({ message: globalBlockCheck.error });\n      }\n      if (globalBlockCheck.isBlocked) {\n        return res.status(403).json({ message: \"Access denied\" });\n      }\n\n      const teamPlan = link.team?.plan || \"free\";\n      const teamId = link.teamId;\n      // if owner of document is on free plan, return 404\n      if (teamPlan.includes(\"free\")) {\n        log({\n          message: `Link is from a free team _${teamId}_ for custom domain _${domain}/${slug}_`,\n          type: \"info\",\n          mention: true,\n        });\n        return res.status(404).json({\n          error: \"Link not found\",\n          message: `link found, team ${teamPlan}`,\n        });\n      }\n\n      const linkType = link.linkType;\n\n      // Handle workflow links separately\n      if (linkType === \"WORKFLOW_LINK\") {\n        // For workflow links, fetch brand if available\n        let brand: Partial<Brand> | null = null;\n        if (link.teamId) {\n          const teamBrand = await prisma.brand.findUnique({\n            where: { teamId: link.teamId },\n            select: {\n              logo: true,\n              brandColor: true,\n              accentColor: true,\n            },\n          });\n          brand = teamBrand;\n        }\n\n        return res.status(200).json({ linkType, brand, linkId: link.id });\n      }\n\n      let brand: Partial<Brand> | Partial<DataroomBrand> | null = null;\n      let linkData: any;\n\n      if (linkType === \"DOCUMENT_LINK\") {\n        console.time(\"get-document-link-data\");\n        const data = await fetchDocumentLinkData({\n          linkId: link.id,\n          teamId: link.teamId!,\n        });\n        linkData = data.linkData;\n        brand = data.brand;\n        console.timeEnd(\"get-document-link-data\");\n      } else if (linkType === \"DATAROOM_LINK\") {\n        console.time(\"get-dataroom-link-data\");\n        if (documentId) {\n          const data = await fetchDataroomDocumentLinkData({\n            linkId: link.id,\n            teamId: link.teamId!,\n            dataroomDocumentId: documentId,\n            permissionGroupId: link.permissionGroupId || undefined,\n            ...(link.audienceType === LinkAudienceType.GROUP &&\n              link.groupId && {\n                groupId: link.groupId,\n              }),\n          });\n          linkData = data.linkData;\n          brand = data.brand;\n        } else {\n          const data = await fetchDataroomLinkData({\n            linkId: link.id,\n            dataroomId: link.dataroomId,\n            teamId: link.teamId!,\n            permissionGroupId: link.permissionGroupId || undefined,\n            ...(link.audienceType === LinkAudienceType.GROUP &&\n              link.groupId && {\n                groupId: link.groupId,\n              }),\n          });\n          linkData = data.linkData;\n          brand = data.brand;\n          // Include access controls in the link data for the frontend\n          linkData.accessControls = data.accessControls;\n        }\n        console.timeEnd(\"get-dataroom-link-data\");\n      }\n\n      // remove document and domain from link\n      const sanitizedLink = {\n        ...link,\n        teamId: undefined,\n        team: undefined,\n        document: undefined,\n        dataroom: undefined,\n        ...(teamPlan === \"free\" && {\n          customFields: [], // reset custom fields for free plan\n          enableAgreement: false,\n          enableWatermark: false,\n          permissionGroupId: null,\n        }),\n      };\n\n      // clean up the link return object\n      const returnLink = {\n        ...sanitizedLink,\n        ...linkData,\n        dataroomDocument: linkData.dataroom?.documents[0] || undefined,\n      };\n\n      res.status(200).json({ linkType, link: returnLink, brand });\n    } catch (error) {\n      log({\n        message: `Cannot get link for custom domain _${domainSlug}_ \\n\\n${error}`,\n        type: \"error\",\n        mention: true,\n      });\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/links/download/[jobId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getDataroomSessionByLinkIdInPagesRouter } from \"@/lib/auth/dataroom-auth\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const { linkId, jobId } = req.query as { linkId: string; jobId: string };\n  if (!linkId || !jobId) {\n    return res.status(400).json({ error: \"linkId and jobId are required\" });\n  }\n\n  const session = await getDataroomSessionByLinkIdInPagesRouter(req, linkId);\n  if (!session) {\n    return res.status(401).json({ error: \"Session required\" });\n  }\n\n  const view = await prisma.view.findUnique({\n    where: { id: session.viewId },\n    select: { viewerEmail: true },\n  });\n  if (!view?.viewerEmail) {\n    return res.status(403).json({ error: \"Viewer email not found\" });\n  }\n\n  const job = await downloadJobStore.getJob(jobId);\n  if (!job) {\n    return res\n      .status(404)\n      .json({ error: \"Download job not found or expired\" });\n  }\n\n  if (\n    job.linkId !== linkId ||\n    job.viewerEmail?.toLowerCase().trim() !==\n      view.viewerEmail?.toLowerCase().trim()\n  ) {\n    return res.status(403).json({ error: \"Job does not belong to this viewer\" });\n  }\n\n  // Use relative URLs so the browser resolves them against the current page origin,\n  // ensuring the session cookie (scoped to the page host) is always sent.\n  const partCount = job.downloadUrls?.length ?? 0;\n  const proxyDownloadUrls =\n    job.status === \"COMPLETED\" && partCount > 0\n      ? Array.from({ length: partCount }, (_, i) =>\n          `/api/links/download/file/${jobId}/${i}?linkId=${encodeURIComponent(linkId)}`,\n        )\n      : undefined;\n\n  return res.status(200).json({\n    id: job.id,\n    status: job.status,\n    progress: job.progress,\n    totalFiles: job.totalFiles,\n    processedFiles: job.processedFiles,\n    downloadUrls: proxyDownloadUrls,\n    error: job.status === \"FAILED\" ? job.error : undefined,\n    isReady: job.status === \"COMPLETED\" && !!job.downloadUrls?.length,\n    dataroomName: job.dataroomName,\n    createdAt: job.createdAt,\n    completedAt: job.completedAt,\n    expiresAt: job.expiresAt,\n  });\n}\n"
  },
  {
    "path": "pages/api/links/download/bulk.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\nimport { ItemType, ViewType } from \"@prisma/client\";\n\nimport { verifyDataroomSessionInPagesRouter } from \"@/lib/auth/dataroom-auth\";\nimport {\n  buildFolderNameMap,\n  buildFolderPathsFromHierarchy,\n} from \"@/lib/dataroom/build-folder-hierarchy\";\nimport { notifyDocumentDownload } from \"@/lib/integrations/slack/events\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\nimport { bulkDownloadTask } from \"@/lib/trigger/bulk-download\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\nexport const config = {\n  maxDuration: 60,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const { linkId, viewId, emailNotification } = req.body as {\n    linkId: string;\n    viewId: string;\n    emailNotification?: boolean;\n  };\n\n  if (typeof linkId !== \"string\" || !linkId.trim()) {\n    return res.status(400).json({ error: \"linkId is required\" });\n  }\n\n  if (typeof viewId !== \"string\" || !viewId.trim()) {\n    return res.status(400).json({ error: \"viewId is required\" });\n  }\n\n  try {\n    const view = await prisma.view.findUnique({\n      where: {\n        id: viewId,\n        linkId: linkId,\n        viewType: { equals: ViewType.DATAROOM_VIEW },\n      },\n      select: {\n        id: true,\n        viewedAt: true,\n        viewerEmail: true,\n        viewerId: true,\n        verified: true,\n        link: {\n          select: {\n            allowDownload: true,\n            expiresAt: true,\n            isArchived: true,\n            deletedAt: true,\n            enableWatermark: true,\n            watermarkConfig: true,\n            name: true,\n            permissionGroupId: true,\n          },\n        },\n        groupId: true,\n        dataroom: {\n          select: {\n            id: true,\n            name: true,\n            teamId: true,\n            allowBulkDownload: true,\n            folders: {\n              select: {\n                id: true,\n                name: true,\n                path: true,\n                parentId: true,\n              },\n            },\n            documents: {\n              select: {\n                id: true,\n                folderId: true,\n                document: {\n                  select: {\n                    id: true,\n                    name: true,\n                    versions: {\n                      where: { isPrimary: true },\n                      select: {\n                        type: true,\n                        file: true,\n                        storageType: true,\n                        originalFile: true,\n                        contentType: true,\n                        numPages: true,\n                      },\n                      take: 1,\n                    },\n                  },\n                },\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (!view) {\n      return res.status(404).json({ error: \"Error downloading\" });\n    }\n\n    const dataroomId = view.dataroom?.id;\n    const session = await verifyDataroomSessionInPagesRouter(\n      req,\n      linkId,\n      dataroomId ?? \"\",\n    );\n    if (!session) {\n      return res.status(401).json({ error: \"Session required to download\" });\n    }\n\n    // Verified session and email are only required when the viewer requested email notification\n    if (emailNotification) {\n      if (!view.viewerEmail) {\n        return res.status(400).json({\n          error:\n            \"Email is required to receive download notifications. Enter your email in the dataroom.\",\n        });\n      }\n      if (!session.verified) {\n        return res.status(403).json({\n          error:\n            \"Verify your email with the one-time code to receive a notification when the download is ready.\",\n        });\n      }\n    }\n\n    if (!view.link.allowDownload) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // if link is archived, we should not allow the download\n    if (view.link.isArchived) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    if (view.link.deletedAt) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // if link is expired, we should not allow the download\n    if (view.link.expiresAt && view.link.expiresAt < new Date()) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // if dataroom does not exist, we should not allow the download\n    if (!view.dataroom) {\n      return res.status(404).json({ error: \"Error downloading\" });\n    }\n\n    // if dataroom does not allow bulk download, we should not allow the download\n    if (!view.dataroom.allowBulkDownload) {\n      return res\n        .status(403)\n        .json({ error: \"Bulk download is disabled for this dataroom\" });\n    }\n\n    // if viewedAt is longer than 23 hours ago, we should not allow the download\n    if (\n      view.viewedAt &&\n      view.viewedAt < new Date(Date.now() - 23 * 60 * 60 * 1000)\n    ) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // Build folder paths from the FULL folder list (source of truth) BEFORE\n    // permission filtering. This ensures parent folders that only have canView\n    // (not canDownload) are still included in the hierarchy so child paths are\n    // computed correctly (e.g. \"/legal/contracts\" instead of just \"/contracts\").\n    const allFolders = view.dataroom.folders;\n    const computedPathMap = buildFolderPathsFromHierarchy(allFolders);\n    const folderMap = buildFolderNameMap(allFolders, computedPathMap);\n\n    let downloadFolders = allFolders;\n    let downloadDocuments = view.dataroom.documents;\n\n    // Check permissions based on groupId (ViewerGroup) or permissionGroupId (PermissionGroup)\n    const effectiveGroupId = view.groupId || view.link.permissionGroupId;\n\n    if (effectiveGroupId) {\n      let groupPermissions: any[] = [];\n\n      if (view.groupId) {\n        // This is a ViewerGroup (legacy behavior)\n        groupPermissions = await prisma.viewerGroupAccessControls.findMany({\n          where: { groupId: view.groupId, canDownload: true },\n        });\n      } else if (view.link.permissionGroupId) {\n        // This is a PermissionGroup (new behavior)\n        groupPermissions = await prisma.permissionGroupAccessControls.findMany({\n          where: {\n            groupId: view.link.permissionGroupId,\n            canDownload: true,\n          },\n        });\n      }\n\n      const permittedFolderIds = new Set(\n        groupPermissions\n          .filter(\n            (permission) => permission.itemType === ItemType.DATAROOM_FOLDER,\n          )\n          .map((permission) => permission.itemId),\n      );\n      const permittedDocumentIds = new Set(\n        groupPermissions\n          .filter(\n            (permission) => permission.itemType === ItemType.DATAROOM_DOCUMENT,\n          )\n          .map((permission) => permission.itemId),\n      );\n\n      downloadFolders = downloadFolders.filter((folder) =>\n        permittedFolderIds.has(folder.id),\n      );\n      downloadDocuments = downloadDocuments.filter((doc) =>\n        permittedDocumentIds.has(doc.id),\n      );\n    }\n\n    // Create individual document views for each document being downloaded\n    const downloadableDocuments = downloadDocuments.filter(\n      (doc) =>\n        doc.document.versions[0] &&\n        doc.document.versions[0].type !== \"notion\" &&\n        doc.document.versions[0].storageType !== \"VERCEL_BLOB\",\n    );\n\n    // For bulk downloads, only store metadata if there are less than 100 documents\n    const downloadMetadata =\n      downloadableDocuments.length < 100\n        ? {\n            dataroomName: view.dataroom!.name,\n            documentCount: downloadableDocuments.length,\n            documents: downloadableDocuments.map((doc) => ({\n              id: doc.document.id,\n              name: doc.document.name,\n            })),\n          }\n        : {\n            dataroomName: view.dataroom!.name,\n            documentCount: downloadableDocuments.length,\n          };\n\n    await prisma.view.createMany({\n      data: downloadableDocuments.map((doc) => ({\n        viewType: \"DOCUMENT_VIEW\",\n        documentId: doc.document.id,\n        linkId: linkId,\n        dataroomId: view.dataroom!.id,\n        groupId: view.groupId,\n        dataroomViewId: view.id,\n        viewerEmail: view.viewerEmail,\n        downloadedAt: new Date(),\n        downloadType: \"BULK\",\n        downloadMetadata: downloadMetadata,\n        viewerId: view.viewerId,\n        verified: view.verified,\n      })),\n      skipDuplicates: true,\n    });\n\n    // Construct folderStructure and fileKeys\n    const folderStructure: {\n      [key: string]: {\n        name: string;\n        path: string;\n        files: {\n          name: string;\n          key: string;\n          type?: string;\n          numPages?: number;\n          needsWatermark?: boolean;\n        }[];\n      };\n    } = {};\n    const fileKeys: string[] = [];\n\n    const addFileToStructure = (\n      path: string,\n      fileName: string,\n      fileKey: string,\n      fileType?: string,\n      numPages?: number,\n    ) => {\n      const pathParts = path.split(\"/\").filter(Boolean);\n      let currentPath = \"\";\n\n      // Add folder information for each level of the path\n      pathParts.forEach((part, index) => {\n        currentPath += \"/\" + part;\n        const folderInfo = folderMap.get(currentPath);\n        if (!folderStructure[currentPath]) {\n          folderStructure[currentPath] = {\n            name: folderInfo ? folderInfo.name : part,\n            path: currentPath,\n            files: [],\n          };\n        }\n      });\n\n      // Add the file to the leaf folder\n      if (!folderStructure[path]) {\n        const folderInfo = folderMap.get(path) || { name: \"Root\", id: null };\n        folderStructure[path] = {\n          name: folderInfo.name,\n          path: path,\n          files: [],\n        };\n      }\n\n      const needsWatermark =\n        view.link.enableWatermark &&\n        (fileType === \"pdf\" || fileType === \"image\");\n\n      folderStructure[path].files.push({\n        name: fileName,\n        key: fileKey,\n        type: fileType,\n        numPages: numPages,\n        needsWatermark: needsWatermark ?? undefined,\n      });\n      fileKeys.push(fileKey);\n    };\n\n    // Add root level documents\n    downloadDocuments\n      .filter((doc) => !doc.folderId)\n      .filter((doc) => doc.document.versions[0].type !== \"notion\")\n      .filter((doc) => doc.document.versions[0].storageType !== \"VERCEL_BLOB\")\n      .forEach((doc) => {\n        const fileKey =\n          view.link.enableWatermark && doc.document.versions[0].type === \"pdf\"\n            ? doc.document.versions[0].file\n            : (doc.document.versions[0].originalFile ??\n              doc.document.versions[0].file);\n\n        addFileToStructure(\n          \"/\",\n          doc.document.name,\n          fileKey,\n          doc.document.versions[0].type ?? undefined,\n          doc.document.versions[0].numPages ?? undefined,\n        );\n      });\n\n    // Pre-index documents by folderId for O(1) lookup per folder\n    const docsByFolderId = new Map<string, typeof downloadDocuments>();\n    for (const doc of downloadDocuments) {\n      if (!doc.folderId) continue;\n      const list = docsByFolderId.get(doc.folderId) ?? [];\n      list.push(doc);\n      docsByFolderId.set(doc.folderId, list);\n    }\n\n    // Add documents in folders\n    downloadFolders.forEach((folder) => {\n      // Use the computed path from parentId hierarchy instead of the stored path\n      const folderPath = computedPathMap.get(folder.id) ?? folder.path;\n\n      const folderDocs = (docsByFolderId.get(folder.id) ?? [])\n        .filter((doc) => doc.document.versions[0].type !== \"notion\")\n        .filter(\n          (doc) => doc.document.versions[0].storageType !== \"VERCEL_BLOB\",\n        );\n\n      folderDocs &&\n        folderDocs.forEach((doc) => {\n          // Use .file if watermark is enabled and document is PDF, otherwise use .originalFile\n          const fileKey =\n            view.link.enableWatermark && doc.document.versions[0].type === \"pdf\"\n              ? doc.document.versions[0].file\n              : (doc.document.versions[0].originalFile ??\n                doc.document.versions[0].file);\n\n          addFileToStructure(\n            folderPath,\n            doc.document.name,\n            fileKey,\n            doc.document.versions[0].type ?? undefined,\n            doc.document.versions[0].numPages ?? undefined,\n          );\n        });\n\n      // If the folder is empty, ensure it's still added to the structure\n      if (folderDocs && folderDocs.length === 0) {\n        addFileToStructure(folderPath, \"\", \"\");\n      }\n    });\n\n    if (fileKeys.length === 0) {\n      return res.status(404).json({ error: \"No files to download\" });\n    }\n\n    if (view.dataroom?.teamId) {\n      void notifyDocumentDownload({\n        teamId: view.dataroom.teamId,\n        documentId: undefined,\n        dataroomId: view.dataroom.id,\n        linkId,\n        viewerEmail: view.viewerEmail ?? undefined,\n        viewerId: view.viewerId ?? undefined,\n        metadata: {\n          documentCount: downloadDocuments.length,\n          isBulkDownload: true,\n        },\n      }).catch((err) =>\n        console.error(\"Error sending Slack notification:\", err),\n      );\n    }\n\n    const teamId = view.dataroom!.teamId;\n    const storageConfig = await getTeamStorageConfigById(teamId);\n    const sendEmail =\n      !!emailNotification && !!view.viewerEmail && !!session.verified;\n\n    const job = await downloadJobStore.createJob({\n      type: \"bulk\",\n      status: \"PENDING\",\n      dataroomId: view.dataroom!.id,\n      dataroomName: view.dataroom!.name,\n      totalFiles: fileKeys.length,\n      processedFiles: 0,\n      progress: 0,\n      teamId,\n      userId: view.viewerId ?? view.viewerEmail ?? \"viewer\",\n      linkId,\n      viewerId: view.viewerId ?? undefined,\n      viewerEmail: view.viewerEmail ?? undefined,\n      emailNotification: sendEmail,\n      emailAddress: sendEmail ? (view.viewerEmail ?? undefined) : undefined,\n    });\n\n    const handle = await bulkDownloadTask.trigger(\n      {\n        jobId: job.id,\n        dataroomId: view.dataroom!.id,\n        dataroomName: view.dataroom!.name,\n        teamId,\n        folderStructure,\n        fileKeys,\n        sourceBucket: storageConfig.bucket,\n        watermarkConfig: view.link.enableWatermark\n          ? {\n              enabled: true,\n              config: view.link.watermarkConfig,\n              viewerData: {\n                email: view.viewerEmail,\n                date: (view.viewedAt\n                  ? new Date(view.viewedAt)\n                  : new Date()\n                ).toLocaleDateString(),\n                time: (view.viewedAt\n                  ? new Date(view.viewedAt)\n                  : new Date()\n                ).toLocaleTimeString(),\n                link: view.link.name,\n                ipAddress: getIpAddress(req.headers),\n              },\n            }\n          : { enabled: false },\n        viewId: view.id,\n        viewerId: view.viewerId ?? undefined,\n        viewerEmail: view.viewerEmail ?? undefined,\n        linkId,\n        emailNotification: sendEmail,\n        emailAddress: sendEmail ? (view.viewerEmail ?? undefined) : undefined,\n      },\n      {\n        idempotencyKey: job.id,\n        tags: [\n          `team_${teamId}`,\n          `dataroom_${view.dataroom!.id}`,\n          `job_${job.id}`,\n          `link_${linkId}`,\n        ],\n      },\n    );\n\n    await downloadJobStore.updateJob(job.id, { triggerRunId: handle.id });\n\n    return res.status(202).json({\n      jobId: job.id,\n      status: \"PENDING\",\n      message: sendEmail\n        ? \"Download started. We'll email you when it's ready.\"\n        : \"Download started. You can check status on the downloads page.\",\n    });\n  } catch (error) {\n    console.error(\"Error starting bulk download:\", error);\n    return res.status(500).json({\n      message: \"Internal Server Error\",\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/links/download/by-email.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getDataroomSessionByLinkIdInPagesRouter } from \"@/lib/auth/dataroom-auth\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  // 1. IP-based rate limiting (same pattern as OTP endpoints in verify.ts)\n  const ipAddress = getIpAddress(req.headers) ?? \"unknown\";\n  const { success } = await ratelimit(10, \"1 m\").limit(\n    `download-by-email-ip:${ipAddress}`,\n  );\n  if (!success) {\n    return res\n      .status(429)\n      .json({ error: \"Too many requests. Try again later.\" });\n  }\n\n  const { linkId, email } = req.body as { linkId?: string; email?: string };\n  if (!linkId || !email) {\n    return res.status(400).json({ error: \"linkId and email are required\" });\n  }\n\n  // 2. Require a valid dataroom session\n  const session = await getDataroomSessionByLinkIdInPagesRouter(req, linkId);\n  if (!session) {\n    return res.status(401).json({ error: \"Session required\" });\n  }\n\n  // 3. Prevent email enumeration — always return 200 with uniform body\n  const view = await prisma.view.findFirst({\n    where: {\n      linkId,\n      viewType: \"DATAROOM_VIEW\",\n      viewerEmail: { equals: email, mode: \"insensitive\" },\n    },\n    select: { id: true },\n    orderBy: { viewedAt: \"desc\" },\n  });\n\n  return res.status(200).json({ viewId: view?.id ?? null });\n}\n"
  },
  {
    "path": "pages/api/links/download/dataroom-document.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { ItemType, ViewType } from \"@prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport { notifyDocumentDownload } from \"@/lib/integrations/slack/events\";\nimport prisma from \"@/lib/prisma\";\nimport { getFileNameWithPdfExtension } from \"@/lib/utils\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\nexport const config = {\n  maxDuration: 300,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/links/download/dataroom-document\n    const { linkId, viewId, documentId } = req.body as {\n      linkId: string;\n      viewId: string;\n      documentId: string;\n    };\n\n    try {\n      const view = await prisma.view.findUnique({\n        where: {\n          id: viewId,\n          linkId: linkId,\n          viewType: { equals: ViewType.DATAROOM_VIEW },\n        },\n        select: {\n          id: true,\n          viewedAt: true,\n          viewerEmail: true,\n          viewerId: true,\n          verified: true,\n          link: {\n            select: {\n              allowDownload: true,\n              expiresAt: true,\n              isArchived: true,\n              deletedAt: true,\n              enableWatermark: true,\n              watermarkConfig: true,\n              name: true,\n              permissionGroupId: true,\n              teamId: true,\n            },\n          },\n          groupId: true,\n          dataroom: {\n            select: {\n              id: true,\n              documents: {\n                where: { document: { id: documentId } },\n                select: {\n                  id: true,\n                  document: {\n                    select: {\n                      id: true,\n                      name: true,\n                      versions: {\n                        where: { isPrimary: true },\n                        select: {\n                          id: true,\n                          type: true,\n                          file: true,\n                          storageType: true,\n                          originalFile: true,\n                          numPages: true,\n                          contentType: true,\n                        },\n                        take: 1,\n                      },\n                    },\n                  },\n                },\n              },\n            },\n          },\n        },\n      });\n\n      // if view does not exist, we should not allow the download\n      if (!view) {\n        return res.status(404).json({ error: \"Error downloading\" });\n      }\n\n      // if link does not allow download, we should not allow the download\n      if (!view.link.allowDownload) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if link is archived, we should not allow the download\n      if (view.link.isArchived) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if link is deleted, we should not allow the download\n      if (view.link.deletedAt) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if link is expired, we should not allow the download\n      if (view.link.expiresAt && view.link.expiresAt < new Date()) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if dataroom does not exist, we should not allow the download\n      if (!view.dataroom) {\n        return res.status(404).json({ error: \"Error downloading\" });\n      }\n\n      // if viewedAt is longer than 23 hours ago, we should not allow the download\n      if (\n        view.viewedAt &&\n        view.viewedAt < new Date(Date.now() - 23 * 60 * 60 * 1000)\n      ) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      let downloadDocuments = view.dataroom.documents;\n\n      // Check permissions based on groupId (ViewerGroup) or permissionGroupId (PermissionGroup)\n      const effectiveGroupId = view.groupId || view.link.permissionGroupId;\n\n      if (effectiveGroupId) {\n        let groupPermissions: any[] = [];\n\n        if (view.groupId) {\n          // This is a ViewerGroup (legacy behavior)\n          groupPermissions = await prisma.viewerGroupAccessControls.findMany({\n            where: { groupId: view.groupId, canDownload: true },\n          });\n        } else if (view.link.permissionGroupId) {\n          // This is a PermissionGroup (new behavior)\n          groupPermissions =\n            await prisma.permissionGroupAccessControls.findMany({\n              where: {\n                groupId: view.link.permissionGroupId,\n                canDownload: true,\n              },\n            });\n        }\n\n        const permittedDocumentIds = groupPermissions\n          .filter(\n            (permission) => permission.itemType === ItemType.DATAROOM_DOCUMENT,\n          )\n          .map((permission) => permission.itemId);\n\n        downloadDocuments = downloadDocuments.filter((doc) =>\n          permittedDocumentIds.includes(doc.id),\n        );\n      }\n\n      //creates new view for document\n      await prisma.view.create({\n        data: {\n          viewType: \"DOCUMENT_VIEW\",\n          documentId: documentId,\n          linkId: linkId,\n          dataroomId: view.dataroom.id,\n          groupId: view.groupId,\n          dataroomViewId: view.id,\n          viewerEmail: view.viewerEmail,\n          downloadedAt: new Date(),\n          downloadType: \"SINGLE\",\n          viewerId: view.viewerId,\n          verified: view.verified,\n        },\n      });\n\n      if (view.link.teamId) {\n        waitUntil(\n          notifyDocumentDownload({\n            teamId: view.link.teamId,\n            documentId,\n            dataroomId: view.dataroom.id,\n            linkId,\n            viewerEmail: view.viewerEmail ?? undefined,\n            viewerId: view.viewerId ?? undefined,\n          }),\n        );\n      } else {\n        console.log(\"No teamId found, skipping Slack notification\");\n      }\n\n      const file =\n        view.link.enableWatermark &&\n        downloadDocuments[0].document!.versions[0].type === \"pdf\"\n          ? downloadDocuments[0].document!.versions[0].file\n          : (downloadDocuments[0].document!.versions[0].originalFile ??\n            downloadDocuments[0].document!.versions[0].file);\n\n      const downloadUrl = await getFile({\n        type: downloadDocuments[0].document!.versions[0].storageType,\n        data: file,\n        isDownload: true,\n      });\n\n      // For PDF files with watermark, always buffer and process\n      if (\n        downloadDocuments[0].document!.versions[0].type === \"pdf\" &&\n        view.link.enableWatermark &&\n        view.link.watermarkConfig\n      ) {\n        const response = await fetch(\n          `${process.env.NEXTAUTH_URL}/api/mupdf/annotate-document`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n            },\n            body: JSON.stringify({\n              url: downloadUrl,\n              numPages: downloadDocuments[0].document!.versions[0].numPages,\n              watermarkConfig: view.link.watermarkConfig,\n              originalFileName: downloadDocuments[0].document!.name,\n              viewerData: {\n                email: view.viewerEmail,\n                date: (view.viewedAt\n                  ? new Date(view.viewedAt)\n                  : new Date()\n                ).toLocaleDateString(),\n                ipAddress: getIpAddress(req.headers),\n                link: view.link.name,\n                time: (view.viewedAt\n                  ? new Date(view.viewedAt)\n                  : new Date()\n                ).toLocaleTimeString(),\n              },\n            }),\n          },\n        );\n\n        if (!response.ok) {\n          return res.status(500).json({ error: \"Error downloading\" });\n        }\n\n        const pdfBuffer = await response.arrayBuffer();\n\n        // Set appropriate headers for watermarked PDF\n        res.setHeader(\"Content-Type\", \"application/pdf\");\n        res.setHeader(\n          \"Content-Disposition\",\n          `attachment; filename=\"${encodeURIComponent(getFileNameWithPdfExtension(downloadDocuments[0].document!.name))}\"`,\n        );\n        res.setHeader(\"Content-Length\", Buffer.from(pdfBuffer).length);\n\n        // Send the watermarked buffer directly\n        return res.send(Buffer.from(pdfBuffer));\n      }\n\n      // For non-watermarked PDFs, we need to buffer and set proper headers\n      // - contentType is application/pdf\n      // - contentType is null and type is pdf\n      // - contentType starts with image/\n      if (\n        downloadDocuments[0].document!.versions[0].contentType ===\n          \"application/pdf\" ||\n        (downloadDocuments[0].document!.versions[0].contentType === null &&\n          downloadDocuments[0].document!.versions[0].type === \"pdf\") ||\n        downloadDocuments[0].document!.versions[0].contentType?.startsWith(\n          \"image/\",\n        )\n      ) {\n        const response = await fetch(downloadUrl);\n        if (!response.ok) {\n          return res.status(500).json({ error: \"Error downloading file\" });\n        }\n\n        const pdfBuffer = await response.arrayBuffer();\n\n        // Set appropriate headers to force download\n        res.setHeader(\"Content-Type\", \"application/pdf\");\n        res.setHeader(\n          \"Content-Disposition\",\n          `attachment; filename=\"${encodeURIComponent(downloadDocuments[0].document!.name)}\"`,\n        );\n        res.setHeader(\"Content-Length\", Buffer.from(pdfBuffer).length);\n        res.setHeader(\"Cache-Control\", \"no-cache\");\n\n        // Send the PDF buffer directly\n        return res.send(Buffer.from(pdfBuffer));\n      }\n\n      const headResponse = await fetch(downloadUrl, { method: \"HEAD\" });\n      const contentType =\n        downloadDocuments[0].document!.versions[0].contentType ||\n        headResponse.headers.get(\"content-type\") ||\n        \"application/octet-stream\";\n      const fileName = downloadDocuments[0].document!.name;\n\n      // For all other files, return direct download URL\n      return res.status(200).json({\n        downloadUrl,\n        fileName,\n        contentType,\n        isDirectDownload: true,\n      });\n    } catch (error) {\n      console.error(\"Error:\", error);\n      return res.status(500).json({ error: \"Error downloading file\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/links/download/dataroom-folder.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\nimport { ItemType, ViewType } from \"@prisma/client\";\n\nimport { verifyDataroomSessionInPagesRouter } from \"@/lib/auth/dataroom-auth\";\nimport {\n  buildFolderPathsFromHierarchy,\n  collectDescendantIds,\n} from \"@/lib/dataroom/build-folder-hierarchy\";\nimport { notifyDocumentDownload } from \"@/lib/integrations/slack/events\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\nimport { bulkDownloadTask } from \"@/lib/trigger/bulk-download\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\nexport const config = {\n  maxDuration: 60,\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  try {\n    const { folderId, dataroomId, viewId, linkId, emailNotification } = req.body as {\n      folderId: string;\n      dataroomId: string;\n      viewId: string;\n      linkId: string;\n      emailNotification?: boolean;\n    };\n    if (!folderId) {\n      return res\n        .status(400)\n        .json({ error: \"folderId is required in request body\" });\n    }\n\n    const view = await prisma.view.findUnique({\n      where: {\n        id: viewId,\n        linkId: linkId,\n        viewType: { equals: ViewType.DATAROOM_VIEW },\n      },\n      select: {\n        id: true,\n        viewedAt: true,\n        viewerEmail: true,\n        viewerId: true,\n        verified: true,\n        link: {\n          select: {\n            teamId: true,\n            allowDownload: true,\n            expiresAt: true,\n            isArchived: true,\n            deletedAt: true,\n            enableWatermark: true,\n            watermarkConfig: true,\n            name: true,\n            permissionGroupId: true,\n          },\n        },\n        groupId: true,\n      },\n    });\n\n    if (!view) {\n      return res.status(404).json({ error: \"Error downloading\" });\n    }\n\n    const session = await verifyDataroomSessionInPagesRouter(\n      req,\n      linkId,\n      dataroomId,\n    );\n    if (!session) {\n      return res.status(401).json({ error: \"Session required to download\" });\n    }\n\n    // Verified session and email are only required when the viewer requested email notification\n    if (emailNotification) {\n      if (!view.viewerEmail) {\n        return res.status(400).json({\n          error:\n            \"Email is required to receive download notifications. Enter your email in the dataroom.\",\n        });\n      }\n      if (!session.verified) {\n        return res.status(403).json({\n          error:\n            \"Verify your email with the one-time code to receive a notification when the download is ready.\",\n        });\n      }\n    }\n\n    if (!view.link.allowDownload) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // if link is archived, we should not allow the download\n    if (view.link.isArchived) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // if link is deleted, we should not allow the download\n    if (view.link.deletedAt) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // if link is expired, we should not allow the download\n    if (view.link.expiresAt && view.link.expiresAt < new Date()) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    // if viewedAt is longer than 23 hours ago, we should not allow the download\n    if (\n      view.viewedAt &&\n      view.viewedAt < new Date(Date.now() - 23 * 60 * 60 * 1000)\n    ) {\n      return res.status(403).json({ error: \"Error downloading\" });\n    }\n\n    const rootFolder = await prisma.dataroomFolder.findUnique({\n      where: {\n        id: folderId,\n        dataroomId,\n      },\n      select: { id: true, name: true, path: true, parentId: true },\n    });\n\n    if (!rootFolder) {\n      return res.status(404).json({ error: \"Folder not found\" });\n    }\n\n    // Fetch all folders in this dataroom so we can traverse the parentId\n    // hierarchy. This avoids relying on the materialized `path` field which\n    // can become stale after renames/moves.\n    const allDataroomFolders = await prisma.dataroomFolder.findMany({\n      where: { dataroomId },\n      select: { id: true, name: true, path: true, parentId: true },\n    });\n\n    // Collect descendants via parentId chain (source of truth)\n    const descendantIds = collectDescendantIds(rootFolder.id, allDataroomFolders);\n    const subfolders = allDataroomFolders.filter((f) => descendantIds.has(f.id));\n\n    // Keep the full (unfiltered) folder list for path computation below.\n    // Permission filtering may remove parent folders that only have canView\n    // (not canDownload), which would break child path computation.\n    const fullFolders = [rootFolder, ...subfolders];\n    let allFolders = [...fullFolders];\n    let allDocuments = await prisma.dataroomDocument.findMany({\n      where: {\n        dataroomId,\n        folderId: {\n          in: allFolders.map((f) => f.id),\n        },\n      },\n      select: {\n        id: true,\n        folderId: true,\n        document: {\n          select: {\n            id: true,\n            name: true,\n            versions: {\n              where: { isPrimary: true },\n              select: {\n                type: true,\n                file: true,\n                storageType: true,\n                originalFile: true,\n                numPages: true,\n                contentType: true,\n              },\n              take: 1,\n            },\n          },\n        },\n      },\n    });\n\n    // Check permissions based on groupId (ViewerGroup) or permissionGroupId (PermissionGroup)\n    const effectiveGroupId = view.groupId || view.link.permissionGroupId;\n\n    if (effectiveGroupId) {\n      let groupPermissions: any[] = [];\n\n      if (view.groupId) {\n        // This is a ViewerGroup (legacy behavior)\n        groupPermissions = await prisma.viewerGroupAccessControls.findMany({\n          where: { groupId: view.groupId, canDownload: true },\n        });\n      } else if (view.link.permissionGroupId) {\n        // This is a PermissionGroup (new behavior)\n        groupPermissions = await prisma.permissionGroupAccessControls.findMany({\n          where: { groupId: view.link.permissionGroupId, canDownload: true },\n        });\n      }\n\n      const permittedFolderIds = new Set(\n        groupPermissions\n          .filter(\n            (permission) => permission.itemType === ItemType.DATAROOM_FOLDER,\n          )\n          .map((permission) => permission.itemId),\n      );\n      const permittedDocumentIds = new Set(\n        groupPermissions\n          .filter(\n            (permission) => permission.itemType === ItemType.DATAROOM_DOCUMENT,\n          )\n          .map((permission) => permission.itemId),\n      );\n\n      allFolders = allFolders.filter((folder) =>\n        permittedFolderIds.has(folder.id),\n      );\n      allDocuments = allDocuments.filter((doc) =>\n        permittedDocumentIds.has(doc.id),\n      );\n    }\n\n    // Build folder paths from the FULL (unfiltered) folder hierarchy so that\n    // parent folders removed by permission filtering are still present in the\n    // path map, producing correct child paths (e.g. \"/legal/contracts\" instead\n    // of just \"/contracts\").\n    const computedPathMap = buildFolderPathsFromHierarchy(fullFolders);\n\n    // Compute the root folder's path from the hierarchy\n    const computedRootPath = computedPathMap.get(rootFolder.id) ?? rootFolder.path;\n\n    const folderStructure: {\n      [key: string]: {\n        name: string;\n        path: string;\n        files: {\n          name: string;\n          key: string;\n          type?: string;\n          numPages?: number;\n          needsWatermark?: boolean;\n        }[];\n      };\n    } = {};\n\n    const fileKeys: string[] = [];\n\n    const addFileToStructure = (\n      computedFolderPath: string,\n      rootFolderInfo: { name: string; computedPath: string },\n      fileName: string,\n      fileKey: string,\n      fileType?: string,\n      numPages?: number,\n    ) => {\n      let relativePath = \"\";\n      if (computedFolderPath !== rootFolderInfo.computedPath) {\n        const escapedRoot = rootFolderInfo.computedPath.replace(\n          /[.*+?^${}()|[\\]\\\\]/g,\n          \"\\\\$&\",\n        );\n        const pathRegex = new RegExp(`^${escapedRoot}/(.*)$`);\n        const match = computedFolderPath.match(pathRegex);\n        relativePath = match ? match[1] : \"\";\n      }\n\n      const pathParts = [safeSlugify(rootFolderInfo.name)];\n      if (relativePath) {\n        pathParts.push(...relativePath.split(\"/\").filter(Boolean));\n      }\n\n      let currentPath = \"\";\n      for (const part of pathParts) {\n        currentPath += \"/\" + part;\n        if (!folderStructure[currentPath]) {\n          folderStructure[currentPath] = {\n            name: part,\n            path: currentPath,\n            files: [],\n          };\n        }\n      }\n\n      if (fileName && fileKey) {\n        const needsWatermark =\n          view.link.enableWatermark &&\n          (fileType === \"pdf\" || fileType === \"image\");\n\n        folderStructure[currentPath].files.push({\n          name: fileName,\n          key: fileKey,\n          type: fileType,\n          numPages: numPages,\n          needsWatermark: needsWatermark ?? undefined,\n        });\n        fileKeys.push(fileKey);\n      }\n    };\n\n    const rootFolderInfo = { name: rootFolder.name, computedPath: computedRootPath };\n\n    // Pre-index documents by folderId for O(1) lookup per folder\n    const docsByFolderId = new Map<string, typeof allDocuments>();\n    for (const doc of allDocuments) {\n      if (!doc.folderId) continue;\n      const list = docsByFolderId.get(doc.folderId) ?? [];\n      list.push(doc);\n      docsByFolderId.set(doc.folderId, list);\n    }\n\n    for (const folder of allFolders) {\n      const folderPath = computedPathMap.get(folder.id) ?? folder.path;\n      const docs = docsByFolderId.get(folder.id) ?? [];\n\n      if (docs.length === 0) {\n        addFileToStructure(\n          folderPath,\n          rootFolderInfo,\n          \"\",\n          \"\",\n          undefined,\n          undefined,\n        );\n        continue;\n      }\n\n      for (const doc of docs) {\n        const version = doc.document.versions[0];\n        if (\n          !version ||\n          version.type === \"notion\" ||\n          version.storageType === \"VERCEL_BLOB\"\n        )\n          continue;\n\n        // Use .file if watermark is enabled and document is PDF, otherwise use .originalFile\n        const fileKey =\n          view.link.enableWatermark && version.type === \"pdf\"\n            ? version.file\n            : (version.originalFile ?? version.file);\n        addFileToStructure(\n          folderPath,\n          rootFolderInfo,\n          doc.document.name,\n          fileKey,\n          version.type ?? undefined,\n          version.numPages ?? undefined,\n        );\n      }\n    }\n\n    const rootPath = \"/\" + safeSlugify(rootFolder.name);\n    if (!folderStructure[rootPath]) {\n      folderStructure[rootPath] = {\n        name: safeSlugify(rootFolder.name),\n        path: rootPath,\n        files: [],\n      };\n    }\n\n    // Don't update the DATAROOM_VIEW with downloadedAt for folder downloads\n    // Only bulk downloads should update the DATAROOM_VIEW\n\n    // Create individual document views for each document in the folder\n    const downloadableDocuments = allDocuments.filter(\n      (doc) =>\n        doc.document.versions[0] &&\n        doc.document.versions[0].type !== \"notion\" &&\n        doc.document.versions[0].storageType !== \"VERCEL_BLOB\",\n    );\n\n    // Prepare metadata with folder name and document list\n    const downloadMetadata = {\n      folderName: rootFolder.name,\n      folderPath: rootFolder.path,\n      documents: downloadableDocuments.map((doc) => ({\n        id: doc.document.id,\n        name: doc.document.name,\n      })),\n    };\n\n    await prisma.view.createMany({\n      data: downloadableDocuments.map((doc) => ({\n        viewType: \"DOCUMENT_VIEW\",\n        documentId: doc.document.id,\n        linkId: linkId,\n        dataroomId: dataroomId,\n        groupId: view.groupId,\n        dataroomViewId: view.id,\n        viewerEmail: view.viewerEmail,\n        downloadedAt: new Date(),\n        downloadType: \"FOLDER\",\n        downloadMetadata: downloadMetadata,\n        viewerId: view.viewerId,\n        verified: view.verified,\n      })),\n      skipDuplicates: true,\n    });\n\n    if (view.link.teamId) {\n      void notifyDocumentDownload({\n        teamId: view.link.teamId,\n        documentId: undefined,\n        dataroomId,\n        linkId,\n        viewerEmail: view.viewerEmail ?? undefined,\n        viewerId: view.viewerId ?? undefined,\n        metadata: {\n          folderName: rootFolder.name,\n          documentCount: allDocuments.length,\n          isFolderDownload: true,\n        },\n      }).catch((err) => console.error(\"Error sending Slack notification:\", err));\n    }\n\n    const teamId = view.link.teamId!;\n    const storageConfig = await getTeamStorageConfigById(teamId);\n    const dataroom = await prisma.dataroom.findUnique({\n      where: { id: dataroomId },\n      select: { name: true },\n    });\n    const dataroomName = dataroom?.name ?? \"Dataroom\";\n    const sendEmail =\n      !!emailNotification && !!view.viewerEmail && !!session.verified;\n\n    const job = await downloadJobStore.createJob({\n      type: \"folder\",\n      status: \"PENDING\",\n      dataroomId,\n      dataroomName,\n      folderName: rootFolder.name,\n      totalFiles: fileKeys.length,\n      processedFiles: 0,\n      progress: 0,\n      teamId,\n      userId: view.viewerId ?? view.viewerEmail ?? \"viewer\",\n      linkId,\n      viewerId: view.viewerId ?? undefined,\n      viewerEmail: view.viewerEmail ?? undefined,\n      emailNotification: sendEmail,\n      emailAddress: sendEmail ? view.viewerEmail ?? undefined : undefined,\n    });\n\n    const handle = await bulkDownloadTask.trigger(\n      {\n        jobId: job.id,\n        dataroomId,\n        dataroomName,\n        teamId,\n        folderStructure,\n        fileKeys,\n        sourceBucket: storageConfig.bucket,\n        watermarkConfig: view.link.enableWatermark\n          ? {\n              enabled: true,\n              config: view.link.watermarkConfig,\n              viewerData: {\n                email: view.viewerEmail,\n                date: new Date(\n                  view.viewedAt ? view.viewedAt : new Date(),\n                ).toLocaleDateString(),\n                time: new Date(\n                  view.viewedAt ? view.viewedAt : new Date(),\n                ).toLocaleTimeString(),\n                link: view.link.name,\n                ipAddress: getIpAddress(req.headers),\n              },\n            }\n          : { enabled: false },\n        viewId: view.id,\n        viewerId: view.viewerId ?? undefined,\n        viewerEmail: view.viewerEmail ?? undefined,\n        linkId,\n        emailNotification: sendEmail,\n        emailAddress: sendEmail ? view.viewerEmail ?? undefined : undefined,\n        folderName: rootFolder.name,\n      },\n      {\n        idempotencyKey: job.id,\n        tags: [`team_${teamId}`, `dataroom_${dataroomId}`, `job_${job.id}`, `link_${linkId}`],\n      },\n    );\n\n    await downloadJobStore.updateJob(job.id, { triggerRunId: handle.id });\n\n    return res.status(202).json({\n      jobId: job.id,\n      status: \"PENDING\",\n      message: sendEmail\n        ? \"Download started. We'll email you when it's ready.\"\n        : \"Download started. Check the downloads page for status.\",\n    });\n  } catch (error) {\n    console.error(\"Download error:\", error);\n    return res.status(500).json({\n      message: \"Internal Server Error\",\n      error: (error as Error).message,\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/links/download/file/[jobId]/[partIndex].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getDataroomSessionByLinkIdInPagesRouter } from \"@/lib/auth/dataroom-auth\";\nimport { generateFreshPresignedUrl } from \"@/lib/files/bulk-download-presign\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const linkId = req.query.linkId as string;\n  const { jobId, partIndex } = req.query as {\n    jobId: string;\n    partIndex: string;\n  };\n  if (!linkId || !jobId || partIndex == null) {\n    return res\n      .status(400)\n      .json({ error: \"linkId, jobId and partIndex required\" });\n  }\n\n  const session = await getDataroomSessionByLinkIdInPagesRouter(req, linkId);\n  if (!session) {\n    return res.status(401).json({ error: \"Session required\" });\n  }\n\n  const view = await prisma.view.findUnique({\n    where: { id: session.viewId },\n    select: { viewerEmail: true },\n  });\n  if (!view?.viewerEmail) {\n    return res.status(403).json({ error: \"Viewer email not found\" });\n  }\n\n  const job = await downloadJobStore.getJob(jobId);\n  if (!job) {\n    return res\n      .status(404)\n      .json({ error: \"Download job not found or expired\" });\n  }\n\n  if (\n    job.linkId !== linkId ||\n    job.viewerEmail?.toLowerCase().trim() !==\n      view.viewerEmail?.toLowerCase().trim()\n  ) {\n    return res\n      .status(403)\n      .json({ error: \"Job does not belong to this viewer\" });\n  }\n\n  if (job.status !== \"COMPLETED\" || !job.downloadUrls?.length) {\n    return res.status(400).json({ error: \"Download not ready\" });\n  }\n\n  const index = parseInt(partIndex, 10);\n  if (Number.isNaN(index) || index < 0 || index >= job.downloadUrls.length) {\n    return res.status(400).json({ error: \"Invalid part index\" });\n  }\n\n  // Generate a fresh presigned URL using long-term IAM credentials\n  // to avoid the Lambda STS token expiration issue\n  const s3Key = job.downloadS3Keys?.[index];\n  if (s3Key && job.teamId) {\n    try {\n      const freshUrl = await generateFreshPresignedUrl(job.teamId, s3Key);\n      return res.redirect(302, freshUrl);\n    } catch (error) {\n      console.error(\"Failed to generate fresh presigned URL:\", error);\n    }\n  }\n\n  // Fallback to stored presigned URL (may fail if STS token expired)\n  const presignedUrl = job.downloadUrls[index];\n  res.redirect(302, presignedUrl);\n}\n"
  },
  {
    "path": "pages/api/links/download/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { LinkType } from \"@prisma/client\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport { notifyDocumentDownload } from \"@/lib/integrations/slack/events\";\nimport prisma from \"@/lib/prisma\";\nimport { getFileNameWithPdfExtension } from \"@/lib/utils\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\n// This function can run for a maximum of 300 seconds\nexport const config = {\n  maxDuration: 300,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/links/download\n    const { linkId, viewId } = req.body as { linkId: string; viewId: string };\n\n    try {\n      const view = await prisma.view.findUnique({\n        where: {\n          id: viewId,\n          linkId: linkId,\n        },\n        select: {\n          id: true,\n          viewedAt: true,\n          viewerEmail: true,\n          link: {\n            select: {\n              linkType: true,\n              allowDownload: true,\n              expiresAt: true,\n              isArchived: true,\n              deletedAt: true,\n              enableWatermark: true,\n              watermarkConfig: true,\n              name: true,\n            },\n          },\n          document: {\n            select: {\n              id: true,\n              teamId: true,\n              downloadOnly: true,\n              name: true,\n              versions: {\n                where: { isPrimary: true },\n                select: {\n                  type: true,\n                  file: true,\n                  storageType: true,\n                  numPages: true,\n                  originalFile: true,\n                  contentType: true,\n                },\n                take: 1,\n              },\n            },\n          },\n        },\n      });\n\n      // if view does not exist, we should not allow the download\n      if (!view) {\n        return res.status(404).json({ error: \"Error downloading\" });\n      }\n\n      // if document is downloadOnly, always allow. Otherwise, check link settings.\n      if (!view.document?.downloadOnly && !view.link.allowDownload) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if link is archived, we should not allow the download\n      if (view.link.isArchived) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if link is deleted, we should not allow the download\n      if (view.link.deletedAt) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if link is expired, we should not allow the download\n      if (view.link.expiresAt && view.link.expiresAt < new Date()) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if document is a Notion document, we should not allow the download\n      if (view.document!.versions[0].type === \"notion\") {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // if viewedAt is longer than 30 mins ago, we should not allow the download for document links and 23 hours ago for dataroom links\n      if (\n        (view.link.linkType === LinkType.DOCUMENT_LINK &&\n          view.viewedAt < new Date(Date.now() - 30 * 60 * 1000)) ||\n        (view.link.linkType === LinkType.DATAROOM_LINK &&\n          view.viewedAt < new Date(Date.now() - 23 * 60 * 60 * 1000))\n      ) {\n        return res.status(403).json({ error: \"Error downloading\" });\n      }\n\n      // update the view with the downloadedAt timestamp\n      await prisma.view.update({\n        where: { id: viewId },\n        data: { downloadedAt: new Date() },\n      });\n\n      if (view.document?.teamId) {\n        try {\n          await notifyDocumentDownload({\n            teamId: view.document.teamId,\n            documentId: view.document.id,\n            dataroomId: undefined,\n            linkId,\n            viewerEmail: view.viewerEmail ?? undefined,\n            viewerId: undefined,\n          });\n        } catch (error) {\n          console.error(\"Error sending Slack notification:\", error);\n        }\n      }\n\n      // get the file to be downloaded, if watermark is enabled and document is not pdf, then get the pdf file, otherwise return the original file\n      // if watermark is enabled and watermark config is present and document version is pdf, then get the file\n      // if watermark is not enabled, then get the original file\n      const file =\n        view.link.enableWatermark &&\n        view.link.watermarkConfig &&\n        view.document!.versions[0].type === \"pdf\"\n          ? view.document!.versions[0].file\n          : (view.document!.versions[0].originalFile ??\n            view.document!.versions[0].file);\n\n      const downloadUrl = await getFile({\n        type: view.document!.versions[0].storageType,\n        data: file,\n        isDownload: true,\n      });\n\n      if (\n        view.document!.versions[0].type === \"pdf\" &&\n        view.link.enableWatermark &&\n        view.link.watermarkConfig\n      ) {\n        const response = await fetch(\n          `${process.env.NEXTAUTH_URL}/api/mupdf/annotate-document`,\n          {\n            method: \"POST\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n              Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,\n            },\n            body: JSON.stringify({\n              url: downloadUrl,\n              numPages: view.document!.versions[0].numPages,\n              watermarkConfig: view.link.watermarkConfig,\n              originalFileName: view.document!.name,\n              viewerData: {\n                email: view.viewerEmail,\n                date: new Date(\n                  view.viewedAt ? view.viewedAt : new Date(),\n                ).toLocaleDateString(),\n                ipAddress: getIpAddress(req.headers),\n                link: view.link.name,\n                time: new Date(\n                  view.viewedAt ? view.viewedAt : new Date(),\n                ).toLocaleTimeString(),\n              },\n            }),\n          },\n        );\n\n        if (!response.ok) {\n          // Try to get the specific error details from the watermarking API\n          let errorMessage = \"Error downloading\";\n          try {\n            const errorData = await response.json();\n            if (errorData.error && errorData.details) {\n              errorMessage = `${errorData.error}: ${errorData.details}`;\n            } else if (errorData.error) {\n              errorMessage = errorData.error;\n            }\n          } catch {\n            // If we can't parse the error response, use generic message\n            errorMessage = \"Error downloading\";\n          }\n\n          return res.status(500).json({ error: errorMessage });\n        }\n\n        const pdfBuffer = await response.arrayBuffer();\n\n        // Set appropriate headers\n        res.setHeader(\"Content-Type\", \"application/pdf\");\n        res.setHeader(\n          \"Content-Disposition\",\n          `attachment; filename=\"${encodeURIComponent(getFileNameWithPdfExtension(view.document!.name))}\"`,\n        );\n        res.setHeader(\"Content-Length\", Buffer.from(pdfBuffer).length);\n\n        // Send the watermarked buffer directly\n        return res.send(Buffer.from(pdfBuffer));\n      }\n\n      return res\n        .status(200)\n        .json({ downloadUrl, fileName: view.document!.name });\n    } catch (error) {\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n  }\n\n  // We only allow POST requests\n  res.setHeader(\"Allow\", [\"POST\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/links/download/jobs.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getDataroomSessionByLinkIdInPagesRouter } from \"@/lib/auth/dataroom-auth\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const linkId = req.query.linkId as string;\n  if (!linkId) {\n    return res.status(400).json({ error: \"linkId is required\" });\n  }\n\n  const session = await getDataroomSessionByLinkIdInPagesRouter(req, linkId);\n  if (!session) {\n    return res.status(401).json({ error: \"Session required\" });\n  }\n\n  const view = await prisma.view.findUnique({\n    where: { id: session.viewId },\n    select: { viewerEmail: true },\n  });\n  if (!view?.viewerEmail) {\n    return res.status(200).json([]);\n  }\n\n  const jobs = await downloadJobStore.getViewerJobs(\n    linkId,\n    view.viewerEmail,\n    20,\n  );\n\n  // Replace raw S3 presigned URLs with relative proxy URLs so the browser\n  // routes through the current origin (where the session cookie lives).\n  const sanitisedJobs = jobs.map((job) => {\n    const partCount = job.downloadUrls?.length ?? 0;\n    const proxyUrls =\n      job.status === \"COMPLETED\" && partCount > 0\n        ? Array.from(\n            { length: partCount },\n            (_, i) =>\n              `/api/links/download/file/${job.id}/${i}?linkId=${encodeURIComponent(linkId)}`,\n          )\n        : undefined;\n\n    return {\n      id: job.id,\n      status: job.status,\n      progress: job.progress,\n      totalFiles: job.totalFiles,\n      processedFiles: job.processedFiles,\n      downloadUrls: proxyUrls,\n      error: job.status === \"FAILED\" ? job.error : undefined,\n      dataroomName: job.dataroomName,\n      type: job.type,\n      folderName: job.folderName,\n      createdAt: job.createdAt,\n      expiresAt: job.expiresAt,\n    };\n  });\n\n  return res.status(200).json(sanitisedJobs);\n}\n"
  },
  {
    "path": "pages/api/links/download/verify.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { parse } from \"cookie\";\n\nimport {\n  collectFingerprintHeaders,\n  createDataroomSession,\n  generateSessionFingerprint,\n  getDataroomSessionByLinkIdInPagesRouter,\n  updateDataroomSessionVerified,\n} from \"@/lib/auth/dataroom-auth\";\nimport { sendOtpVerificationEmail } from \"@/lib/emails/send-email-otp-verification\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { generateOTP } from \"@/lib/utils/generate-otp\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\nconst OTP_IDENTIFIER_PREFIX = \"download-otp:\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const linkId = req.query.linkId as string;\n    if (!linkId) {\n      return res.status(400).json({ error: \"linkId is required\" });\n    }\n\n    const session = await getDataroomSessionByLinkIdInPagesRouter(req, linkId);\n    if (!session) {\n      return res\n        .status(401)\n        .json({ error: \"Session required\", verified: false });\n    }\n    return res.status(200).json({ verified: session.verified });\n  }\n\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const { linkId, viewId: providedViewId, email, code } = req.body as {\n    linkId?: string;\n    viewId?: string;\n    email?: string;\n    code?: string;\n  };\n\n  if (!linkId || !email) {\n    return res\n      .status(400)\n      .json({ error: \"linkId and email are required\" });\n  }\n\n  // Look up the view: use provided viewId if given, otherwise find by email + linkId\n  const view = providedViewId\n    ? await prisma.view.findUnique({\n        where: { id: providedViewId, linkId, viewType: \"DATAROOM_VIEW\" },\n        select: {\n          id: true,\n          dataroomId: true,\n          viewerId: true,\n          viewerEmail: true,\n          link: { select: { teamId: true } },\n        },\n      })\n    : await prisma.view.findFirst({\n        where: {\n          linkId,\n          viewType: \"DATAROOM_VIEW\",\n          viewerEmail: { equals: email, mode: \"insensitive\" },\n        },\n        select: {\n          id: true,\n          dataroomId: true,\n          viewerId: true,\n          viewerEmail: true,\n          link: { select: { teamId: true } },\n        },\n        orderBy: { viewedAt: \"desc\" },\n      });\n\n  if (!view || view.viewerEmail?.toLowerCase() !== email.toLowerCase()) {\n    return res\n      .status(404)\n      .json({ error: \"View not found or email does not match\" });\n  }\n\n  // Use the resolved viewId from here on\n  const viewId = view.id;\n\n  if (!view.link?.teamId) {\n    return res.status(400).json({ error: \"Link has no team\" });\n  }\n\n  if (!code) {\n    const { success: emailLimitSuccess } = await ratelimit(1, \"30 s\").limit(\n      `${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,\n    );\n    if (!emailLimitSuccess) {\n      return res.status(429).json({\n        error: \"Please wait 30 seconds before requesting another code.\",\n      });\n    }\n\n    const ipAddressValue = getIpAddress(req.headers) ?? \"unknown\";\n    const { success: ipSuccess } = await ratelimit(10, \"1 m\").limit(\n      `download-otp-ip:${ipAddressValue}`,\n    );\n    if (!ipSuccess) {\n      return res.status(429).json({\n        error: \"Too many requests. Try again later.\",\n      });\n    }\n\n    await prisma.verificationToken.deleteMany({\n      where: {\n        identifier: `${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,\n      },\n    });\n\n    const otpCode = generateOTP();\n    const expiresAt = new Date();\n    expiresAt.setMinutes(expiresAt.getMinutes() + 10);\n\n    await prisma.verificationToken.create({\n      data: {\n        token: otpCode,\n        identifier: `${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,\n        expires: expiresAt,\n      },\n    });\n\n    await sendOtpVerificationEmail(email, otpCode, true, view.link.teamId);\n\n    return res.status(200).json({\n      type: \"email-verification\",\n      message: \"Verification code sent to your email.\",\n    });\n  }\n\n  const { success: ipSuccess } = await ratelimit(10, \"1 m\").limit(\n    `download-verify-ip:${getIpAddress(req.headers) ?? \"unknown\"}`,\n  );\n  if (!ipSuccess) {\n    return res.status(429).json({ error: \"Too many requests.\" });\n  }\n\n  const verification = await prisma.verificationToken.findUnique({\n    where: {\n      token: code,\n      identifier: `${OTP_IDENTIFIER_PREFIX}${linkId}:${email.toLowerCase()}`,\n    },\n  });\n\n  if (!verification) {\n    return res.status(401).json({\n      error: \"Invalid or expired code. Request a new code.\",\n      resetVerification: true,\n    });\n  }\n\n  if (Date.now() > verification.expires.getTime()) {\n    await prisma.verificationToken.delete({\n      where: { token: code },\n    });\n    return res.status(401).json({\n      error: \"Code expired. Request a new code.\",\n      resetVerification: true,\n    });\n  }\n\n  await prisma.verificationToken.delete({\n    where: { token: code },\n  });\n\n  await prisma.view.update({\n    where: { id: viewId },\n    data: { verified: true },\n  });\n\n  const cookies = parse(req.headers.cookie || \"\");\n  let sessionToken = cookies[`pm_drs_${linkId}`];\n\n  const needNewSession = async () => {\n    if (!view.dataroomId) return;\n    const ipAddressValue = getIpAddress(req.headers) ?? \"unknown\";\n    const pagesHeader = (name: string) => {\n      const v = req.headers[name];\n      return (Array.isArray(v) ? v[0] : v) ?? null;\n    };\n    const fingerprint = generateSessionFingerprint(\n      collectFingerprintHeaders({ get: pagesHeader }),\n    );\n    const { token, expiresAt } = await createDataroomSession(\n      view.dataroomId,\n      linkId,\n      viewId,\n      ipAddressValue,\n      true,\n      view.viewerId ?? undefined,\n      fingerprint,\n    );\n    sessionToken = token;\n    const maxAge = Math.floor((expiresAt - Date.now()) / 1000);\n    res.setHeader(\"Set-Cookie\", [\n      `pm_drs_${linkId}=${token}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax`,\n    ]);\n  };\n\n  if (sessionToken) {\n    const updated = await updateDataroomSessionVerified(sessionToken, true);\n    // If update failed (e.g. session expired and was deleted), create a new\n    // session so the next request (e.g. bulk) has a valid session.\n    if (!updated) {\n      await needNewSession();\n    }\n  } else {\n    await needNewSession();\n  }\n\n  return res.status(200).json({\n    type: \"verified\",\n    message: \"Email verified. You can receive download notifications.\",\n  });\n}\n"
  },
  {
    "path": "pages/api/links/generate-index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { ItemType } from \"@prisma/client\";\n\nimport { generateDataroomIndex } from \"@/lib/dataroom/index-generator\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { LinkWithDataroom } from \"@/lib/types\";\nimport { IndexFileFormat } from \"@/lib/types/index-file\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // POST /api/links/generate-index\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  const {\n    format = \"excel\",\n    linkId,\n    viewId,\n    dataroomId,\n    viewerId,\n  } = req.body as {\n    format: IndexFileFormat;\n    linkId: string;\n    viewId: string;\n    dataroomId: string;\n    viewerId: string;\n  };\n\n  try {\n    const view = await prisma.view.findUnique({\n      where: {\n        id: viewId,\n        linkId: linkId,\n      },\n      select: {\n        id: true,\n        linkId: true,\n        dataroomId: true,\n        viewerId: true,\n        link: {\n          select: {\n            id: true,\n            dataroomId: true,\n            linkType: true,\n            url: true,\n            name: true,\n            slug: true,\n            expiresAt: true,\n            createdAt: true,\n            updatedAt: true,\n            teamId: true,\n            isArchived: true,\n            deletedAt: true,\n            domainId: true,\n            domainSlug: true,\n            groupId: true,\n            permissionGroupId: true,\n            enableIndexFile: true,\n            dataroom: {\n              select: {\n                id: true,\n                name: true,\n                teamId: true,\n                documents: {\n                  include: {\n                    document: {\n                      include: {\n                        versions: { where: { isPrimary: true } },\n                      },\n                    },\n                  },\n                },\n                folders: true,\n                updatedAt: true,\n                createdAt: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    if (\n      !view ||\n      !view.link ||\n      view.dataroomId !== dataroomId ||\n      view.viewerId !== viewerId\n    ) {\n      return res.status(404).json({ error: \"View not found\" });\n    }\n\n    const link = view.link;\n\n    if (!link || !link.dataroom || link.dataroom.id !== dataroomId) {\n      return res.status(404).json({ error: \"Link not found\" });\n    }\n\n    if (!link.enableIndexFile) {\n      return res\n        .status(404)\n        .json({ error: \"Index file is not enabled for this link\" });\n    }\n\n    // check if link is expired or archived\n    if (link.expiresAt && link.expiresAt < new Date()) {\n      return res.status(404).json({ error: \"Link expired\" });\n    }\n\n    if (link.isArchived) {\n      return res.status(404).json({ error: \"Link archived\" });\n    }\n\n    if (link.deletedAt) {\n      return res.status(404).json({ error: \"Link deleted\" });\n    }\n\n    // Check if the link is a group link and remove the folder/documents from the dataroom if not part of the group permissions\n    if (link.groupId) {\n      const groupAccessControls =\n        await prisma.viewerGroupAccessControls.findMany({\n          where: {\n            groupId: link.groupId,\n            OR: [{ canView: true }, { canDownload: true }],\n          },\n          select: {\n            itemId: true,\n            itemType: true,\n          },\n        });\n\n      const allowedDocuments = groupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_DOCUMENT)\n        .map((control) => control.itemId);\n      const allowedFolders = groupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_FOLDER)\n        .map((control) => control.itemId);\n\n      link.dataroom.documents = link.dataroom.documents.filter((doc) =>\n        allowedDocuments.includes(doc.id),\n      );\n      link.dataroom.folders = link.dataroom.folders.filter((folder) =>\n        allowedFolders.includes(folder.id),\n      );\n    }\n\n    // Check if the link has permission group restrictions and filter accordingly\n    if (link.permissionGroupId) {\n      const permissionGroupAccessControls =\n        await prisma.permissionGroupAccessControls.findMany({\n          where: {\n            groupId: link.permissionGroupId,\n            OR: [{ canView: true }, { canDownload: true }],\n          },\n          select: {\n            itemId: true,\n            itemType: true,\n          },\n        });\n\n      const allowedDocuments = permissionGroupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_DOCUMENT)\n        .map((control) => control.itemId);\n      const allowedFolders = permissionGroupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_FOLDER)\n        .map((control) => control.itemId);\n\n      link.dataroom.documents = link.dataroom.documents.filter((doc) =>\n        allowedDocuments.includes(doc.id),\n      );\n      link.dataroom.folders = link.dataroom.folders.filter((folder) =>\n        allowedFolders.includes(folder.id),\n      );\n    }\n\n    // Map updatedAt to lastUpdatedAt for the dataroom and transform document versions\n    // @ts-ignore\n    const linkWithDataroom: LinkWithDataroom = {\n      ...link,\n      dataroom: {\n        ...link.dataroom,\n        createdAt: link.dataroom.createdAt,\n        lastUpdatedAt: link.dataroom.updatedAt,\n        documents: link.dataroom.documents.map((doc) => ({\n          id: doc.id,\n          folderId: doc.folderId,\n          orderIndex: doc.orderIndex,\n          updatedAt: doc.updatedAt,\n          createdAt: doc.createdAt,\n          hierarchicalIndex: doc.hierarchicalIndex,\n          document: {\n            id: doc.document.id,\n            name: doc.document.name,\n            versions: doc.document.versions.map((version) => ({\n              id: version.id,\n              versionNumber: version.versionNumber,\n              type: version.contentType || \"unknown\",\n              hasPages: version.hasPages,\n              file: version.file,\n              isVertical: version.isVertical,\n              numPages: version.numPages,\n              updatedAt: version.updatedAt,\n              fileSize:\n                typeof version.fileSize === \"bigint\"\n                  ? Number(version.fileSize)\n                  : version.fileSize,\n            })),\n          },\n        })),\n      },\n    };\n\n    const { dataroomIndex } = await getFeatureFlags({\n      teamId: link.dataroom.teamId,\n    });\n\n    // Generate the index file using the appropriate generator\n    const { data, filename, mimeType } = await generateDataroomIndex(\n      linkWithDataroom,\n      {\n        format,\n        baseUrl: link.domainId\n          ? `${link.domainSlug}/${link.slug}`\n          : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`,\n        showHierarchicalIndex: dataroomIndex,\n      },\n    );\n\n    // Set response headers for file download\n    res.setHeader(\"Content-Type\", mimeType);\n    res.setHeader(\"Content-Disposition\", `attachment; filename=${filename}`);\n\n    // Send the file\n    return res.send(data);\n  } catch (error) {\n    console.error(\"Request error\", error);\n    return res.status(500).json({ error: \"Error generating index\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/links/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { LinkAudienceType, Tag } from \"@prisma/client\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser, WatermarkConfigSchema } from \"@/lib/types\";\nimport {\n  decryptEncrpytedPassword,\n  generateEncrpytedPassword,\n} from \"@/lib/utils\";\nimport { sendLinkCreatedWebhook } from \"@/lib/webhook/triggers/link-created\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport interface DomainObject {\n  id: string;\n  slug: string;\n}\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // POST /api/links\n  if (req.method === \"POST\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      targetId,\n      linkType,\n      password,\n      expiresAt,\n      teamId,\n      enableIndexFile,\n      ...linkDomainData\n    } = req.body;\n\n    const userId = (session.user as CustomUser).id;\n\n    const dataroomLink = linkType === \"DATAROOM_LINK\";\n    const documentLink = linkType === \"DOCUMENT_LINK\";\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n        select: { teamId: true },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).json({ error: \"Unauthorized.\" });\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New link creation is not available.\",\n        });\n      }\n\n      const hashedPassword =\n        password && password.length > 0\n          ? await generateEncrpytedPassword(password)\n          : null;\n      const exat = expiresAt ? new Date(expiresAt) : null;\n\n      let { domain, slug, ...linkData } = linkDomainData;\n\n      // set domain and slug to null if the domain is papermark.com\n      if (domain && domain === \"papermark.com\") {\n        domain = null;\n        slug = null;\n      }\n\n      let domainObj: DomainObject | null;\n\n      if (domain && slug) {\n        domainObj = await prisma.domain.findUnique({\n          where: {\n            slug: domain,\n          },\n        });\n\n        if (!domainObj) {\n          return res.status(400).json({ error: \"Domain not found.\" });\n        }\n\n        const existingLink = await prisma.link.findUnique({\n          where: {\n            domainSlug_slug: {\n              slug: slug,\n              domainSlug: domain,\n            },\n          },\n        });\n\n        if (existingLink) {\n          return res.status(400).json({\n            error: \"The link already exists.\",\n          });\n        }\n      }\n\n      if (linkData.enableAgreement && !linkData.agreementId) {\n        return res.status(400).json({\n          error: \"No agreement selected.\",\n        });\n      }\n\n      if (\n        linkData.audienceType === LinkAudienceType.GROUP &&\n        !linkData.groupId\n      ) {\n        return res.status(400).json({\n          error: \"No group selected.\",\n        });\n      }\n\n      if (linkData.enableWatermark) {\n        if (!linkData.watermarkConfig) {\n          return res.status(400).json({\n            error:\n              \"Watermark configuration is required when watermark is enabled.\",\n          });\n        }\n\n        // Validate the watermark config structure\n        const validation = WatermarkConfigSchema.safeParse(\n          linkData.watermarkConfig,\n        );\n        if (!validation.success) {\n          return res.status(400).json({\n            error: \"Invalid watermark configuration.\",\n            details: validation.error.issues\n              .map((issue) => issue.message)\n              .join(\", \"),\n          });\n        }\n      }\n\n      // Validate visitor group IDs belong to this team\n      if (linkData.visitorGroupIds?.length > 0) {\n        const validGroups = await prisma.visitorGroup.findMany({\n          where: {\n            id: { in: linkData.visitorGroupIds },\n            teamId: teamId,\n          },\n          select: { id: true },\n        });\n\n        if (validGroups.length !== linkData.visitorGroupIds.length) {\n          return res.status(400).json({\n            error:\n              \"One or more visitor group IDs do not belong to this team.\",\n          });\n        }\n      }\n\n      // Fetch the link and its related document from the database\n      const updatedLink = await prisma.$transaction(async (tx) => {\n        const link = await tx.link.create({\n          data: {\n            documentId: documentLink ? targetId : null,\n            dataroomId: dataroomLink ? targetId : null,\n            linkType,\n            teamId,\n            ownerId: userId,\n            password: hashedPassword,\n            name: linkData.name || null,\n            emailProtected:\n              linkData.audienceType === LinkAudienceType.GROUP\n                ? true\n                : linkData.emailProtected,\n            emailAuthenticated: linkData.emailAuthenticated,\n            expiresAt: exat,\n            allowDownload: linkData.allowDownload,\n            domainId: domainObj?.id || null,\n            domainSlug: domain || null,\n            slug: slug || null,\n            enableIndexFile: enableIndexFile,\n            enableNotification: linkData.enableNotification,\n            enableFeedback: linkData.enableFeedback,\n            enableScreenshotProtection: linkData.enableScreenshotProtection,\n            enableCustomMetatag: linkData.enableCustomMetatag,\n            metaTitle: linkData.metaTitle || null,\n            metaDescription: linkData.metaDescription || null,\n            metaImage: linkData.metaImage || null,\n            metaFavicon: linkData.metaFavicon || null,\n            welcomeMessage: linkData.welcomeMessage || null,\n            allowList: linkData.allowList,\n            denyList: linkData.denyList,\n            audienceType: linkData.audienceType,\n            groupId:\n              linkData.audienceType === LinkAudienceType.GROUP\n                ? linkData.groupId\n                : null,\n            ...(linkData.enableQuestion && {\n              enableQuestion: linkData.enableQuestion,\n              feedback: {\n                create: {\n                  data: {\n                    question: linkData.questionText,\n                    type: linkData.questionType,\n                  },\n                },\n              },\n            }),\n            ...(linkData.enableAgreement && {\n              enableAgreement: linkData.enableAgreement,\n              agreementId: linkData.agreementId,\n            }),\n            ...(linkData.enableWatermark && {\n              enableWatermark: linkData.enableWatermark,\n              watermarkConfig: linkData.watermarkConfig,\n            }),\n            ...(linkData.enableUpload && {\n              enableUpload: linkData.enableUpload,\n              isFileRequestOnly: linkData.isFileRequestOnly,\n              uploadFolderId: linkData.uploadFolderId,\n            }),\n            enableAIAgents: linkData.enableAIAgents || false,\n            enableConversation: linkData.enableConversation || false,\n            showBanner: linkData.showBanner,\n            ...(linkData.customFields && {\n              customFields: {\n                createMany: {\n                  data: linkData.customFields.map(\n                    (field: any, index: number) => ({\n                      type: field.type,\n                      identifier: field.identifier,\n                      label: field.label,\n                      placeholder: field.placeholder,\n                      required: field.required,\n                      disabled: field.disabled,\n                      orderIndex: index,\n                    }),\n                  ),\n                },\n              },\n            }),\n            // Connect visitor groups for allow-list\n            ...(linkData.visitorGroupIds?.length > 0 && {\n              visitorGroups: {\n                createMany: {\n                  data: linkData.visitorGroupIds.map(\n                    (visitorGroupId: string) => ({\n                      visitorGroupId,\n                    }),\n                  ),\n                },\n              },\n            }),\n          },\n          include: {\n            customFields: true,\n            visitorGroups: {\n              select: {\n                visitorGroupId: true,\n              },\n            },\n          },\n        });\n\n        let tags: Partial<Tag>[] = [];\n        if (linkData.tags?.length) {\n          // create tag items\n          await tx.tagItem.createMany({\n            data: linkData.tags.map((tagId: string) => ({\n              tagId,\n              itemType: \"LINK_TAG\",\n              linkId: link.id,\n              taggedBy: userId,\n            })),\n            skipDuplicates: true,\n          });\n\n          // return tags\n          tags = await tx.tag.findMany({\n            where: { id: { in: linkData.tags } },\n            select: { id: true, name: true, color: true, description: true },\n          });\n        }\n\n        return { ...link, tags };\n      });\n\n      const linkWithView = {\n        ...updatedLink,\n        _count: { views: 0 },\n        views: [],\n      };\n\n      if (!linkWithView) {\n        return res.status(404).json({ error: \"Link not found\" });\n      }\n\n      waitUntil(\n        sendLinkCreatedWebhook({\n          teamId,\n          data: {\n            link_id: linkWithView.id,\n            document_id: linkWithView.documentId,\n            dataroom_id: linkWithView.dataroomId,\n          },\n        }),\n      );\n\n      // Revalidate the view page to pre-generate it\n      await fetch(\n        `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&linkId=${linkWithView.id}&hasDomain=${linkWithView.domainId ? \"true\" : \"false\"}`,\n      );\n\n      // Decrypt the password for the new link\n      if (linkWithView.password !== null) {\n        linkWithView.password = decryptEncrpytedPassword(linkWithView.password);\n      }\n\n      return res.status(200).json(linkWithView);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n\n  return res.status(405).json({ error: \"Method not allowed\" });\n}\n"
  },
  {
    "path": "pages/api/mupdf/annotate-document.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport fontkit from \"@pdf-lib/fontkit\";\nimport { PDFDocument, PDFFont, StandardFonts, degrees, rgb } from \"pdf-lib\";\n\nimport {\n  getFileNameWithPdfExtension,\n  hexToRgb,\n  log,\n  safeTemplateReplace,\n} from \"@/lib/utils\";\n\n// This function can run for a maximum of 300 seconds\nexport const config = {\n  maxDuration: 300,\n};\n\n/**\n * Validates a URL to prevent SSRF attacks.\n * Only allows HTTPS requests to the configured distribution hosts.\n */\nfunction validateUrl(urlString: string): URL {\n  let parsedUrl: URL;\n\n  // Parse the URL\n  try {\n    parsedUrl = new URL(urlString);\n  } catch (error) {\n    throw new Error(\"Invalid URL format\");\n  }\n\n  // Validate protocol - only HTTPS allowed\n  if (parsedUrl.protocol !== \"https:\") {\n    throw new Error(\"Only HTTPS URLs are allowed\");\n  }\n\n  // Get allowed distribution hosts from environment\n  const allowedHosts = [\n    process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_HOST,\n    process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_HOST_US,\n  ].filter((host): host is string => !!host);\n\n  if (allowedHosts.length === 0) {\n    throw new Error(\"No distribution hosts configured\");\n  }\n\n  // Validate hostname against allow-list\n  const hostname = parsedUrl.hostname.toLowerCase();\n  const isAllowedHost = allowedHosts.some(\n    (allowedHost) => hostname === allowedHost.toLowerCase(),\n  );\n\n  if (!isAllowedHost) {\n    throw new Error(\n      \"Host not allowed. Only requests to configured distribution hosts are permitted\",\n    );\n  }\n\n  return parsedUrl;\n}\n\ninterface WatermarkConfig {\n  text: string;\n  isTiled: boolean;\n  position:\n    | \"top-left\"\n    | \"top-center\"\n    | \"top-right\"\n    | \"middle-left\"\n    | \"middle-center\"\n    | \"middle-right\"\n    | \"bottom-left\"\n    | \"bottom-center\"\n    | \"bottom-right\";\n  rotation: 0 | 30 | 45 | 90 | 180;\n  color: string;\n  fontSize: number;\n  opacity: number; // 0 to 0.8\n}\n\ninterface ViewerData {\n  email: string;\n  date: string;\n  ipAddress: string;\n  link: string;\n  time: string;\n}\n\nfunction getPositionCoordinates(\n  position: WatermarkConfig[\"position\"],\n  width: number,\n  height: number,\n  textWidth: number,\n  textHeight: number,\n): number[] {\n  const positions = {\n    \"top-left\": [10, height - textHeight],\n    \"top-center\": [(width - textWidth) / 2, height - textHeight],\n    \"top-right\": [width - textWidth - 10, height - textHeight],\n    \"middle-left\": [10, (height - textHeight) / 2],\n    \"middle-center\": [(width - textWidth) / 2, (height - textHeight) / 2],\n    \"middle-right\": [width - textWidth - 10, (height - textHeight) / 2],\n    \"bottom-left\": [10, 20],\n    \"bottom-center\": [(width - textWidth) / 2, 20],\n    \"bottom-right\": [width - textWidth - 10, 20],\n  };\n  return positions[position];\n}\n\nasync function insertWatermark(\n  pdfDoc: PDFDocument,\n  config: WatermarkConfig,\n  viewerData: ViewerData,\n  pageIndex: number,\n  font: PDFFont, // Pre-embedded font passed in\n): Promise<void> {\n  const pages = pdfDoc.getPages();\n  const page = pages[pageIndex];\n  const { width, height } = page.getSize();\n\n  // Safely replace template variables with whitelisted values only\n  const rawWatermarkText = safeTemplateReplace(config.text, viewerData);\n\n  // Handle Unicode characters that can't be encoded in WinAnsi\n  const sanitizeText = (text: string): string => {\n    // Common character replacements for WinAnsi compatibility\n    const replacements: { [key: string]: string } = {\n      // Turkish characters\n      İ: \"I\",\n      ı: \"i\",\n      ğ: \"g\",\n      Ğ: \"G\",\n      ü: \"u\",\n      Ü: \"U\",\n      ş: \"s\",\n      Ş: \"S\",\n      ç: \"c\",\n      Ç: \"C\",\n      ö: \"o\",\n      Ö: \"O\",\n      // German characters\n      ß: \"ss\",\n      ä: \"a\",\n      Ä: \"A\",\n      ë: \"e\",\n      Ë: \"E\",\n      // French characters\n      à: \"a\",\n      À: \"A\",\n      é: \"e\",\n      É: \"E\",\n      è: \"e\",\n      È: \"E\",\n      ê: \"e\",\n      Ê: \"E\",\n      ù: \"u\",\n      Ù: \"U\",\n      ô: \"o\",\n      Ô: \"O\",\n      // Spanish characters\n      ñ: \"n\",\n      Ñ: \"N\",\n      á: \"a\",\n      Á: \"A\",\n      í: \"i\",\n      Í: \"I\",\n      ó: \"o\",\n      Ó: \"O\",\n      ú: \"u\",\n      Ú: \"U\",\n      // Common symbols\n      \"€\": \"EUR\",\n      \"£\": \"GBP\",\n      \"¥\": \"JPY\",\n      \"©\": \"(c)\",\n      \"®\": \"(R)\",\n      \"™\": \"TM\",\n      \"…\": \"...\",\n      \"–\": \"-\",\n      \"—\": \"-\",\n      \"\\u201C\": '\"',\n      \"\\u201D\": '\"',\n      \"\\u2018\": \"'\",\n      \"\\u2019\": \"'\",\n      \"•\": \"*\",\n    };\n\n    let sanitized = text;\n\n    // Apply character replacements\n    for (const [original, replacement] of Object.entries(replacements)) {\n      sanitized = sanitized.replace(new RegExp(original, \"g\"), replacement);\n    }\n\n    // Replace any remaining non-WinAnsi characters (outside Latin-1 range)\n    sanitized = sanitized.replace(/[^\\u0000-\\u00FF]/g, \"?\");\n\n    return sanitized;\n  };\n\n  const watermarkText = sanitizeText(rawWatermarkText);\n\n  // Calculate a responsive font size\n  const calculateFontSize = () => {\n    const baseFontSize = Math.min(width, height) * (config.fontSize / 1000);\n    return Math.max(8, Math.min(baseFontSize, config.fontSize));\n  };\n  const fontSize = calculateFontSize();\n\n  // Calculate text dimensions with error handling\n  let textWidth: number;\n  let textHeight: number;\n\n  try {\n    textWidth = font.widthOfTextAtSize(watermarkText, fontSize);\n    textHeight = font.heightAtSize(fontSize);\n  } catch (error) {\n    // If there are still encoding issues, provide fallback values\n    console.warn(\"Font encoding error:\", error);\n    textWidth = watermarkText.length * fontSize * 0.6; // Approximate width\n    textHeight = fontSize * 1.2; // Approximate height\n  }\n\n  if (config.isTiled) {\n    const patternWidth = textWidth / 1.1;\n    const patternHeight = textHeight * 15;\n\n    // Calculate the offset to center the pattern\n    const offsetX = -patternWidth / 4;\n    const offsetY = -patternHeight / 4;\n\n    const maxTilesPerRow = Math.ceil(width / patternWidth) + 1;\n    const maxTilesPerColumn = Math.ceil(height / patternHeight) + 1;\n\n    for (let i = 0; i < maxTilesPerRow; i++) {\n      for (let j = 0; j < maxTilesPerColumn; j++) {\n        const x = i * patternWidth + offsetX;\n        const y = j * patternHeight + offsetY;\n\n        try {\n          page.drawText(watermarkText, {\n            x,\n            y,\n            size: fontSize,\n            font,\n            color: hexToRgb(config.color) ?? rgb(0, 0, 0),\n            opacity: config.opacity,\n            rotate: degrees(config.rotation),\n          });\n        } catch (error) {\n          console.error(\"Error drawing tiled watermark text:\", error);\n          throw new Error(\n            `Failed to apply watermark to page ${pageIndex + 1}: ${error}`,\n          );\n        }\n      }\n    }\n  } else {\n    const [x, y] = getPositionCoordinates(\n      config.position,\n      width,\n      height,\n      textWidth,\n      textHeight,\n    );\n\n    try {\n      page.drawText(watermarkText, {\n        x,\n        y,\n        size: fontSize,\n        font,\n        color: hexToRgb(config.color) ?? rgb(0, 0, 0),\n        opacity: config.opacity,\n        rotate: degrees(config.rotation),\n      });\n    } catch (error) {\n      console.error(\"Error drawing positioned watermark text:\", error);\n      throw new Error(\n        `Failed to apply watermark to page ${pageIndex + 1}: ${error}`,\n      );\n    }\n  }\n}\n\nexport default async (req: NextApiRequest, res: NextApiResponse) => {\n  // check if post method\n  if (req.method !== \"POST\") {\n    res.status(405).json({ error: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  const { url, watermarkConfig, viewerData, numPages, originalFileName } =\n    req.body as {\n      url: string;\n      watermarkConfig: WatermarkConfig;\n      viewerData: ViewerData;\n      numPages: number;\n      originalFileName?: string;\n    };\n\n  // Validate required fields\n  if (!url || typeof url !== \"string\") {\n    return res.status(400).json({ error: \"Invalid or missing URL\" });\n  }\n\n  if (!watermarkConfig || typeof watermarkConfig !== \"object\") {\n    return res\n      .status(400)\n      .json({ error: \"Invalid or missing watermark config\" });\n  }\n\n  if (!numPages || typeof numPages !== \"number\" || numPages <= 0) {\n    return res.status(400).json({ error: \"Invalid page count\" });\n  }\n\n  if (numPages > 1000) {\n    return res.status(400).json({\n      error: \"Document too large\",\n      details: \"Maximum 1000 pages supported\",\n    });\n  }\n\n  const startTime = Date.now();\n\n  // Validate URL to prevent SSRF attacks\n  let validatedUrl: URL;\n  try {\n    validatedUrl = validateUrl(url);\n  } catch (error) {\n    const errorMsg = error instanceof Error ? error.message : String(error);\n    log({\n      message: `URL validation failed: ${errorMsg}\\nAttempted URL: ${url}`,\n      type: \"error\",\n      mention: false,\n    });\n    return res.status(400).json({\n      error: \"Invalid URL\",\n      details: errorMsg,\n    });\n  }\n\n  try {\n    // Fetch the PDF data with timeout\n    let response: Response;\n    try {\n      const fetchStart = Date.now();\n      const controller = new AbortController();\n      const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout for fetch\n\n      // Use the validated URL string for the fetch\n      response = await fetch(validatedUrl.toString(), {\n        signal: controller.signal,\n        headers: {\n          Accept: \"application/pdf\",\n        },\n      });\n\n      clearTimeout(timeoutId);\n\n      if (!response.ok) {\n        throw new Error(`HTTP ${response.status}: ${response.statusText}`);\n      }\n\n      console.log(`PDF fetch took ${Date.now() - fetchStart}ms`);\n    } catch (error) {\n      const errorMsg = error instanceof Error ? error.message : String(error);\n      log({\n        message: `Failed to fetch PDF in conversion process with error: \\n\\n Error: ${errorMsg}\\nURL: ${url}`,\n        type: \"error\",\n        mention: true,\n      });\n\n      if (errorMsg.includes(\"aborted\")) {\n        throw new Error(`Timeout fetching PDF (exceeded 60s)`);\n      }\n      throw new Error(`Failed to fetch PDF: ${errorMsg}`);\n    }\n\n    // Convert the response to a buffer\n    const bufferStart = Date.now();\n    const pdfBuffer = await response.arrayBuffer();\n    const sizeInMB = pdfBuffer.byteLength / 1024 / 1024;\n    console.log(\n      `Buffer conversion took ${Date.now() - bufferStart}ms, size: ${sizeInMB.toFixed(2)}MB`,\n    );\n\n    // Load the PDF document with optimizations\n    const loadStart = Date.now();\n    const pdfDoc = await PDFDocument.load(pdfBuffer, {\n      updateMetadata: false, // Skip metadata updates for performance\n      ignoreEncryption: false, // Respect encryption\n    });\n    console.log(`PDF load took ${Date.now() - loadStart}ms`);\n\n    // Register fontkit and embed font ONCE\n    pdfDoc.registerFontkit(fontkit);\n    const fontStart = Date.now();\n    const font = await pdfDoc.embedFont(StandardFonts.Helvetica);\n    console.log(`Font embedding took ${Date.now() - fontStart}ms`);\n\n    // Calculate optimal batch size based on document size and page count\n    // Larger documents = smaller batches to prevent memory spikes\n    const calculateBatchSize = (pages: number, sizeMB: number): number => {\n      if (sizeMB > 50 || pages > 200) return 5; // Large docs: 5 pages at a time\n      if (sizeMB > 20 || pages > 100) return 10; // Medium docs: 10 pages\n      return 20; // Smaller docs: 20 pages at a time\n    };\n\n    const BATCH_SIZE = calculateBatchSize(numPages, sizeInMB);\n    const watermarkStart = Date.now();\n\n    console.log(`Processing ${numPages} pages in batches of ${BATCH_SIZE}`);\n\n    for (let batchStart = 0; batchStart < numPages; batchStart += BATCH_SIZE) {\n      const batchEnd = Math.min(batchStart + BATCH_SIZE, numPages);\n      const batchPromises = [];\n\n      for (let i = batchStart; i < batchEnd; i++) {\n        batchPromises.push(\n          insertWatermark(pdfDoc, watermarkConfig, viewerData, i, font).catch(\n            (error) => {\n              const errMsg =\n                error instanceof Error ? error.message : String(error);\n              console.error(`Error watermarking page ${i + 1}:`, errMsg);\n              throw new Error(`Failed to watermark page ${i + 1}: ${errMsg}`);\n            },\n          ),\n        );\n      }\n\n      await Promise.all(batchPromises);\n      const progress = ((batchEnd / numPages) * 100).toFixed(1);\n      console.log(\n        `Processed pages ${batchStart + 1}-${batchEnd} of ${numPages} (${progress}%)`,\n      );\n    }\n\n    console.log(`All watermarking took ${Date.now() - watermarkStart}ms`);\n\n    // Save the modified PDF with optimizations\n    const saveStart = Date.now();\n    const pdfBytes = await pdfDoc.save({\n      useObjectStreams: false, // Better compatibility, slight size increase\n      addDefaultPage: false, // Don't add default page\n      objectsPerTick: 50, // Process objects in smaller chunks for better memory management\n    });\n    const finalSizeMB = pdfBytes.length / 1024 / 1024;\n    console.log(\n      `PDF save took ${Date.now() - saveStart}ms, final size: ${finalSizeMB.toFixed(2)}MB`,\n    );\n\n    console.log(\n      `Total processing time: ${Date.now() - startTime}ms for ${numPages} pages`,\n    );\n\n    // Set appropriate headers\n    res.setHeader(\"Content-Type\", \"application/pdf\");\n    res.setHeader(\n      \"Content-Disposition\",\n      `attachment; filename=\"${encodeURIComponent(getFileNameWithPdfExtension(originalFileName))}\"`,\n    );\n\n    res.status(200).send(Buffer.from(pdfBytes));\n\n    return;\n  } catch (error) {\n    const errorMessage = error instanceof Error ? error.message : String(error);\n    const elapsedTime = Date.now() - startTime;\n\n    // Determine appropriate status code based on error type\n    let statusCode = 500;\n    let errorType = \"Failed to apply watermark\";\n\n    if (errorMessage.includes(\"Timeout\") || errorMessage.includes(\"timeout\")) {\n      statusCode = 504;\n      errorType = \"Request timeout\";\n    } else if (\n      errorMessage.includes(\"too large\") ||\n      errorMessage.includes(\"Maximum\")\n    ) {\n      statusCode = 413;\n      errorType = \"Document too large\";\n    } else if (\n      errorMessage.includes(\"fetch\") ||\n      errorMessage.includes(\"HTTP\")\n    ) {\n      statusCode = 502;\n      errorType = \"Failed to fetch document\";\n    } else if (errorMessage.includes(\"Failed to watermark page\")) {\n      statusCode = 500;\n      errorType = \"Watermarking error\";\n    }\n\n    log({\n      message: `${errorType} after ${elapsedTime}ms: ${errorMessage}\\n\\nDocument: ${originalFileName || \"unknown\"}\\nPages: ${numPages}\\nURL: ${url?.substring(0, 100)}...`,\n      type: \"error\",\n      mention: elapsedTime > 120000, // Only mention if it took more than 2 minutes\n    });\n\n    // Return proper error response\n    res.status(statusCode).json({\n      error: errorType,\n      details: errorMessage,\n      processingTime: elapsedTime,\n    });\n    return;\n  }\n};\n"
  },
  {
    "path": "pages/api/mupdf/convert-page.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { DocumentPage } from \"@prisma/client\";\nimport { get } from \"@vercel/edge-config\";\nimport { waitUntil } from \"@vercel/functions\";\nimport * as mupdf from \"mupdf\";\n\nimport { putFileServer } from \"@/lib/files/put-file-server\";\nimport prisma from \"@/lib/prisma\";\nimport { log } from \"@/lib/utils\";\n\n// This function can run for a maximum of 120 seconds\nexport const config = {\n  maxDuration: 180,\n};\n\nexport default async (req: NextApiRequest, res: NextApiResponse) => {\n  // check if post method\n  if (req.method !== \"POST\") {\n    res.status(405).json({ error: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  const { documentVersionId, pageNumber, url, teamId, trustedTeam } =\n    req.body as {\n      documentVersionId: string;\n      pageNumber: number;\n      url: string;\n      teamId: string;\n      trustedTeam?: boolean;\n    };\n\n  try {\n    // Fetch the PDF data\n    let response: Response;\n    try {\n      response = await fetch(url);\n    } catch (error) {\n      log({\n        message: `Failed to fetch PDF in conversion process with error: \\n\\n Error: ${error} \\n\\n \\`Metadata: {teamId: ${teamId}, documentVersionId: ${documentVersionId}, pageNumber: ${pageNumber}}\\``,\n        type: \"error\",\n        mention: true,\n      });\n      throw new Error(`Failed to fetch pdf on document page ${pageNumber}`);\n    }\n\n    // Convert the response to a buffer\n    const pdfData = await response.arrayBuffer();\n    // Create a MuPDF instance\n    var doc = new mupdf.PDFDocument(pdfData);\n    console.log(\"Original document size:\", pdfData.byteLength);\n\n    const page = doc.loadPage(pageNumber - 1); // 0-based page index\n    // get the bounds of the page for orientation and scaling\n    const bounds = page.getBounds();\n    const [ulx, uly, lrx, lry] = bounds;\n    const widthInPoints = Math.abs(lrx - ulx);\n    const heightInPoints = Math.abs(lry - uly);\n\n    // Validate document dimensions\n    if (widthInPoints <= 0 || heightInPoints <= 0) {\n      throw new Error(\n        `Invalid page dimensions: ${widthInPoints} × ${heightInPoints} points`,\n      );\n    }\n\n    // Log original dimensions for debugging\n    console.log(\n      `Original page dimensions: ${widthInPoints} × ${heightInPoints} points (${(widthInPoints / 72).toFixed(1)}\" × ${(heightInPoints / 72).toFixed(1)}\")`,\n    );\n\n    if (pageNumber === 1) {\n      // get the orientation of the document and update document version\n      const isVertical = heightInPoints > widthInPoints;\n\n      await prisma.documentVersion.update({\n        where: { id: documentVersionId },\n        data: { isVertical },\n      });\n    }\n\n    // Calculate optimal scale factor based on document dimensions and memory constraints\n    const getOptimalScaleFactor = (width: number, height: number): number => {\n      // Maximum reasonable pixel dimensions to prevent memory issues\n      const MAX_PIXEL_DIMENSION = 8000;\n      const MAX_TOTAL_PIXELS = 32_000_000; // ~32MP to stay within memory limits\n\n      // Start with default scaling logic\n      // Note: Avoid scale factor 3 exactly due to mupdf 1.26.4 rendering bug with tiling patterns\n      let scaleFactor = width >= 1600 ? 2 : 2.95;\n\n      // Check if scaled dimensions would exceed limits\n      const scaledWidth = width * scaleFactor;\n      const scaledHeight = height * scaleFactor;\n      const totalPixels = scaledWidth * scaledHeight;\n\n      // Reduce scale factor if dimensions are too large\n      if (\n        scaledWidth > MAX_PIXEL_DIMENSION ||\n        scaledHeight > MAX_PIXEL_DIMENSION ||\n        totalPixels > MAX_TOTAL_PIXELS\n      ) {\n        // Calculate maximum safe scale factor\n        const maxScaleByWidth = MAX_PIXEL_DIMENSION / width;\n        const maxScaleByHeight = MAX_PIXEL_DIMENSION / height;\n        const maxScaleByTotal = Math.sqrt(MAX_TOTAL_PIXELS / (width * height));\n\n        scaleFactor = Math.min(\n          maxScaleByWidth,\n          maxScaleByHeight,\n          maxScaleByTotal,\n        );\n\n        // Ensure minimum scale factor of 1\n        scaleFactor = Math.max(1, Math.floor(scaleFactor * 10) / 10); // Round down to 1 decimal\n\n        console.log(\n          `Large document detected. Reduced scale factor from ${width >= 1600 ? 2 : 2.95} to ${scaleFactor}`,\n        );\n      }\n\n      return scaleFactor;\n    };\n\n    const scaleFactor = getOptimalScaleFactor(widthInPoints, heightInPoints);\n    const doc_to_screen = mupdf.Matrix.scale(scaleFactor, scaleFactor);\n\n    console.log(\"Scale factor:\", scaleFactor);\n    console.log(\n      \"Final dimensions:\",\n      `${widthInPoints * scaleFactor} × ${heightInPoints * scaleFactor}`,\n    );\n\n    // get links\n    const links = page.getLinks();\n    const embeddedLinks = links.map((link) => {\n      const coords = link.getBounds().join(\",\");\n\n      // Check if this is an internal link (GoTo action for TOC, etc.)\n      if (!link.isExternal()) {\n        try {\n          // Resolve internal link to page number (0-indexed from mupdf)\n          const targetPage = doc.resolveLink(link);\n          if (targetPage >= 0) {\n            return {\n              href: `#page=${targetPage + 1}`, // Convert to 1-indexed for frontend\n              coords,\n              isInternal: true,\n              targetPage: targetPage + 1,\n            };\n          }\n        } catch (e) {\n          console.log(\"Failed to resolve internal link:\", e);\n        }\n        // Fallback for unresolvable internal links\n        return { href: \"\", coords, isInternal: true };\n      }\n\n      // External URI link\n      return { href: link.getURI(), coords, isInternal: false };\n    });\n\n    // Check embedded links for blocked keywords (skip for trusted teams)\n    if (embeddedLinks.length > 0 && !trustedTeam) {\n      try {\n        const keywords = await get(\"keywords\");\n        if (Array.isArray(keywords) && keywords.length > 0) {\n          for (const link of embeddedLinks) {\n            if (link.href) {\n              const matchedKeyword = keywords.find(\n                (keyword) =>\n                  typeof keyword === \"string\" &&\n                  link.href.toLowerCase().includes(keyword.toLowerCase()),\n              );\n\n              if (matchedKeyword) {\n                waitUntil(\n                  log({\n                    message: `Document processing blocked: ${matchedKeyword} \\n\\n \\`Metadata: {teamId: ${teamId}, documentVersionId: ${documentVersionId}, pageNumber: ${pageNumber}}\\``,\n                    type: \"error\",\n                    mention: true,\n                  }),\n                );\n                res.status(400).json({\n                  error: \"Document processing blocked\",\n                  matchedUrl: link.href,\n                  matchedKeyword: matchedKeyword,\n                  pageNumber: pageNumber,\n                });\n                return;\n              }\n            }\n          }\n        }\n      } catch (error) {\n        // Log error but continue processing if check fails\n        console.log(\"Failed to check keywords:\", error);\n      }\n    }\n\n    // Will be updated if we use a reduced scale factor\n    let actualScaleFactor = scaleFactor;\n\n    const metadata = {\n      originalWidth: widthInPoints,\n      originalHeight: heightInPoints,\n      width: widthInPoints * actualScaleFactor,\n      height: heightInPoints * actualScaleFactor,\n      scaleFactor: actualScaleFactor,\n    };\n\n    // Estimate memory usage before creating pixmap\n    const finalWidth = Math.floor(widthInPoints * scaleFactor);\n    const finalHeight = Math.floor(heightInPoints * scaleFactor);\n    const estimatedMemoryMB = (finalWidth * finalHeight * 3) / (1024 * 1024); // RGB = 3 bytes per pixel\n\n    console.log(\n      `Estimated memory usage: ${estimatedMemoryMB.toFixed(1)}MB for ${finalWidth} × ${finalHeight} pixels`,\n    );\n\n    // Warn if memory usage is high\n    if (estimatedMemoryMB > 200) {\n      console.warn(\n        `High memory usage expected: ${estimatedMemoryMB.toFixed(1)}MB. Consider reducing document size.`,\n      );\n    }\n\n    console.time(\"toPixmap\");\n    let scaledPixmap;\n    try {\n      scaledPixmap = page.toPixmap(\n        doc_to_screen,\n        mupdf.ColorSpace.DeviceRGB,\n        false,\n        true,\n      );\n    } catch (error) {\n      // If pixmap creation fails, try with a smaller scale factor\n      console.error(\n        \"Pixmap creation failed, attempting with reduced scale factor:\",\n        error,\n      );\n      const reducedScaleFactor = Math.max(1, scaleFactor * 0.5);\n      console.log(`Retrying with reduced scale factor: ${reducedScaleFactor}`);\n\n      const reduced_doc_to_screen = mupdf.Matrix.scale(\n        reducedScaleFactor,\n        reducedScaleFactor,\n      );\n      scaledPixmap = page.toPixmap(\n        reduced_doc_to_screen,\n        mupdf.ColorSpace.DeviceRGB,\n        false,\n        true,\n      );\n\n      // Update metadata with actual scale factor used\n      actualScaleFactor = reducedScaleFactor;\n      metadata.width = widthInPoints * actualScaleFactor;\n      metadata.height = heightInPoints * actualScaleFactor;\n      metadata.scaleFactor = actualScaleFactor;\n      console.log(\n        \"Successfully created pixmap with reduced scale factor:\",\n        actualScaleFactor,\n      );\n    }\n    console.timeEnd(\"toPixmap\");\n\n    console.time(\"compare\");\n    console.time(\"asPNG\");\n    const pngBuffer = scaledPixmap.asPNG(); // as PNG\n    console.timeEnd(\"asPNG\");\n    console.time(\"asJPEG\");\n    const jpegBuffer = scaledPixmap.asJPEG(80, false); // as JPEG\n    console.timeEnd(\"asJPEG\");\n\n    const pngSize = pngBuffer.byteLength;\n    const jpegSize = jpegBuffer.byteLength;\n\n    let chosenBuffer;\n    let chosenFormat;\n    if (pngSize < jpegSize) {\n      chosenBuffer = pngBuffer;\n      chosenFormat = \"png\";\n    } else {\n      chosenBuffer = jpegBuffer;\n      chosenFormat = \"jpeg\";\n    }\n\n    console.log(\"Chosen format:\", chosenFormat);\n\n    console.timeEnd(\"compare\");\n\n    let buffer = Buffer.from(chosenBuffer);\n\n    // get docId from url with starts with \"doc_\" with regex\n    const match = url.match(/(doc_[^\\/]+)\\//);\n    const docId = match ? match[1] : undefined;\n\n    const { type, data } = await putFileServer({\n      file: {\n        name: `page-${pageNumber}.${chosenFormat}`,\n        type: `image/${chosenFormat}`,\n        buffer: buffer,\n      },\n      teamId: teamId,\n      docId: docId,\n    });\n\n    buffer = Buffer.alloc(0); // free memory\n    chosenBuffer = Buffer.alloc(0); // free memory\n    scaledPixmap.destroy(); // free memory\n    page.destroy(); // free memory\n\n    if (!data || !type) {\n      throw new Error(`Failed to upload document page ${pageNumber}`);\n    }\n\n    let documentPage: DocumentPage | null = null;\n\n    // Check if a documentPage with the same pageNumber and versionId already exists\n    const existingPage = await prisma.documentPage.findUnique({\n      where: {\n        pageNumber_versionId: {\n          pageNumber: pageNumber,\n          versionId: documentVersionId,\n        },\n      },\n    });\n\n    if (!existingPage) {\n      // Only create a new documentPage if it doesn't already exist\n      documentPage = await prisma.documentPage.create({\n        data: {\n          versionId: documentVersionId,\n          pageNumber: pageNumber,\n          file: data,\n          storageType: type,\n          pageLinks: embeddedLinks,\n          metadata: metadata,\n        },\n      });\n    } else {\n      documentPage = existingPage;\n    }\n\n    // Send the images as a response\n    res.status(200).json({ documentPageId: documentPage.id });\n    return;\n  } catch (error) {\n    log({\n      message: `Failed to convert page with error: \\n\\n Error: ${error} \\n\\n \\`Metadata: {teamId: ${teamId}, documentVersionId: ${documentVersionId}, pageNumber: ${pageNumber}}\\``,\n      type: \"error\",\n      mention: true,\n    });\n    throw error;\n  }\n};\n"
  },
  {
    "path": "pages/api/mupdf/get-pages.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport * as mupdf from \"mupdf\";\n\nexport default async (req: NextApiRequest, res: NextApiResponse) => {\n  // check if post method\n  if (req.method !== \"POST\") {\n    res.status(405).json({ error: \"Method Not Allowed\" });\n    return;\n  }\n\n  // Extract the API Key from the Authorization header\n  const authHeader = req.headers.authorization;\n  const token = authHeader?.split(\" \")[1]; // Assuming the format is \"Bearer [token]\"\n\n  // Check if the API Key matches\n  if (token !== process.env.INTERNAL_API_KEY) {\n    res.status(401).json({ message: \"Unauthorized\" });\n    return;\n  }\n\n  try {\n    const { url } = req.body as { url: string };\n    // Fetch the PDF data\n    const response = await fetch(url);\n    // Convert the response to an ArrayBuffer\n    const pdfData = await response.arrayBuffer();\n    // Create a MuPDF instance\n    var doc = new mupdf.PDFDocument(pdfData);\n\n    var n = doc.countPages();\n\n    // Send the images as a response\n    res.status(200).json({ numPages: n });\n  } catch (error) {\n    console.error(\"Error:\", error);\n    res.status(500).json({ error: \"Internal Server Error\" });\n  }\n};\n"
  },
  {
    "path": "pages/api/notification-preferences/dataroom.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { verifyUnsubscribeToken } from \"@/lib/utils/unsubscribe\";\nimport { ZViewerNotificationPreferencesSchema } from \"@/lib/zod/schemas/notifications\";\n\nconst UpdatePreferencesSchema = z.object({\n  frequency: z.enum([\"instant\", \"daily\", \"weekly\", \"disabled\"]),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\" && req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  const { token } = req.query as { token: string };\n\n  if (!token) {\n    res.status(400).json({ message: \"Token is required\" });\n    return;\n  }\n\n  const payload = verifyUnsubscribeToken(token);\n\n  if (!payload) {\n    res.status(400).json({ message: \"Invalid or expired token\" });\n    return;\n  }\n\n  if (payload.exp && payload.exp < Date.now() / 1000) {\n    res.status(400).json({ message: \"Token expired\" });\n    return;\n  }\n\n  const { viewerId, dataroomId, teamId } = payload;\n\n  if (!dataroomId) {\n    res.status(400).json({ message: \"Dataroom ID is required\" });\n    return;\n  }\n\n  if (req.method === \"GET\") {\n    return res.redirect(`/notification-preferences?token=${token}`);\n  }\n\n  // POST: update preferences\n  const ipAddress =\n    req.headers[\"x-forwarded-for\"] || req.socket.remoteAddress || \"127.0.0.1\";\n  const { success, limit, reset, remaining } = await ratelimit(5, \"1 m\").limit(\n    `notification_prefs_${ipAddress}`,\n  );\n\n  res.setHeader(\"Retry-After\", reset.toString());\n  res.setHeader(\"X-RateLimit-Limit\", limit.toString());\n  res.setHeader(\"X-RateLimit-Remaining\", remaining.toString());\n  res.setHeader(\"X-RateLimit-Reset\", reset.toString());\n\n  if (!success) {\n    return res.status(429).json({ error: \"Too many requests\" });\n  }\n\n  // RFC 8058 one-click unsubscribe: email clients POST with\n  // body \"List-Unsubscribe=One-Click\" (form-urlencoded)\n  const isOneClick =\n    req.body?.[\"List-Unsubscribe\"] === \"One-Click\" ||\n    (typeof req.body === \"string\" &&\n      req.body.includes(\"List-Unsubscribe=One-Click\"));\n\n  try {\n    const frequency = isOneClick\n      ? \"disabled\"\n      : UpdatePreferencesSchema.parse(req.body).frequency;\n\n    const viewer = await prisma.viewer.findUnique({\n      where: { id: viewerId, teamId },\n      select: { notificationPreferences: true },\n    });\n\n    if (!viewer) {\n      return res.status(404).json({ message: \"Viewer not found\" });\n    }\n\n    const parsedPreferences = ZViewerNotificationPreferencesSchema.safeParse(\n      viewer.notificationPreferences,\n    );\n\n    const rawPrefs =\n      typeof viewer.notificationPreferences === \"object\" &&\n      viewer.notificationPreferences !== null\n        ? (viewer.notificationPreferences as Record<string, unknown>)\n        : {};\n\n    const base = {\n      ...rawPrefs,\n      ...(parsedPreferences.success ? parsedPreferences.data : {}),\n    };\n\n    const isDisabled = frequency === \"disabled\";\n    const updatedPreferences = {\n      ...base,\n      dataroom: {\n        ...(base.dataroom && typeof base.dataroom === \"object\"\n          ? base.dataroom\n          : {}),\n        [dataroomId]: {\n          enabled: !isDisabled,\n          frequency: isDisabled ? \"instant\" : frequency,\n        },\n      },\n    };\n\n    await prisma.viewer.update({\n      where: { id: viewerId, teamId },\n      data: { notificationPreferences: updatedPreferences },\n    });\n\n    return res\n      .status(200)\n      .json({ message: \"Notification preferences updated successfully.\" });\n  } catch (error) {\n    if (error instanceof z.ZodError) {\n      return res.status(400).json({ message: \"Invalid request body\", errors: error.errors });\n    }\n    console.error(\"Failed to update notification preferences:\", error);\n    return res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/passkeys/register.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport {\n  finishServerPasskeyRegistration,\n  startServerPasskeyRegistration,\n} from \"@/lib/api/auth/passkey\";\nimport { errorhandler } from \"@/lib/errorHandler\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { start, finish, credential } = req.body as {\n      start: boolean;\n      finish: boolean;\n      credential: any;\n    };\n\n    try {\n      if (start) {\n        const createOptions = await startServerPasskeyRegistration({ session });\n        res.status(200).json({ createOptions });\n        return;\n      }\n      if (finish) {\n        await finishServerPasskeyRegistration({ credential, session });\n        res.status(200).json({ message: \"Registered Passkey\" });\n        return;\n      }\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n\n  return res.status(405).json({ error: \"Method not allowed\" });\n}\n"
  },
  {
    "path": "pages/api/progress-token.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { generateTriggerPublicAccessToken } from \"@/lib/utils/generate-trigger-auth-token\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  const { documentVersionId } = req.query;\n\n  if (!documentVersionId || typeof documentVersionId !== \"string\") {\n    return res.status(400).json({ error: \"Document version ID is required\" });\n  }\n\n  try {\n    const publicAccessToken = await generateTriggerPublicAccessToken(\n      `version:${documentVersionId}`,\n    );\n    return res.status(200).json({ publicAccessToken });\n  } catch (error) {\n    console.error(\"Error generating token:\", error);\n    return res.status(500).json({ error: \"Failed to generate token\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/record_click.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { z } from \"zod\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport { recordClickEvent } from \"@/lib/tinybird\";\nimport { log } from \"@/lib/utils\";\n\nconst bodyValidation = z.object({\n  timestamp: z.string(),\n  event_id: z.string(),\n  session_id: z.string(),\n  link_id: z.string(),\n  document_id: z.string(),\n  view_id: z.string(),\n  page_number: z.string(),\n  href: z.string(),\n  version_number: z.number(),\n  dataroom_id: z.string().nullable(),\n});\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const {\n    timestamp,\n    sessionId,\n    linkId,\n    documentId,\n    viewId,\n    pageNumber,\n    href,\n    versionNumber,\n    dataroomId,\n  } = req.body as {\n    timestamp: string;\n    sessionId: string;\n    linkId: string;\n    documentId: string;\n    viewId: string;\n    pageNumber: string;\n    href: string;\n    versionNumber: number;\n    dataroomId: string | null;\n  };\n\n  const clickEventId = newId(\"clickEvent\");\n\n  const clickEventObject = {\n    timestamp: timestamp,\n    event_id: clickEventId,\n    session_id: sessionId,\n    link_id: linkId,\n    document_id: documentId,\n    view_id: viewId,\n    page_number: pageNumber,\n    href: href,\n    version_number: versionNumber || 1,\n    dataroom_id: dataroomId || null,\n  };\n\n  const result = bodyValidation.safeParse(clickEventObject);\n  if (!result.success) {\n    return res\n      .status(400)\n      .json({ error: `Invalid body: ${result.error.message}` });\n  }\n\n  try {\n    await recordClickEvent(result.data);\n    res.status(200).json({ message: \"Click event recorded\" });\n  } catch (error) {\n    log({\n      message: `Failed to record click event (tinybird) for ${linkId}. \\n\\n ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    res.status(500).json({ error: \"Failed to record click event\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/record_reaction.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  // POST /api/record_reaction\n\n  const { viewId, pageNumber, type } = req.body as {\n    viewId: string;\n    pageNumber: number;\n    type: string;\n  };\n\n  try {\n    const reaction = await prisma.reaction.create({\n      data: {\n        viewId,\n        pageNumber,\n        type,\n      },\n      include: {\n        view: {\n          select: {\n            documentId: true,\n            dataroomId: true,\n            linkId: true,\n            viewerEmail: true,\n            viewerId: true,\n            teamId: true,\n          },\n        },\n      },\n    });\n\n    if (!reaction) {\n      res.status(500).json({ message: \"Internal Server Error\" });\n      return;\n    }\n\n\n\n    res.status(200).json({ message: \"Reaction recorded\" });\n    return;\n  } catch (error) {\n    res.status(500).json({ message: \"Internal Server Error\" });\n    return;\n  }\n}\n"
  },
  {
    "path": "pages/api/record_video_view.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { z } from \"zod\";\n\nimport { EU_COUNTRY_CODES, VIDEO_EVENT_TYPES } from \"@/lib/constants\";\nimport { newId } from \"@/lib/id-helper\";\nimport { recordVideoView } from \"@/lib/tinybird\";\nimport { Geo } from \"@/lib/types\";\nimport { capitalize, getDomainWithoutWWW, log } from \"@/lib/utils\";\nimport { LOCALHOST_GEO_DATA, getGeoData } from \"@/lib/utils/geo\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\nimport { userAgentFromString } from \"@/lib/utils/user-agent\";\n\nconst bodyValidation = z.object({\n  timestamp: z.string(),\n  id: z.string(),\n  link_id: z.string(),\n  document_id: z.string(),\n  view_id: z.string(),\n  dataroom_id: z.string().nullable(),\n  version_number: z.number(),\n  event_type: z.enum(VIDEO_EVENT_TYPES),\n  start_time: z.number(),\n  end_time: z.number(),\n  playback_rate: z.number().transform((rate) => Math.round(rate * 100)), // 1.5 -> 150\n  volume: z.number().transform((vol) => Math.round(vol * 100)), // 0.7 -> 70\n  is_muted: z.number(),\n  is_focused: z.number(),\n  is_fullscreen: z.number(),\n  country: z.string().optional(),\n  city: z.string().optional(),\n  region: z.string().optional(),\n  latitude: z.string().optional(),\n  longitude: z.string().optional(),\n  ua: z.string().optional(),\n  browser: z.string().optional(),\n  browser_version: z.string().optional(),\n  engine: z.string().optional(),\n  engine_version: z.string().optional(),\n  os: z.string().optional(),\n  os_version: z.string().optional(),\n  device: z.string().optional(),\n  device_vendor: z.string().optional(),\n  device_model: z.string().optional(),\n  cpu_architecture: z.string().optional(),\n  bot: z.boolean().optional(),\n  referer: z.string().optional(),\n  referer_url: z.string().optional(),\n  ip_address: z.string().nullable(),\n});\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const geo: Geo =\n    process.env.VERCEL === \"1\" ? getGeoData(req.headers) : LOCALHOST_GEO_DATA;\n  const isEuCountry = geo.country && EU_COUNTRY_CODES.includes(geo.country);\n\n  // Get user agent data\n  const ua = userAgentFromString(req.headers[\"user-agent\"]);\n  const referer = req.headers.referer;\n  const refererDomain = referer ? getDomainWithoutWWW(referer) : \"(direct)\";\n\n  const ipAddress = getIpAddress(req.headers);\n\n  const videoViewId = newId(\"videoView\");\n\n  const {\n    timestamp,\n    linkId,\n    documentId,\n    viewId,\n    dataroomId,\n    versionNumber,\n    startTime,\n    endTime,\n    playbackRate,\n    volume,\n    isMuted,\n    isFocused,\n    isFullscreen,\n    eventType,\n  } = req.body as {\n    timestamp: string;\n    linkId: string;\n    documentId: string;\n    viewId: string;\n    dataroomId: string | null;\n    versionNumber: number;\n    startTime: number;\n    endTime: number | undefined;\n    playbackRate: number;\n    volume: number;\n    isMuted: number;\n    isFocused: number;\n    isFullscreen: number;\n    eventType: string;\n  };\n\n  const videoViewObject = {\n    timestamp: timestamp,\n    id: videoViewId,\n    link_id: linkId,\n    document_id: documentId,\n    view_id: viewId,\n    dataroom_id: dataroomId || null,\n    version_number: versionNumber || 1,\n    event_type: eventType,\n    start_time: startTime,\n    end_time: endTime,\n    playback_rate: playbackRate,\n    volume,\n    is_muted: isMuted ? 1 : 0,\n    is_focused: isFocused ? 1 : 0,\n    is_fullscreen: isFullscreen ? 1 : 0,\n    country: geo?.country || \"Unknown\",\n    city: geo?.city || \"Unknown\",\n    region: geo?.region || \"Unknown\",\n    latitude: geo?.latitude || \"Unknown\",\n    longitude: geo?.longitude || \"Unknown\",\n    ua: ua.ua || \"Unknown\",\n    browser: ua.browser.name || \"Unknown\",\n    browser_version: ua.browser.version || \"Unknown\",\n    engine: ua.engine.name || \"Unknown\",\n    engine_version: ua.engine.version || \"Unknown\",\n    os: ua.os.name || \"Unknown\",\n    os_version: ua.os.version || \"Unknown\",\n    device: ua.device.type ? capitalize(ua.device.type) : \"Desktop\",\n    device_vendor: ua.device.vendor || \"Unknown\",\n    device_model: ua.device.model || \"Unknown\",\n    cpu_architecture: ua.cpu?.architecture || \"Unknown\",\n    bot: ua.isBot,\n    referer: refererDomain,\n    referer_url: referer || \"(direct)\",\n    ip_address:\n      // only record IP if it's a valid IP and not from a EU country\n      typeof ipAddress === \"string\" &&\n      ipAddress.trim().length > 0 &&\n      !isEuCountry\n        ? ipAddress\n        : null,\n  };\n\n  const result = bodyValidation.safeParse(videoViewObject);\n  if (!result.success) {\n    return res\n      .status(400)\n      .json({ error: `Invalid body: ${result.error.message}` });\n  }\n\n  try {\n    await recordVideoView(result.data);\n    res.status(200).json({ message: \"Video view recorded\" });\n  } catch (error) {\n    log({\n      message: `Failed to record video view (tinybird) for ${linkId}. \\n\\n ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    res.status(500).json({ error: \"Failed to record video view\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/record_view.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { z } from \"zod\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport { publishPageView } from \"@/lib/tinybird\";\nimport { Geo } from \"@/lib/types\";\nimport { capitalize, getDomainWithoutWWW, log } from \"@/lib/utils\";\nimport { LOCALHOST_GEO_DATA, getGeoData } from \"@/lib/utils/geo\";\nimport { userAgentFromString } from \"@/lib/utils/user-agent\";\n\nconst bodyValidation = z.object({\n  id: z.string(),\n  linkId: z.string(),\n  documentId: z.string(),\n  viewId: z.string(),\n  dataroomId: z.string().nullable().optional(),\n  versionNumber: z.number().int().optional(),\n  time: z.number().int(),\n  duration: z.number().int(),\n  pageNumber: z.string(),\n  country: z.string().optional(),\n  city: z.string().optional(),\n  region: z.string().optional(),\n  latitude: z.string().optional(),\n  longitude: z.string().optional(),\n  ua: z.string().optional(),\n  browser: z.string().optional(),\n  browser_version: z.string().optional(),\n  engine: z.string().optional(),\n  engine_version: z.string().optional(),\n  os: z.string().optional(),\n  os_version: z.string().optional(),\n  device: z.string().optional(),\n  device_vendor: z.string().optional(),\n  device_model: z.string().optional(),\n  cpu_architecture: z.string().optional(),\n  bot: z.boolean().optional(),\n  referer: z.string().optional(),\n  referer_url: z.string().optional(),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  const geo: Geo =\n    process.env.VERCEL === \"1\" ? getGeoData(req.headers) : LOCALHOST_GEO_DATA;\n\n  const referer = req.headers.referer;\n  const ua = userAgentFromString(req.headers[\"user-agent\"]);\n\n  const {\n    linkId,\n    documentId,\n    viewId,\n    dataroomId,\n    duration,\n    pageNumber,\n    versionNumber,\n  } = req.body as {\n    linkId: string;\n    documentId: string;\n    viewId: string;\n    dataroomId: string | undefined;\n    duration: number;\n    pageNumber: number;\n    versionNumber: number;\n  };\n\n  const time = Date.now(); // in milliseconds\n\n  const pageViewId = newId(\"view\");\n\n  const pageViewObject = {\n    id: pageViewId,\n    linkId,\n    documentId,\n    viewId,\n    dataroomId: dataroomId || null,\n    versionNumber: versionNumber || 1,\n    time,\n    duration,\n    pageNumber: pageNumber.toString(),\n    country: geo?.country || \"Unknown\",\n    city: geo?.city || \"Unknown\",\n    region: geo?.region || \"Unknown\",\n    latitude: geo?.latitude || \"Unknown\",\n    longitude: geo?.longitude || \"Unknown\",\n    ua: ua.ua || \"Unknown\",\n    browser: ua.browser.name || \"Unknown\",\n    browser_version: ua.browser.version || \"Unknown\",\n    engine: ua.engine.name || \"Unknown\",\n    engine_version: ua.engine.version || \"Unknown\",\n    os: ua.os.name || \"Unknown\",\n    os_version: ua.os.version || \"Unknown\",\n    device: ua.device.type ? capitalize(ua.device.type) : \"Desktop\",\n    device_vendor: ua.device.vendor || \"Unknown\",\n    device_model: ua.device.model || \"Unknown\",\n    cpu_architecture: ua.cpu?.architecture || \"Unknown\",\n    bot: ua.isBot,\n    referer: referer ? getDomainWithoutWWW(referer) : \"(direct)\",\n    referer_url: referer || \"(direct)\",\n  };\n\n  const result = bodyValidation.safeParse(pageViewObject);\n  if (!result.success) {\n    return res\n      .status(400)\n      .json({ error: `Invalid body: ${result.error.message}` });\n  }\n\n  try {\n    await publishPageView(result.data);\n\n    res.status(200).json({ message: \"View recorded\" });\n  } catch (error) {\n    log({\n      message: `Failed to record view (tinybird) for ${linkId}. \\n\\n ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/report.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { waitUntil } from \"@vercel/functions\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\n\nconst bodyValidation = z.object({\n  linkId: z.string(),\n  documentId: z.string(),\n  viewId: z.string(),\n  abuseType: z.number().int().min(1).max(6),\n});\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // We only allow POST requests\n  if (req.method !== \"POST\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  const { linkId, documentId, viewId, abuseType } = req.body as {\n    linkId: string;\n    documentId: string;\n    viewId: string;\n    abuseType: number;\n  };\n  const result = bodyValidation.safeParse(req.body);\n  if (!result.success) {\n    return res.status(400).json({ message: \"Invalid request\" });\n  }\n\n  try {\n    const view = await prisma.view.findUnique({\n      where: {\n        id: viewId,\n        linkId,\n        documentId,\n      },\n      select: { id: true },\n    });\n\n    if (!view) {\n      return res.status(400).json({\n        status: \"error\",\n        message: \"View not found\",\n      });\n    }\n  } catch (err) {\n    console.error(err);\n\n    return res.status(500).json({\n      status: \"error\",\n      message: (err as Error).message,\n    });\n  }\n\n  try {\n    // Create a unique Redis key to track reports for the documentId\n    const reportKey = `report:doc_${documentId}`;\n    const viewIdValue = `view_${viewId}`;\n\n    // Check if the viewId has already reported for this documentId\n    const hasReported = await redis.sismember(reportKey, viewIdValue);\n    if (hasReported) {\n      return res.status(400).json({\n        status: \"error\",\n        message: \"You have already reported this document\",\n      });\n    }\n\n    // Perform all non-dependent Redis operations in parallel\n    waitUntil(\n      Promise.all([\n        // Add the viewId to the Redis set for this documentId\n        redis.sadd(reportKey, viewIdValue),\n\n        // Increment the report count for the documentId\n        redis.hincrby(\"reportCount\", `doc_${documentId}`, 1),\n\n        // Store the abuse type report under a Redis hash for future analysis\n        redis.hset(`report:doc_${documentId}:details`, {\n          [viewIdValue]: abuseType, // Store the abuseType as a number for this viewId\n        }),\n      ]),\n    );\n\n    return res.status(200).json({\n      status: \"success\",\n      message: \"Report submitted successfully\",\n    });\n  } catch (err) {\n    console.error(err);\n\n    return res.status(500).json({\n      status: \"error\",\n      message: (err as Error).message,\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/revalidate.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  // Check for secret to confirm this is a valid request\n  if (req.query.secret !== process.env.REVALIDATE_TOKEN) {\n    return res.status(401).json({ message: \"Invalid token\" });\n  }\n\n  const { linkId, documentId, teamId, hasDomain } = req.query as {\n    linkId: string;\n    documentId: string;\n    teamId: string;\n    hasDomain: string;\n  };\n\n  try {\n    if (linkId) {\n      if (hasDomain === \"true\") {\n        // revalidate a custom domain link\n        const link = await prisma.link.findUnique({\n          where: { id: linkId },\n          select: { domainSlug: true, slug: true },\n        });\n        if (!link) {\n          throw new Error(\"Link not found\");\n        }\n        console.log(\n          \"revalidating\",\n          `/view/domains/${link.domainSlug}/${link.slug}`,\n        );\n        await res.revalidate(`/view/domains/${link.domainSlug}/${link.slug}`);\n      } else {\n        console.log(\"revalidating\", `/view/${linkId}`);\n        // revalidate a regular papermark link\n        await res.revalidate(`/view/${linkId}`);\n      }\n    }\n\n    if (documentId) {\n      // revalidate all links for this document\n      const links = await prisma.link.findMany({\n        where: {\n          documentId: documentId,\n        },\n        select: { id: true, domainSlug: true, slug: true },\n      });\n      for (const link of links) {\n        if (link.domainSlug && link.slug) {\n          // revalidate a custom domain link\n          console.log(\n            \"revalidating\",\n            `/view/domains/${link.domainSlug}/${link.slug}`,\n          );\n          await res.revalidate(`/view/domains/${link.domainSlug}/${link.slug}`);\n        } else {\n          // revalidate a regular papermark link\n          console.log(\"revalidating document link\", `/view/${link.id}`);\n          await res.revalidate(`/view/${link.id}`);\n        }\n      }\n    }\n\n    if (teamId) {\n      // revalidate all links for this team (both regular and custom domain)\n      const allLinks = await prisma.link.findMany({\n        where: {\n          teamId: teamId,\n          isArchived: false,\n          deletedAt: null,\n        },\n        select: {\n          id: true,\n          domainSlug: true,\n          slug: true,\n        },\n      });\n\n      for (const link of allLinks) {\n        if (link.domainSlug && link.slug) {\n          console.log(\n            \"revalidating domain link\",\n            `/view/domains/${link.domainSlug}/${link.slug}`,\n          );\n          await res.revalidate(\n            `/view/domains/${link.domainSlug}/${link.slug}`,\n          );\n        } else {\n          console.log(\"revalidating link\", `/view/${link.id}`);\n          await res.revalidate(`/view/${link.id}`);\n        }\n      }\n    }\n\n    return res.json({ revalidated: true });\n  } catch (err) {\n    // If there was an error, Next.js will continue\n    // to show the last successfully generated page\n    console.error(\"Error during revalidation:\", err);\n    return res.status(500).send(\"Error revalidating\");\n  }\n}\n"
  },
  {
    "path": "pages/api/stripe/webhook-old.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { checkoutSessionCompleted } from \"@/ee/stripe/webhooks/checkout-session-completed\";\nimport { customerSubscriptionDeleted } from \"@/ee/stripe/webhooks/customer-subscription-deleted\";\nimport { customerSubsciptionUpdated } from \"@/ee/stripe/webhooks/customer-subscription-updated\";\nimport { invoiceUpcoming } from \"@/ee/stripe/webhooks/invoice-upcoming\";\nimport { Readable } from \"node:stream\";\nimport type Stripe from \"stripe\";\n\nimport { log } from \"@/lib/utils\";\n\n// Stripe requires the raw body to construct the event.\nexport const config = {\n  api: {\n    bodyParser: false,\n  },\n};\n\nasync function buffer(readable: Readable) {\n  const chunks: Buffer[] = [];\n  for await (const chunk of readable) {\n    chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n  }\n  return Buffer.concat(chunks);\n}\n\nconst relevantEvents = new Set([\n  \"checkout.session.completed\",\n  \"customer.subscription.updated\",\n  \"customer.subscription.deleted\",\n]);\n\nexport default async function webhookHandler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // POST /api/stripe/webhook-old – listen to Stripe webhooks\n  if (req.method === \"POST\") {\n    const buf = await buffer(req);\n    const sig = req.headers[\"stripe-signature\"];\n    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET_OLD;\n    let event: Stripe.Event;\n    try {\n      if (!sig || !webhookSecret) return;\n      const stripe = stripeInstance(true);\n      event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);\n    } catch (err: any) {\n      return res.status(400).send(`Webhook Error: ${err.message}`);\n    }\n\n    // Ignore unsupported events\n    if (!relevantEvents.has(event.type)) {\n      return res.status(400).send(`Unhandled event type: ${event.type}`);\n    }\n\n    try {\n      switch (event.type) {\n        case \"checkout.session.completed\":\n          await checkoutSessionCompleted(event, true);\n          break;\n        case \"customer.subscription.updated\":\n          await customerSubsciptionUpdated(event, res, true);\n          break;\n        case \"customer.subscription.deleted\":\n          await customerSubscriptionDeleted(event, res);\n          break;\n        case \"invoice.upcoming\":\n          await invoiceUpcoming(event, res, true);\n          break;\n      }\n    } catch (error) {\n      await log({\n        message: `Stripe webhook failed. Error: ${error}`,\n        type: \"error\",\n      });\n      return res\n        .status(400)\n        .send(\"Webhook error: Webhook handler failed. View logs.\");\n    }\n\n    return res.status(200).json({ received: true });\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/stripe/webhook.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { processPaymentFailure } from \"@/ee/features/security\";\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { checkoutSessionCompleted } from \"@/ee/stripe/webhooks/checkout-session-completed\";\nimport { customerSubscriptionDeleted } from \"@/ee/stripe/webhooks/customer-subscription-deleted\";\nimport { customerSubsciptionUpdated } from \"@/ee/stripe/webhooks/customer-subscription-updated\";\nimport { invoiceUpcoming } from \"@/ee/stripe/webhooks/invoice-upcoming\";\nimport { Readable } from \"node:stream\";\nimport type Stripe from \"stripe\";\n\nimport { log } from \"@/lib/utils\";\n\n// Stripe requires the raw body to construct the event.\n// add supportsResponseStreaming to enable waitUntil\nexport const config = {\n  supportsResponseStreaming: true,\n  api: {\n    bodyParser: false,\n  },\n};\n\nasync function buffer(readable: Readable) {\n  const chunks: Buffer[] = [];\n  for await (const chunk of readable) {\n    chunks.push(typeof chunk === \"string\" ? Buffer.from(chunk) : chunk);\n  }\n  return Buffer.concat(chunks);\n}\n\nconst relevantEvents = new Set([\n  \"checkout.session.completed\",\n  \"customer.subscription.updated\",\n  \"customer.subscription.deleted\",\n  \"payment_intent.payment_failed\",\n  \"invoice.upcoming\",\n]);\n\nexport default async function webhookHandler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // POST /api/stripe/webhook – listen to Stripe webhooks\n  if (req.method === \"POST\") {\n    const buf = await buffer(req);\n    const sig = req.headers[\"stripe-signature\"];\n    const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;\n    let event: Stripe.Event;\n    try {\n      if (!sig || !webhookSecret) return;\n      const stripe = stripeInstance();\n      event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);\n    } catch (err: any) {\n      return res.status(400).send(`Webhook Error: ${err.message}`);\n    }\n\n    // Ignore unsupported events\n    if (!relevantEvents.has(event.type)) {\n      return res.status(400).send(`Unhandled event type: ${event.type}`);\n    }\n\n    try {\n      switch (event.type) {\n        case \"checkout.session.completed\":\n          await checkoutSessionCompleted(event);\n          break;\n        case \"customer.subscription.updated\":\n          await customerSubsciptionUpdated(event, res);\n          break;\n        case \"customer.subscription.deleted\":\n          await customerSubscriptionDeleted(event, res);\n          break;\n        case \"payment_intent.payment_failed\":\n          await processPaymentFailure(event);\n          break;\n        case \"invoice.upcoming\":\n          await invoiceUpcoming(event, res);\n          break;\n      }\n    } catch (error) {\n      await log({\n        message: `Stripe webhook failed. Error: ${error}`,\n        type: \"error\",\n      });\n      return res\n        .status(400)\n        .send(\"Webhook error: Webhook handler failed. View logs.\");\n    }\n\n    return res.status(200).json({ received: true });\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/agreements/[agreementId]/download.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { getFile } from \"@/lib/files/get-file\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/agreements/:agreementId/download\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, agreementId } = req.query as {\n      teamId: string;\n      agreementId: string;\n    };\n\n    if (!teamId || !agreementId) {\n      return res.status(400).json(\"Missing required parameters\");\n    }\n\n    try {\n      // Check if user belongs to the team and get the agreement\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          name: true,\n          agreements: {\n            where: {\n              id: agreementId,\n              deletedAt: null, // Only allow downloading non-deleted agreements\n            },\n            select: {\n              id: true,\n              name: true,\n              content: true,\n              requireName: true,\n              createdAt: true,\n              updatedAt: true,\n              _count: {\n                select: {\n                  links: true,\n                  responses: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!team || team.agreements.length === 0) {\n        return res.status(404).json(\"Agreement not found or unauthorized\");\n      }\n\n      const agreement = team.agreements[0];\n\n      // Check if the content is a Papermark URL\n      const isPapermarkUrl =\n        agreement.content.includes(\"papermark.com/view/\") ||\n        agreement.content.includes(\"www.papermark.com/view/\");\n\n      let fileContent: string;\n      let filename: string;\n      let link: any = null;\n\n      if (isPapermarkUrl) {\n        // Extract linkId from Papermark URL\n        const urlParts = agreement.content.split(\"/view/\");\n        if (urlParts.length < 2) {\n          return res.status(400).json(\"Invalid Papermark URL format\");\n        }\n\n        const linkId = urlParts[1].split(/[/?#]/)[0]; // Get linkId, remove any query params or fragments\n\n        // Fetch the link and its document\n        link = await prisma.link.findUnique({\n          where: { id: linkId },\n          include: {\n            document: {\n              select: {\n                name: true,\n                file: true,\n                originalFile: true,\n                storageType: true,\n                type: true,\n                contentType: true,\n                versions: {\n                  where: { isPrimary: true },\n                  select: {\n                    file: true,\n                    originalFile: true,\n                    storageType: true,\n                    type: true,\n                    contentType: true,\n                  },\n                  take: 1,\n                },\n              },\n            },\n          },\n        });\n\n        if (!link || !link.document) {\n          return res\n            .status(404)\n            .json(\"Document not found for the provided Papermark URL\");\n        }\n\n        // Use the primary version if available, otherwise use the document file\n        const documentVersion = link.document.versions[0];\n        const fileKey = documentVersion\n          ? documentVersion.originalFile || documentVersion.file\n          : link.document.originalFile || link.document.file;\n        const storageType = documentVersion\n          ? documentVersion.storageType\n          : link.document.storageType;\n        const fileType = documentVersion\n          ? documentVersion.type\n          : link.document.type;\n\n        // Get the file URL from storage\n        const fileUrl = await getFile({\n          type: storageType,\n          data: fileKey,\n          isDownload: true,\n        });\n\n        // Fetch the actual file content with safety measures\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout\n\n        const fileResponse = await fetch(fileUrl, {\n          signal: controller.signal,\n          // Add size limit check in the response handling\n        });\n\n        clearTimeout(timeoutId);\n\n        if (!fileResponse.ok) {\n          throw new Error(\"Failed to fetch document content\");\n        }\n\n        // Use the document name for filename\n        const docName = link.document.name.replace(/\\.[^/.]+$/, \"\"); // Remove extension\n        let extension = \"txt\";\n        if (fileType === \"pdf\") extension = \"pdf\";\n        else if (fileType === \"docs\") extension = \"docx\";\n        else if (fileType === \"slides\") extension = \"pptx\";\n        else if (fileType === \"sheet\") extension = \"xlsx\";\n\n        filename = `${docName\n          .replace(/[^a-z0-9\\-_]/gi, \"_\")\n          .toLowerCase()\n          .substring(0, 50)}_agreement.${extension}`;\n\n        // Handle different file types appropriately\n        const isPdf =\n          fileType === \"pdf\" || link.document.contentType?.includes(\"pdf\");\n        const isDocx =\n          fileType === \"docs\" ||\n          link.document.contentType?.includes(\"wordprocessingml\");\n\n        if (isPdf || isDocx) {\n          // Handle binary files (PDFs, Word docs)\n          const buffer = await fileResponse.arrayBuffer();\n          let contentType =\n            link.document.contentType || \"application/octet-stream\";\n\n          if (isPdf) contentType = \"application/pdf\";\n          else if (isDocx)\n            contentType =\n              \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\";\n\n          res.setHeader(\"Content-Type\", contentType);\n          res.setHeader(\n            \"Content-Disposition\",\n            `attachment; filename=\"${filename}\"`,\n          );\n          res.setHeader(\"Content-Length\", buffer.byteLength.toString());\n          return res.send(Buffer.from(buffer));\n        } else {\n          // Handle text-based files\n          fileContent = await fileResponse.text();\n        }\n      } else {\n        // Regular URL - return formatted metadata as before\n        fileContent = `\nAGREEMENT DETAILS\n================\n\nName: ${agreement.name}\nURL: ${agreement.content}\nRequires Name: ${agreement.requireName ? \"Yes\" : \"No\"}\nCreated: ${agreement.createdAt.toLocaleDateString()} at ${agreement.createdAt.toLocaleTimeString()}\nLast Updated: ${agreement.updatedAt.toLocaleDateString()} at ${agreement.updatedAt.toLocaleTimeString()}\nTeam: ${team.name}\n\nUSAGE STATISTICS\n===============\n\nUsed in ${agreement._count.links} link${agreement._count.links === 1 ? \"\" : \"s\"}\nTotal responses: ${agreement._count.responses}\n\nAGREEMENT URL\n=============\n\n${agreement.content}\n\n---\nDownloaded on: ${new Date().toLocaleDateString()} at ${new Date().toLocaleTimeString()}\nAgreement ID: ${agreement.id}\n        `.trim();\n\n        filename = `${agreement.name\n          .replace(/[^a-z0-9\\-_]/gi, \"_\")\n          .toLowerCase()\n          .substring(0, 50)}_agreement.txt`;\n      }\n\n      // Set headers for file download (only for text files - PDFs are handled above)\n      if (fileContent) {\n        res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n        res.setHeader(\n          \"Content-Disposition\",\n          `attachment; filename=\"${filename}\"`,\n        );\n        res.setHeader(\"Content-Length\", Buffer.byteLength(fileContent, \"utf8\"));\n        return res.send(fileContent);\n      }\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/agreements/[agreementId]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/agreements/:agreementId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { agreementId } = req.query as { agreementId: string };\n\n    if (!teamId || !agreementId) {\n      return res.status(401).json(\"Unauthorized\");\n    }\n\n    try {\n      await prisma.agreement.update({\n        where: {\n          id: agreementId,\n          teamId,\n        },\n        data: {\n          deletedAt: new Date(),\n          deletedBy: userId,\n        },\n      });\n\n      return res.status(200).json({ message: \"Agreement deleted\" });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"PUT\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/agreements/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\nimport { validateContent } from \"@/lib/utils/sanitize-html\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\n// Zod schema for agreement creation\nconst createAgreementSchema = z.object({\n  name: z\n    .string()\n    .min(1, \"Name is required\")\n    .max(150, \"Name must be less than 150 characters\"),\n  content: z\n    .string()\n    .min(1, \"Content is required\")\n    .max(1500, \"Content must be less than 1500 characters\"),\n  contentType: z.enum([\"LINK\", \"TEXT\"]).default(\"LINK\"),\n  requireName: z.boolean().default(false),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/agreements\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: {\n          agreements: {\n            include: {\n              _count: {\n                select: {\n                  links: {\n                    where: {\n                      deletedAt: null,\n                    },\n                  },\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).json(\"Unauthorized\");\n      }\n\n      const agreements = team.agreements;\n      return res.status(200).json(agreements);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/agreements\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n\n    if (!teamId) {\n      return res.status(401).json(\"Unauthorized\");\n    }\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).json(\"Unauthorized\");\n      }\n\n      // Validate and parse request body\n      const parseResult = createAgreementSchema.safeParse(req.body);\n      if (!parseResult.success) {\n        return res.status(400).json({\n          error: \"Invalid request body\",\n          details: parseResult.error.flatten().fieldErrors,\n        });\n      }\n\n      const { name, content, contentType, requireName } = parseResult.data;\n\n      // Sanitize content using existing sanitization logic\n      const sanitizedContent = validateContent(content, 1500);\n\n      const agreement = await prisma.agreement.create({\n        data: {\n          teamId,\n          name: name.trim(),\n          content: sanitizedContent,\n          contentType: contentType || \"LINK\",\n          requireName,\n        },\n      });\n\n      return res.status(201).json(agreement);\n    } catch (error) {\n      log({\n        message: `Failed to add agreement. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n        mention: true,\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/ai-settings.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nconst updateAISettingsSchema = z.object({\n  agentsEnabled: z.boolean(),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId } = req.query as { teamId: string };\n\n  // Verify user has access to the team\n  const teamAccess = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId: userId,\n        teamId: teamId,\n      },\n    },\n    select: { teamId: true, role: true },\n  });\n\n  if (!teamAccess) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  // Check if AI feature is enabled for this team\n  const features = await getFeatureFlags({ teamId });\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/ai-settings\n    try {\n      const team = await prisma.team.findUnique({\n        where: { id: teamId },\n        select: {\n          agentsEnabled: true,\n          vectorStoreId: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(404).json({ error: \"Team not found\" });\n      }\n\n      const isAdmin = teamAccess.role === \"ADMIN\";\n\n      return res.status(200).json({\n        agentsEnabled: team.agentsEnabled,\n        vectorStoreId: team.vectorStoreId,\n        isAdmin,\n        isAIFeatureEnabled: features.ai,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/ai-settings\n    // AI feature must be enabled for this team\n    if (!features.ai) {\n      return res\n        .status(403)\n        .json({ error: \"AI feature is not available for this team\" });\n    }\n\n    // Only admins can update AI settings\n    if (teamAccess.role !== \"ADMIN\") {\n      return res.status(403).json({\n        error: \"Only team admins can manage AI settings\",\n      });\n    }\n\n    try {\n      const validation = updateAISettingsSchema.safeParse(req.body);\n      if (!validation.success) {\n        return res.status(400).json({\n          error: \"Invalid request body\",\n          details: validation.error,\n        });\n      }\n\n      const { agentsEnabled } = validation.data;\n\n      const updatedTeam = await prisma.team.update({\n        where: { id: teamId },\n        data: { agentsEnabled },\n        select: {\n          agentsEnabled: true,\n          vectorStoreId: true,\n        },\n      });\n\n      return res.status(200).json({\n        agentsEnabled: updatedTeam.agentsEnabled,\n        vectorStoreId: updatedTeam.vectorStoreId,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\", \"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/cancel.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/billing/cancellation/api/cancel-route\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/cancellation-feedback.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/billing/cancellation/api/cancellation-feedback-route\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // POST /api/teams/:teamId/billing – get user's subscription info\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n        },\n        select: {\n          id: true,\n          subscriptionId: true,\n          startsAt: true,\n          endsAt: true,\n          plan: true,\n          _count: {\n            select: {\n              documents: true,\n            },\n          },\n        },\n      });\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exists\" });\n      }\n      return res.status(200).json(team);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/invoices.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nfunction isOldAccount(plan: string) {\n  return plan.includes(\"+old\");\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId } = req.query as { teamId: string };\n\n  try {\n    // Get team with stripeId\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n      select: {\n        stripeId: true,\n        plan: true,\n      },\n    });\n\n    if (!team) {\n      return res.status(404).json({ error: \"Team not found\" });\n    }\n\n    if (!team.stripeId) {\n      return res.status(200).json({ invoices: [] });\n    }\n\n    // Fetch invoices from Stripe\n    const stripe = stripeInstance(isOldAccount(team.plan));\n    const invoices = await stripe.invoices.list({\n      customer: team.stripeId,\n      limit: 100,\n    });\n\n    // Transform invoices to a simpler format\n    const transformedInvoices = invoices.data.map((invoice) => ({\n      id: invoice.id,\n      number: invoice.number,\n      status: invoice.status,\n      amount: invoice.amount_paid,\n      currency: invoice.currency,\n      created: invoice.created,\n      invoicePdf: invoice.invoice_pdf,\n      hostedInvoiceUrl: invoice.hosted_invoice_url,\n      periodStart: invoice.period_start,\n      periodEnd: invoice.period_end,\n      description: invoice.lines.data[0]?.description || \"Subscription\",\n    }));\n\n    return res.status(200).json({ invoices: transformedInvoices });\n  } catch (error) {\n    console.error(\"Error fetching invoices:\", error);\n    return res.status(500).json({ error: \"Failed to fetch invoices\" });\n  }\n}\n\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/manage.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { getCouponFromPlan } from \"@/ee/stripe/functions/get-coupon-from-plan\";\nimport { getQuantityFromPriceId } from \"@/ee/stripe/functions/get-quantity-from-plan\";\nimport getSubscriptionItem from \"@/ee/stripe/functions/get-subscription-item\";\nimport { getPlanFromPriceId, isOldAccount } from \"@/ee/stripe/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { identifyUser, trackAnalytics } from \"@/lib/analytics\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/billing/manage – manage a user's subscription\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const userEmail = (session.user as CustomUser).email;\n\n    const { teamId } = req.query as { teamId: string };\n    const {\n      priceId,\n      upgradePlan,\n      quantity,\n      addSeat,\n      proAnnualBanner,\n      return_url,\n      applyYearlyDiscount,\n      type = \"manage\",\n    } = req.body as {\n      priceId: string;\n      upgradePlan: boolean;\n      quantity?: number;\n      addSeat?: boolean;\n      proAnnualBanner?: boolean;\n      return_url?: string;\n      applyYearlyDiscount?: boolean;\n      type?:\n        | \"manage\"\n        | \"invoices\"\n        | \"subscription_update\"\n        | \"payment_method_update\"\n        | \"cancellation\";\n    };\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          stripeId: true,\n          subscriptionId: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(400).json({ error: \"Team does not exists\" });\n      }\n      if (!team.stripeId) {\n        return res.status(400).json({ error: \"No Stripe customer ID\" });\n      }\n\n      if (!team.subscriptionId) {\n        return res.status(400).json({ error: \"No subscription ID\" });\n      }\n\n      const {\n        id: subscriptionItemId,\n        currentPeriodStart,\n        currentPeriodEnd,\n      } = await getSubscriptionItem(\n        team.subscriptionId,\n        isOldAccount(team.plan),\n      );\n\n      const minQuantity = getQuantityFromPriceId(priceId);\n\n      const stripe = stripeInstance(isOldAccount(team.plan));\n      \n      // Apply 30% discount for yearly plans before redirecting to billing portal\n      // Same logic as retention flow: apply coupon directly to subscription\n      if (applyYearlyDiscount && upgradePlan) {\n        const plan = getPlanFromPriceId(priceId, isOldAccount(team.plan));\n        if (plan) {\n          // Use the same logic as retention flow: getCouponFromPlan(team.plan, isAnnualPlan)\n          // team.plan format is \"pro\", \"business\", \"pro+old\", \"business+old\", etc.\n          const planString = `${plan.slug}${isOldAccount(team.plan) ? \"+old\" : \"\"}`;\n          const couponId = getCouponFromPlan(planString, true);\n          \n          // Verify coupon exists before applying (coupons might only exist in production, not test mode)\n          try {\n            await stripe.coupons.retrieve(couponId);\n            // Apply discount directly to subscription (same as retention flow)\n            await stripe.subscriptions.update(team.subscriptionId, {\n              discounts: [{ coupon: couponId }],\n            });\n          } catch (error: any) {\n            // If coupon doesn't exist (common in test mode), log and skip\n            if (error.code === \"resource_missing\") {\n              console.warn(\n                `[Manage] Coupon \"${couponId}\" not found in ${process.env.NEXT_PUBLIC_VERCEL_ENV || \"test\"} mode. ` +\n                `Skipping discount application. This is expected if the coupon only exists in production.`\n              );\n            } else {\n              // Re-throw other errors\n              throw error;\n            }\n          }\n        }\n      }\n\n      const { url } = await stripe.billingPortal.sessions.create({\n        customer: team.stripeId,\n        return_url: `${process.env.NEXTAUTH_URL}/settings/billing?cancel=true`,\n        ...(type === \"manage\" &&\n          (upgradePlan || addSeat) &&\n          subscriptionItemId && {\n            flow_data: {\n              type: \"subscription_update_confirm\",\n              subscription_update_confirm: {\n                subscription: team.subscriptionId,\n                items: [\n                  {\n                    id: subscriptionItemId,\n                    quantity: isOldAccount(team.plan)\n                      ? 1\n                      : (quantity ?? minQuantity),\n                    price: priceId,\n                  },\n                ],\n              },\n              after_completion: {\n                type: \"redirect\",\n                redirect: {\n                  return_url:\n                    return_url ??\n                    `${process.env.NEXTAUTH_URL}/settings/billing?success=true`,\n                },\n              },\n            },\n          }),\n        ...(type === \"subscription_update\" && {\n          flow_data: {\n            type: \"subscription_update\",\n            subscription_update: {\n              subscription: team.subscriptionId,\n            },\n          },\n        }),\n        ...(type === \"cancellation\" && {\n          flow_data: {\n            type: \"subscription_cancel\",\n            subscription_cancel: {\n              subscription: team.subscriptionId,\n            },\n            after_completion: {\n              type: \"redirect\",\n              redirect: {\n                return_url:\n                  return_url ??\n                  `${process.env.NEXTAUTH_URL}/settings/billing?cancellation=true`,\n              },\n            },\n          },\n        }),\n      });\n\n      waitUntil(identifyUser(userEmail ?? userId));\n      waitUntil(\n        trackAnalytics({\n          event: \"Stripe Billing Portal Clicked\",\n          teamId,\n          action: proAnnualBanner ? \"pro-annual-banner\" : undefined,\n        }),\n      );\n\n      return res.status(200).json(url);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/pause.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/billing/cancellation/api/pause-route\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/plan.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPaused } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport getSubscriptionItem, {\n  SubscriptionDiscount,\n} from \"@/ee/stripe/functions/get-subscription-item\";\nimport { isOldAccount } from \"@/ee/stripe/utils\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/billing/plan\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n    const withDiscount = req.query.withDiscount === \"true\";\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          plan: true,\n          stripeId: true,\n          subscriptionId: true,\n          startsAt: true,\n          endsAt: true,\n          pausedAt: true,\n          pauseStartsAt: true,\n          pauseEndsAt: true,\n          cancelledAt: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(404).json({ error: \"Team not found\" });\n      }\n\n      const isCustomer = !!team.stripeId;\n\n      // calculate the plan cycle either yearly or monthly based on the startsAt and endsAt dates\n      let subscriptionCycle = \"monthly\";\n      if (team?.startsAt && team?.endsAt) {\n        const durationInDays = Math.round(\n          (team.endsAt.getTime() - team.startsAt.getTime()) /\n            (1000 * 60 * 60 * 24),\n        );\n        // If duration is more than 31 days, consider it yearly\n        subscriptionCycle = durationInDays > 31 ? \"yearly\" : \"monthly\";\n      }\n\n      // Fetch discount information if team has an active subscription\n      let discount: SubscriptionDiscount | null = null;\n      if (\n        withDiscount &&\n        team?.subscriptionId &&\n        team.plan &&\n        team.plan !== \"free\" &&\n        team.pauseStartsAt === null\n      ) {\n        try {\n          const subscriptionData = await getSubscriptionItem(\n            team.subscriptionId,\n            isOldAccount(team.plan),\n          );\n          discount = subscriptionData.discount;\n        } catch (error) {\n          // If we can't fetch discount info, just log and continue without it\n          console.error(\"Failed to fetch discount information:\", error);\n        }\n      }\n\n      // Calculate if team is currently paused\n      const isPaused = isTeamPaused(team);\n\n      return res.status(200).json({\n        plan: team.plan,\n        startsAt: team.startsAt,\n        endsAt: team.endsAt,\n        isCustomer,\n        subscriptionCycle,\n        pausedAt: team.pausedAt,\n        pauseStartsAt: team.pauseStartsAt,\n        pauseEndsAt: team.pauseEndsAt,\n        isPaused,\n        cancelledAt: team.cancelledAt,\n        discount,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/reactivate.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/billing/cancellation/api/reactivate-route\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/retention-offer.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/billing/cancellation/api/retention-route\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/unpause.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/billing/cancellation/api/unpause-route\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/billing/upgrade.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { checkRateLimit, rateLimiters } from \"@/ee/features/security\";\nimport { stripeInstance } from \"@/ee/stripe\";\nimport { getCouponFromPlan } from \"@/ee/stripe/functions/get-coupon-from-plan\";\nimport { getPlanFromPriceId, isOldAccount } from \"@/ee/stripe/utils\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { identifyUser, trackAnalytics } from \"@/lib/analytics\";\nimport { getDubDiscountForExternalUserId } from \"@/lib/dub\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { getIpAddress } from \"@/lib/utils/ip\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // Apply rate limiting\n    const clientIP = getIpAddress(req.headers);\n    const rateLimitResult = await checkRateLimit(\n      rateLimiters.billing,\n      clientIP,\n    );\n\n    if (!rateLimitResult.success) {\n      return res.status(429).json({\n        error: \"Too many billing requests. Please try again later.\",\n        remaining: rateLimitResult.remaining,\n      });\n    }\n\n    // POST /api/teams/:teamId/billing/upgrade\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const { teamId, priceId, applyYearlyDiscount } = req.query as {\n      teamId: string;\n      priceId: string;\n      applyYearlyDiscount?: string;\n    };\n\n    const { id: userId, email: userEmail } = session.user as CustomUser;\n\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId,\n          },\n        },\n      },\n      select: { stripeId: true, plan: true },\n    });\n\n    if (!team) {\n      res.status(404).end(\"Unauthorized\");\n      return;\n    }\n\n    const oldAccount = isOldAccount(team.plan);\n    const plan = getPlanFromPriceId(priceId, oldAccount);\n\n    if (!plan) {\n      res.status(400).json({ error: \"Invalid price ID\" });\n      return;\n    }\n\n    const minimumQuantity = plan.minQuantity;\n\n    let stripeSession;\n    let couponId: string | undefined;\n\n    // Apply 30% coupon for yearly plans if requested (same as retention flow)\n    // Since the upgrade modal is yearly-only, if applyYearlyDiscount is true, always apply\n    if (applyYearlyDiscount === \"true\") {\n      // Use the same logic as retention flow: getCouponFromPlan(team.plan, isAnnualPlan)\n      // team.plan format is \"pro\", \"business\", \"pro+old\", \"business+old\", etc.\n      const planString = oldAccount ? `${plan.slug}+old` : plan.slug;\n      couponId = getCouponFromPlan(planString, true);\n      \n      // Verify coupon exists in Stripe (coupons might only exist in production, not test mode)\n      const stripe = stripeInstance(oldAccount);\n      try {\n        await stripe.coupons.retrieve(couponId);\n      } catch (error: any) {\n        // If coupon doesn't exist (common in test mode), continue without discount\n        if (error.code === \"resource_missing\") {\n          console.warn(\n            `[Upgrade] Coupon \"${couponId}\" not found in ${process.env.NEXT_PUBLIC_VERCEL_ENV || \"test\"} mode. ` +\n            `Continuing without discount. This is expected if the coupon only exists in production.`\n          );\n          couponId = undefined;\n        } else {\n          // Re-throw other errors\n          throw error;\n        }\n      }\n    }\n\n    const lineItem = {\n      price: priceId,\n      quantity: oldAccount ? 1 : minimumQuantity,\n      ...(!oldAccount && {\n        adjustable_quantity: {\n          enabled: true,\n          minimum: minimumQuantity,\n          maximum: 99,\n        },\n      }),\n    };\n\n    const dubDiscount = await getDubDiscountForExternalUserId(userId);\n\n    const stripe = stripeInstance(oldAccount);\n    const baseSessionConfig = {\n      billing_address_collection: \"required\" as const,\n      success_url: `${process.env.NEXTAUTH_URL}/settings/billing?success=true`,\n      cancel_url: `${process.env.NEXTAUTH_URL}/settings/billing?cancel=true`,\n      line_items: [lineItem],\n      automatic_tax: {\n        enabled: true,\n      },\n      tax_id_collection: {\n        enabled: true,\n      },\n      mode: \"subscription\" as const,\n      client_reference_id: teamId,\n      ...(couponId && {\n        discounts: [{ coupon: couponId }],\n      }),\n    };\n\n    if (team.stripeId) {\n      // if the team already has a stripeId (i.e. is a customer) let's use as a customer\n      stripeSession = await stripe.checkout.sessions.create({\n        ...baseSessionConfig,\n        customer: team.stripeId,\n        customer_update: { name: \"auto\" },\n        ...(!couponId && { allow_promotion_codes: true }),\n      });\n    } else {\n      // else initialize a new customer\n      stripeSession = await stripe.checkout.sessions.create({\n        ...baseSessionConfig,\n        customer_email: userEmail ?? undefined,\n        ...(dubDiscount ?? (!couponId && { allow_promotion_codes: true })),\n        metadata: {\n          dubCustomerId: userId,\n        },\n      });\n    }\n\n    waitUntil(\n      Promise.all([\n        identifyUser(userEmail ?? userId),\n        trackAnalytics({\n          event: \"Stripe Checkout Clicked\",\n          teamId,\n          priceId: priceId,\n        }),\n      ]),\n    );\n\n    return res.status(200).json(stripeSession);\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/branding.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { del } from \"@vercel/blob\";\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId } = req.query as { teamId: string };\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n      },\n      select: {\n        id: true,\n        users: { select: { userId: true } },\n      },\n    });\n\n    // check that the user is member of the team, otherwise return 403\n    const teamUsers = team?.users;\n    const isUserPartOfTeam = teamUsers?.some(\n      (user) => user.userId === (session.user as CustomUser).id,\n    );\n    if (!isUserPartOfTeam) {\n      return res.status(403).end(\"Unauthorized to access this team\");\n    }\n  } catch (error) {\n    errorhandler(error, res);\n  }\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/branding\n    const brand = await prisma.brand.findUnique({\n      where: {\n        teamId: teamId,\n      },\n    });\n\n    if (!brand) {\n      return res.status(200).json(null);\n    }\n\n    return res.status(200).json(brand);\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/branding\n    const {\n      logo,\n      banner,\n      brandColor,\n      accentColor,\n      applyAccentColorToDataroomView,\n      welcomeMessage,\n    } = req.body as {\n      logo?: string;\n      banner?: string;\n      brandColor?: string;\n      accentColor?: string;\n      applyAccentColorToDataroomView?: boolean;\n      welcomeMessage?: string;\n    };\n\n    // update team with new branding\n    const brand = await prisma.brand.create({\n      data: {\n        logo: logo,\n        banner,\n        brandColor,\n        accentColor,\n        applyAccentColorToDataroomView: !!applyAccentColorToDataroomView,\n        welcomeMessage,\n        teamId: teamId,\n      },\n    });\n\n    // Cache the logo URL in Redis if logo exists\n    if (logo) {\n      await redis.set(`brand:logo:${teamId}`, logo);\n    }\n\n    return res.status(200).json(brand);\n  } else if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/branding\n    const {\n      logo,\n      banner,\n      brandColor,\n      accentColor,\n      applyAccentColorToDataroomView,\n      welcomeMessage,\n    } = req.body as {\n      logo?: string;\n      banner?: string;\n      brandColor?: string;\n      accentColor?: string;\n      applyAccentColorToDataroomView?: boolean;\n      welcomeMessage?: string;\n    };\n\n    // Use upsert to handle both create and update cases\n    const brand = await prisma.brand.upsert({\n      where: {\n        teamId: teamId,\n      },\n      create: {\n        logo,\n        banner,\n        brandColor,\n        accentColor,\n        applyAccentColorToDataroomView: !!applyAccentColorToDataroomView,\n        welcomeMessage,\n        teamId: teamId,\n      },\n      update: {\n        logo,\n        banner,\n        brandColor,\n        accentColor,\n        applyAccentColorToDataroomView: !!applyAccentColorToDataroomView,\n        welcomeMessage,\n      },\n    });\n\n    // Update logo in Redis cache\n    if (logo) {\n      await redis.set(`brand:logo:${teamId}`, logo);\n    } else {\n      // If logo is null or undefined, delete the cache\n      await redis.del(`brand:logo:${teamId}`);\n    }\n\n    return res.status(200).json(brand);\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/branding\n    const brand = await prisma.brand.findFirst({\n      where: {\n        teamId: teamId,\n      },\n      select: { id: true, logo: true, banner: true },\n    });\n\n    if (brand) {\n      // delete the logo from vercel blob\n      if (brand.logo) {\n        await del(brand.logo);\n      }\n      // delete the banner from vercel blob\n      if (brand.banner) {\n        await del(brand.banner);\n      }\n    }\n\n    // delete the branding from database\n    await prisma.brand.delete({\n      where: {\n        id: brand?.id,\n      },\n    });\n\n    // Remove logo from Redis cache\n    await redis.del(`brand:logo:${teamId}`);\n\n    return res.status(204).end();\n  } else {\n    // We only allow GET and DELETE requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\", \"PUT\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/change-role.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PUT\") {\n    // DELETE /api/teams/:teamId/change-role\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    const { userToBeChanged, role } = req.body as {\n      userToBeChanged: string;\n      role: \"MEMBER\" | \"MANAGER\" | \"ADMIN\";\n    };\n\n    try {\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return res.status(401).json(\"Unauthorized\");\n      }\n\n      // Only ADMINs can change roles\n      if (role === \"ADMIN\" && userTeam.role !== \"ADMIN\") {\n        return res.status(403).json(\"Only admins can change user roles\");\n      }\n\n      if (userTeam?.role === \"ADMIN\" && userTeam.userId === userToBeChanged) {\n        return res.status(401).json(\"You can't change the Admin\");\n      }\n\n      await prisma.userTeam.update({\n        where: {\n          userId_teamId: {\n            userId: userToBeChanged,\n            teamId,\n          },\n        },\n        data: {\n          role,\n        },\n      });\n      return res.status(204).end();\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"PUT\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/apply-permissions.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { ItemType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const { teamId, id: dataroomId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    const { documentIds, strategy, folderPath } = req.body as {\n      documentIds: string[];\n      strategy: string;\n      folderPath?: string;\n    };\n\n    // Validate input\n    if (\n      !documentIds ||\n      !Array.isArray(documentIds) ||\n      documentIds.length === 0\n    ) {\n      return res.status(400).json({ message: \"Document IDs are required\" });\n    }\n\n    if (!strategy) {\n      return res.status(400).json({ message: \"Strategy is required\" });\n    }\n\n    // Validate strategy\n    if (\n      ![\"INHERIT_FROM_PARENT\", \"ASK_EVERY_TIME\", \"HIDDEN_BY_DEFAULT\"].includes(\n        strategy,\n      )\n    ) {\n      return res.status(400).json({ message: \"Invalid strategy\" });\n    }\n\n    // Check if the user is part of the team\n    const team = await prisma.team.findFirst({\n      where: {\n        id: teamId,\n        users: { some: { userId } },\n      },\n    });\n\n    if (!team) {\n      return res.status(403).json({ message: \"Unauthorized\" });\n    }\n\n    // Get dataroom and verify it exists and belongs to the team\n    const dataroom = await prisma.dataroom.findUnique({\n      where: { id: dataroomId },\n      select: {\n        id: true,\n        teamId: true,\n        defaultPermissionStrategy: true,\n      },\n    });\n\n    if (!dataroom || dataroom.teamId !== teamId) {\n      return res.status(404).json({ message: \"Dataroom not found\" });\n    }\n\n    // Get dataroom documents for the provided document IDs\n    const dataroomDocuments = await prisma.dataroomDocument.findMany({\n      where: {\n        documentId: { in: documentIds },\n        dataroomId,\n      },\n      select: { id: true, documentId: true, folderId: true },\n    });\n\n    if (dataroomDocuments.length === 0) {\n      return res\n        .status(404)\n        .json({ message: \"No documents found in this dataroom\" });\n    }\n\n    // Apply permissions based on strategy\n    await applyPermissionStrategy(\n      dataroomId,\n      dataroomDocuments,\n      strategy,\n      folderPath,\n    );\n\n    return res.status(200).json({\n      message: \"Permissions applied successfully\",\n      documentsProcessed: dataroomDocuments.length,\n    });\n  } catch (error) {\n    errorhandler(error, res);\n  }\n}\n\nasync function applyPermissionStrategy(\n  dataroomId: string,\n  dataroomDocuments: {\n    id: string;\n    documentId: string;\n    folderId: string | null;\n  }[],\n  strategy: string,\n  folderPath?: string,\n) {\n  if (strategy === \"INHERIT_FROM_PARENT\") {\n    const isRootLevel = !folderPath || folderPath.length === 0;\n\n    if (isRootLevel) {\n      // For root level, apply view-only permissions to all groups\n      await applyRootLevelPermissions(dataroomId, dataroomDocuments);\n    } else {\n      // For subfolders, inherit from parent folder\n      await inheritFromParentFolder(dataroomId, dataroomDocuments, folderPath);\n    }\n  } else if (strategy === \"ASK_EVERY_TIME\") {\n    // Do nothing here - the UI will handle showing the permission modal\n    return;\n  } else if (strategy === \"HIDDEN_BY_DEFAULT\") {\n    // Do nothing here - documents remain hidden with no permissions\n    return;\n  }\n}\n\nasync function applyRootLevelPermissions(\n  dataroomId: string,\n  dataroomDocuments: {\n    id: string;\n    documentId: string;\n    folderId: string | null;\n  }[],\n) {\n  // Get both ViewerGroups and PermissionGroups\n  const [viewerGroups, permissionGroups] = await Promise.all([\n    prisma.viewerGroup.findMany({\n      where: { dataroomId },\n      select: { id: true },\n    }),\n    prisma.permissionGroup.findMany({\n      where: { dataroomId },\n      select: { id: true },\n    }),\n  ]);\n\n  const viewerGroupPermissionsToCreate: any[] = [];\n  const permissionGroupPermissionsToCreate: any[] = [];\n\n  // ViewerGroup permissions - all get view-only access\n  viewerGroups.forEach((group) => {\n    dataroomDocuments.forEach((doc) => {\n      viewerGroupPermissionsToCreate.push({\n        groupId: group.id,\n        itemId: doc.id,\n        itemType: ItemType.DATAROOM_DOCUMENT,\n        canView: true,\n        canDownload: false,\n      });\n    });\n  });\n\n  // PermissionGroup permissions - all get view-only access\n  permissionGroups.forEach((group) => {\n    dataroomDocuments.forEach((doc) => {\n      permissionGroupPermissionsToCreate.push({\n        groupId: group.id,\n        itemId: doc.id,\n        itemType: ItemType.DATAROOM_DOCUMENT,\n        canView: true,\n        canDownload: false,\n        canDownloadOriginal: false,\n      });\n    });\n  });\n\n  // Apply permissions in a transaction\n  await prisma.$transaction(async (tx) => {\n    // Create new permissions\n    if (viewerGroupPermissionsToCreate.length > 0) {\n      await tx.viewerGroupAccessControls.createMany({\n        data: viewerGroupPermissionsToCreate,\n        skipDuplicates: true,\n      });\n    }\n\n    if (permissionGroupPermissionsToCreate.length > 0) {\n      await tx.permissionGroupAccessControls.createMany({\n        data: permissionGroupPermissionsToCreate,\n        skipDuplicates: true,\n      });\n    }\n  });\n}\n\nasync function inheritFromParentFolder(\n  dataroomId: string,\n  dataroomDocuments: {\n    id: string;\n    documentId: string;\n    folderId: string | null;\n  }[],\n  folderPath: string,\n) {\n  // Get parent folder permissions and apply them to new documents\n  const pathSegments = folderPath.split(\"/\").filter(Boolean);\n  const parentPath = \"/\" + pathSegments.slice(0, -1).join(\"/\");\n\n  const parentFolder = await prisma.dataroomFolder.findUnique({\n    where: {\n      dataroomId_path: { dataroomId, path: parentPath },\n    },\n    select: { id: true },\n  });\n\n  if (!parentFolder) {\n    // If no parent folder found, apply root level permissions\n    await applyRootLevelPermissions(dataroomId, dataroomDocuments);\n    return;\n  }\n\n  // Get existing permissions for the parent folder\n  const [parentViewerPermissions, parentPermissionGroupPermissions] =\n    await Promise.all([\n      prisma.viewerGroupAccessControls.findMany({\n        where: {\n          itemId: parentFolder.id,\n          itemType: ItemType.DATAROOM_FOLDER,\n        },\n        select: { groupId: true, canView: true, canDownload: true },\n      }),\n      prisma.permissionGroupAccessControls.findMany({\n        where: {\n          itemId: parentFolder.id,\n          itemType: ItemType.DATAROOM_FOLDER,\n        },\n        select: {\n          groupId: true,\n          canView: true,\n          canDownload: true,\n          canDownloadOriginal: true,\n        },\n      }),\n    ]);\n\n  // Apply parent permissions to documents\n  await prisma.$transaction(async (tx) => {\n    // Create permissions based on parent folder permissions\n    const viewerGroupPermissionsToCreate: any[] = [];\n    const permissionGroupPermissionsToCreate: any[] = [];\n\n    parentViewerPermissions.forEach((parentPerm) => {\n      dataroomDocuments.forEach((doc) => {\n        viewerGroupPermissionsToCreate.push({\n          groupId: parentPerm.groupId,\n          itemId: doc.id,\n          itemType: ItemType.DATAROOM_DOCUMENT,\n          canView: parentPerm.canView,\n          canDownload: parentPerm.canDownload,\n        });\n      });\n    });\n\n    parentPermissionGroupPermissions.forEach((parentPerm) => {\n      dataroomDocuments.forEach((doc) => {\n        permissionGroupPermissionsToCreate.push({\n          groupId: parentPerm.groupId,\n          itemId: doc.id,\n          itemType: ItemType.DATAROOM_DOCUMENT,\n          canView: parentPerm.canView,\n          canDownload: parentPerm.canDownload,\n          canDownloadOriginal: parentPerm.canDownloadOriginal,\n        });\n      });\n    });\n\n    if (viewerGroupPermissionsToCreate.length > 0) {\n      await tx.viewerGroupAccessControls.createMany({\n        data: viewerGroupPermissionsToCreate,\n        skipDuplicates: true,\n      });\n    }\n\n    if (permissionGroupPermissionsToCreate.length > 0) {\n      await tx.permissionGroupAccessControls.createMany({\n        data: permissionGroupPermissionsToCreate,\n        skipDuplicates: true,\n      });\n    }\n  });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/apply-template.ts",
    "content": "// Re-export from ee folder\nexport { default } from \"@/ee/features/templates/api/datarooms/apply-template\";\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/branding.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { del } from \"@vercel/blob\";\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id: dataroomId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: (session.user as CustomUser).id,\n          },\n        },\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!team) {\n      return res.status(403).end(\"Unauthorized to access this team\");\n    }\n\n    const dataroom = await prisma.dataroom.findUnique({\n      where: {\n        id: dataroomId,\n        teamId: teamId,\n      },\n    });\n\n    if (!dataroom) {\n      return res.status(404).end(\"Dataroom not found\");\n    }\n  } catch (error) {\n    errorhandler(error, res);\n  }\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/branding\n    const brand = await prisma.dataroomBrand.findUnique({\n      where: {\n        dataroomId,\n      },\n    });\n\n    if (!brand) {\n      return res.status(200).json(null);\n    }\n\n    return res.status(200).json(brand);\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/branding\n    const {\n      logo,\n      banner,\n      brandColor,\n      accentColor,\n      applyAccentColorToDataroomView,\n      welcomeMessage,\n    } = req.body as {\n      logo?: string;\n      banner?: string;\n      brandColor?: string;\n      accentColor?: string;\n      applyAccentColorToDataroomView?: boolean;\n      welcomeMessage?: string;\n    };\n\n    // update team with new branding\n    const brand = await prisma.dataroomBrand.create({\n      data: {\n        logo,\n        banner,\n        brandColor,\n        accentColor,\n        applyAccentColorToDataroomView: !!applyAccentColorToDataroomView,\n        welcomeMessage,\n        dataroomId,\n      },\n    });\n\n    return res.status(200).json(brand);\n  } else if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/datarooms/:id/branding\n    const {\n      logo,\n      banner,\n      brandColor,\n      accentColor,\n      applyAccentColorToDataroomView,\n      welcomeMessage,\n    } = req.body as {\n      logo?: string;\n      banner?: string;\n      brandColor?: string;\n      accentColor?: string;\n      applyAccentColorToDataroomView?: boolean;\n      welcomeMessage?: string;\n    };\n\n    const brand = await prisma.dataroomBrand.update({\n      where: {\n        dataroomId,\n      },\n      data: {\n        logo,\n        banner,\n        brandColor,\n        accentColor,\n        applyAccentColorToDataroomView: !!applyAccentColorToDataroomView,\n        welcomeMessage,\n      },\n    });\n\n    return res.status(200).json(brand);\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/datarooms/:id/branding\n    const brand = await prisma.dataroomBrand.findFirst({\n      where: {\n        dataroomId,\n      },\n      select: { id: true, logo: true, banner: true },\n    });\n\n    if (brand && brand.logo) {\n      // delete the logo from vercel blob\n      await del(brand.logo);\n    }\n    if (brand && brand.banner) {\n      // delete the logo from vercel blob\n      await del(brand.banner);\n    }\n\n    // delete the branding from database\n    await prisma.dataroomBrand.delete({\n      where: {\n        id: brand?.id,\n      },\n    });\n\n    return res.status(204).end();\n  } else {\n    // We only allow GET, POST, PUT, DELETE requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\", \"PUT\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/calculate-indexes.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { calculateAndUpdateHierarchicalIndexes } from \"@/lib/utils/calculate-hierarchical-indexes\";\n\nexport const config = {\n  maxDuration: 300,\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id: dataroomId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const userId = (session.user as CustomUser).id;\n\n  if (!dataroomId || typeof dataroomId !== \"string\") {\n    return res.status(400).json({ message: \"Invalid dataroom ID\" });\n  }\n\n  if (!teamId || typeof teamId !== \"string\") {\n    return res.status(400).json({ message: \"Invalid team ID\" });\n  }\n\n  try {\n    // Verify user has access to this dataroom and get team plan\n    const dataroom = await prisma.dataroom.findUnique({\n      where: {\n        id: dataroomId,\n        teamId,\n        team: {\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      },\n      select: {\n        id: true,\n        name: true,\n        team: {\n          select: {\n            plan: true,\n          },\n        },\n      },\n    });\n\n    if (!dataroom) {\n      return res.status(404).json({ message: \"Dataroom not found\" });\n    }\n\n    // Check if user has access: feature flag enabled OR datarooms-plus plan\n    const featureFlags = await getFeatureFlags({ teamId });\n    const hasDataroomsPlusPlan =\n      dataroom.team.plan === \"datarooms-plus\" ||\n      dataroom.team.plan === \"datarooms-plus+old\" ||\n      dataroom.team.plan === \"datarooms-premium\" ||\n      dataroom.team.plan === \"datarooms-premium+old\";\n\n    if (!featureFlags.dataroomIndex && !hasDataroomsPlusPlan) {\n      return res.status(403).json({\n        message: \"This feature requires a Data Rooms Plus plan\",\n      });\n    }\n\n    // Calculate and update hierarchical indexes\n    const result = await calculateAndUpdateHierarchicalIndexes(dataroomId);\n\n    res.status(200).json({\n      message: \"Hierarchical indexes calculated successfully\",\n      foldersUpdated: result.foldersUpdated,\n      documentsUpdated: result.documentsUpdated,\n      totalUpdated: result.foldersUpdated + result.documentsUpdated,\n    });\n  } catch (error) {\n    console.error(\"Error calculating hierarchical indexes:\", error);\n    res.status(500).json({\n      message: \"Error calculating hierarchical indexes\",\n      error: error instanceof Error ? error.message : \"Unknown error\",\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/conversations/[[...conversations]].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { handleRoute } from \"@/ee/features/conversations/api/team-conversations-route\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/conversations/toggle-conversations.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport toggleConversationsRoute from \"@/ee/features/conversations/api/toggle-conversations-route\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return toggleConversationsRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/documents/[documentId]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/datarooms/:id/documents/:documentId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n    const userId = (session.user as CustomUser).id;\n    const {\n      teamId,\n      id: dataroomId,\n      documentId,\n    } = req.query as { teamId: string; id: string; documentId: string };\n    const { folderId, currentPathName } = req.body as {\n      folderId: string;\n      currentPathName: string;\n    };\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).json({ message: \"Unauthorized\" });\n      }\n\n      const document = await prisma.dataroomDocument.update({\n        where: {\n          id: documentId,\n          dataroomId: dataroomId,\n        },\n        data: {\n          folderId: folderId,\n        },\n        select: {\n          folder: {\n            select: {\n              path: true,\n            },\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).json({ message: \"Document not found\" });\n      }\n\n      return res.status(200).json({\n        message: \"Document moved successfully\",\n        newPath: document.folder?.path,\n        oldPath: currentPathName,\n      });\n    } catch (error) {}\n  } else if (req.method === \"DELETE\") {\n    /// DELETE /api/teams/:teamId/datarooms/:id/documents/:documentId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const {\n      teamId,\n      id: dataroomId,\n      documentId,\n    } = req.query as { teamId: string; id: string; documentId: string };\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n      if (!teamAccess) {\n        return res.status(401).json({ message: \"Unauthorized\" });\n      }\n\n      if (teamAccess.role !== \"ADMIN\" && teamAccess.role !== \"MANAGER\") {\n        return res.status(403).json({\n          message:\n            \"You are not permitted to perform this action. Only admin and managers can delete dataroom documents.\",\n        });\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ message: \"Dataroom not found\" });\n      }\n\n      const document = await prisma.dataroomDocument.delete({\n        where: {\n          id: documentId,\n          dataroomId: dataroomId,\n        },\n      });\n\n      if (!document) {\n        return res.status(404).json({ message: \"Document not found\" });\n      }\n\n      return res.status(204).end(); // No Content\n    } catch (error) {}\n  } else {\n    // We only allow PATCH and DELETE requests\n    res.setHeader(\"Allow\", [\"PATCH\", \"DELETE\"]);\n    return res\n      .status(405)\n      .json({ message: `Method ${req.method} Not Allowed` });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/documents/[documentId]/stats.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { View } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport {\n  getTotalAvgPageDuration,\n  getTotalDocumentDuration,\n} from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/documents/:documentId/stats\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      documentId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      documentId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Verify the document exists in the dataroom\n      const dataroomDocument = await prisma.dataroomDocument.findFirst({\n        where: {\n          dataroomId,\n          document: {\n            id: documentId,\n            teamId,\n          },\n        },\n        include: {\n          document: {\n            include: {\n              versions: {\n                take: 1,\n                where: {\n                  isPrimary: true,\n                },\n                select: {\n                  versionNumber: true,\n                  numPages: true,\n                },\n              },\n              team: {\n                select: {\n                  plan: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!dataroomDocument) {\n        return res.status(404).json({\n          error: \"Document not found in this dataroom\",\n        });\n      }\n\n      // Get all views for this document that are specifically from the dataroom\n      const views = await prisma.view.findMany({\n        where: {\n          documentId: documentId,\n        },\n      });\n\n      // if there are no views, return an empty array\n      if (!views || views.length === 0) {\n        return res.status(200).json({\n          views: [],\n          duration: { data: [] },\n          total_duration: 0,\n          avgCompletionRate: 0,\n          totalViews: 0,\n          totalPagesMax: 0,\n        });\n      }\n\n      const activeViews = views.filter((view) => !view.isArchived);\n      const archivedViews = views.filter((view) => view.isArchived);\n      const nonDataroomViews = activeViews.filter(\n        (view) => view.dataroomId !== dataroomId,\n      );\n\n      // exclude views from the team's members if requested\n      let internalViews: View[] = [];\n\n      // combined archived and internal and non-dataroom views\n      const allExcludedViews = [\n        ...internalViews,\n        ...archivedViews,\n        ...nonDataroomViews,\n      ];\n\n      // filter out the excluded views\n      const filteredViews = views\n        .filter(\n          (view) => !allExcludedViews.map((view) => view.id).includes(view.id),\n        )\n        .map((view) => ({\n          id: view.id,\n        }));\n\n      const duration = await getTotalAvgPageDuration({\n        documentId: documentId,\n        excludedLinkIds: \"\",\n        excludedViewIds: allExcludedViews.map((view) => view.id).join(\",\"),\n        since: 0,\n      });\n\n      const stats = {\n        views: filteredViews,\n        duration,\n        total_duration: 0, // INFO: hiding this for now\n        avgCompletionRate: 0, // INFO: hiding this for now\n        totalViews: filteredViews.length,\n        completionRate: 0,\n        totalPagesMax: dataroomDocument.document.versions[0].numPages,\n      };\n\n      return res.status(200).json(stats);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/documents/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport {\n  SUPPORTED_AI_CONTENT_TYPES,\n  addFileToVectorStoreTask,\n  processDocumentForAITask,\n} from \"@/ee/features/ai/lib/trigger\";\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { runs } from \"@trigger.dev/sdk/v3\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { sendDataroomChangeNotificationTask } from \"@/lib/trigger/dataroom-change-notification\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log, serializeFileSize } from \"@/lib/utils\";\nimport { sortItemsByIndexAndName } from \"@/lib/utils/sort-items-by-index-name\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/documents\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const documents = await prisma.dataroomDocument.findMany({\n        where: {\n          dataroomId: dataroomId,\n          folderId: null,\n        },\n        orderBy: [\n          { orderIndex: \"asc\" },\n          {\n            document: {\n              name: \"asc\",\n            },\n          },\n        ],\n        select: {\n          id: true,\n          dataroomId: true,\n          folderId: true,\n          orderIndex: true,\n          hierarchicalIndex: true,\n          createdAt: true,\n          updatedAt: true,\n          document: {\n            select: {\n              id: true,\n              name: true,\n              type: true,\n              advancedExcelEnabled: true,\n              versions: {\n                select: { id: true, hasPages: true },\n              },\n              isExternalUpload: true,\n              _count: {\n                select: {\n                  views: { where: { dataroomId } },\n                  versions: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      const sortedDocuments = sortItemsByIndexAndName(documents);\n\n      return res.status(200).json(sortedDocuments);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res\n        .status(500)\n        .json({ error: \"Error fetching documents from dataroom\" });\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/documents\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    // Assuming data is an object with `name` and `description` properties\n    const { documentId, folderPathName } = req.body as {\n      documentId: string;\n      folderPathName?: string;\n    };\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. Adding documents to dataroom is not available.\",\n        });\n      }\n\n      const folder = await prisma.dataroomFolder.findUnique({\n        where: {\n          dataroomId_path: {\n            dataroomId,\n            path: \"/\" + folderPathName,\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      const dataroomDocument = await prisma.dataroomDocument.create({\n        data: {\n          documentId,\n          dataroomId,\n          folderId: folder?.id,\n        },\n        include: {\n          document: {\n            include: {\n              versions: {\n                where: { isPrimary: true },\n                take: 1,\n              },\n            },\n          },\n          dataroom: {\n            select: {\n              teamId: true,\n              name: true,\n              enableChangeNotifications: true,\n              agentsEnabled: true,\n              vectorStoreId: true,\n              links: {\n                select: { id: true },\n                orderBy: { createdAt: \"desc\" },\n                take: 1,\n              },\n              _count: {\n                select: { viewerGroups: true, permissionGroups: true },\n              },\n            },\n          },\n        },\n      });\n\n      // Auto-index document if dataroom has AI agents enabled\n      if (\n        dataroomDocument.dataroom.agentsEnabled &&\n        dataroomDocument.dataroom.vectorStoreId\n      ) {\n        const primaryVersion = dataroomDocument.document.versions[0];\n        const contentType = primaryVersion?.contentType || \"\";\n\n        // Check if AI feature is enabled for the team\n        const features = await getFeatureFlags({ teamId });\n\n        if (\n          features.ai &&\n          primaryVersion &&\n          SUPPORTED_AI_CONTENT_TYPES.includes(contentType)\n        ) {\n          const filePath =\n            primaryVersion.originalFile && contentType !== \"application/pdf\"\n              ? primaryVersion.originalFile\n              : primaryVersion.file;\n\n          const fileMetadata = {\n            teamId: dataroomDocument.dataroom.teamId,\n            documentId: dataroomDocument.document.id,\n            documentName: dataroomDocument.document.name,\n            versionId: primaryVersion.id,\n            dataroomId: dataroomDocument.dataroomId,\n            dataroomDocumentId: dataroomDocument.id,\n            dataroomFolderId: dataroomDocument.folderId || \"root\",\n          };\n\n          try {\n            // If document already has fileId, just add to vector store\n            if (primaryVersion.fileId) {\n              waitUntil(\n                addFileToVectorStoreTask.trigger({\n                  fileId: primaryVersion.fileId,\n                  vectorStoreId: dataroomDocument.dataroom.vectorStoreId,\n                  metadata: fileMetadata,\n                }),\n              );\n            } else {\n              // Trigger full processing\n              waitUntil(\n                processDocumentForAITask.trigger(\n                  {\n                    documentId: dataroomDocument.document.id,\n                    documentVersionId: primaryVersion.id,\n                    teamId: dataroomDocument.dataroom.teamId,\n                    vectorStoreId: dataroomDocument.dataroom.vectorStoreId,\n                    documentName: dataroomDocument.document.name,\n                    filePath,\n                    storageType: primaryVersion.storageType,\n                    contentType,\n                    metadata: fileMetadata,\n                  },\n                  {\n                    idempotencyKey: `ai-index-dataroom-${dataroomId}-${primaryVersion.id}`,\n                    tags: [\n                      `team_${teamId}`,\n                      `dataroom_${dataroomId}`,\n                      `document_${dataroomDocument.document.id}`,\n                      `version_${primaryVersion.id}`,\n                    ],\n                  },\n                ),\n              );\n            }\n          } catch (error) {\n            console.error(\"Error triggering AI indexing for document:\", error);\n            // Don't fail the document add, just log the error\n          }\n        }\n      }\n\n      // Check if the team has the dataroom change notification enabled\n      if (dataroomDocument.dataroom.enableChangeNotifications) {\n        // Get all delayed and queued runs for this dataroom\n        const allRuns = await runs.list({\n          taskIdentifier: [\"send-dataroom-change-notification\"],\n          tag: [`dataroom_${dataroomId}`],\n          status: [\"DELAYED\", \"QUEUED\"],\n          period: \"10m\",\n        });\n\n        // Cancel any existing unsent notification runs for this dataroom\n        await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));\n\n        waitUntil(\n          sendDataroomChangeNotificationTask.trigger(\n            {\n              dataroomId,\n              dataroomDocumentId: dataroomDocument.id,\n              senderUserId: userId,\n              teamId,\n            },\n            {\n              idempotencyKey: `dataroom-notification-${teamId}-${dataroomId}-${dataroomDocument.id}`,\n              tags: [\n                `team_${teamId}`,\n                `dataroom_${dataroomId}`,\n                `document_${dataroomDocument.id}`,\n              ],\n              delay: new Date(Date.now() + 10 * 60 * 1000), // 10 minute delay\n            },\n          ),\n        );\n      }\n\n      return res.status(201).json(serializeFileSize(dataroomDocument));\n    } catch (error) {\n      log({\n        message: `Failed to create dataroom document. \\n\\n*teamId*: _${teamId}_, \\n\\n*dataroomId*: ${dataroomId} \\n\\n ${error}`,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/documents/move.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/datarooms/:id/documents/move\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const { documentIds, folderId } = req.body as {\n      documentIds: string[];\n      folderId: string | null;\n    };\n\n    // Ensure the user is an admin of the team\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      include: {\n        datarooms: {\n          where: { id: dataroomId },\n        },\n        users: {\n          where: {\n            role: { in: [\"ADMIN\", \"MANAGER\"] },\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team || team.users.length === 0 || team.datarooms.length === 0) {\n      return res.status(403).end(\"Forbidden\");\n    }\n\n    // Update the folderId for the specified documents\n    const updatedDocuments = await prisma.dataroomDocument.updateMany({\n      where: {\n        id: { in: documentIds },\n        dataroomId: dataroomId,\n      },\n      data: {\n        folderId: folderId,\n        orderIndex: null,\n      },\n    });\n\n    // Get new path for folder unless folderId is null\n    let folder: { path: string } | null = null;\n    if (folderId) {\n      folder = await prisma.dataroomFolder.findUnique({\n        where: { id: folderId, dataroomId: dataroomId },\n        select: { path: true },\n      });\n    }\n\n    if (updatedDocuments.count === 0) {\n      return res.status(404).end(\"No documents were updated\");\n    }\n\n    console.log(\"Documents moved successfully\", updatedDocuments.count);\n\n    return res.status(200).json({\n      message: \"Document moved successfully\",\n      updatedCount: updatedDocuments.count,\n      newPath: folder?.path,\n    });\n  } else {\n    // We only allow PATCH requests\n    res.setHeader(\"Allow\", [\"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/download/[jobId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { generateFreshPresignedUrl } from \"@/lib/files/bulk-download-presign\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\nimport { CustomUser } from \"@/lib/types\";\n\n// Status polling endpoint for download progress modal\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const {\n    teamId,\n    id: dataroomId,\n    jobId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    jobId: string;\n  };\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    // Verify user has access to the team\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n      select: { teamId: true },\n    });\n\n    if (!teamAccess) {\n      return res\n        .status(403)\n        .json({ error: \"Unauthorized to access this team\" });\n    }\n\n    // Fetch the job from Redis\n    const job = await downloadJobStore.getJob(jobId);\n\n    if (!job) {\n      return res\n        .status(404)\n        .json({ error: \"Download job not found or expired\" });\n    }\n\n    // Verify the job belongs to this team and dataroom\n    if (job.teamId !== teamId || job.dataroomId !== dataroomId) {\n      return res\n        .status(403)\n        .json({ error: \"Job does not belong to this dataroom\" });\n    }\n\n    // Generate fresh presigned URLs using long-term IAM credentials\n    let freshDownloadUrls: string[] | undefined;\n    if (\n      job.status === \"COMPLETED\" &&\n      job.downloadS3Keys?.length &&\n      job.downloadUrls?.length\n    ) {\n      try {\n        freshDownloadUrls = await Promise.all(\n          job.downloadS3Keys.map((s3Key) =>\n            generateFreshPresignedUrl(teamId, s3Key),\n          ),\n        );\n      } catch (error) {\n        console.error(\"Failed to generate fresh presigned URLs:\", error);\n        freshDownloadUrls = job.downloadUrls;\n      }\n    }\n\n    // Return full status for polling\n    return res.status(200).json({\n      id: job.id,\n      status: job.status,\n      progress: job.progress,\n      totalFiles: job.totalFiles,\n      processedFiles: job.processedFiles,\n      downloadUrls:\n        job.status === \"COMPLETED\"\n          ? (freshDownloadUrls ?? job.downloadUrls)\n          : undefined,\n      error: job.status === \"FAILED\" ? job.error : undefined,\n      isReady: job.status === \"COMPLETED\" && !!job.downloadUrls?.length,\n      dataroomName: job.dataroomName,\n      createdAt: job.createdAt,\n      completedAt: job.completedAt,\n      expiresAt: job.expiresAt,\n    });\n  } catch (error) {\n    console.error(\"Error fetching download:\", error);\n    return res.status(500).json({\n      error: \"Failed to fetch download\",\n      details: (error as Error).message,\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/download/bulk.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport {\n  buildFolderNameMap,\n  buildFolderPathsFromHierarchy,\n} from \"@/lib/dataroom/build-folder-hierarchy\";\nimport prisma from \"@/lib/prisma\";\nimport { downloadJobStore } from \"@/lib/redis-download-job-store\";\nimport { bulkDownloadTask } from \"@/lib/trigger/bulk-download\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport const config = {\n  maxDuration: 60, // Reduced since we're just triggering the async task\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id: dataroomId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  if (req.method === \"POST\") {\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: { teamId: true },\n      });\n\n      if (!teamAccess) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n        select: {\n          id: true,\n          name: true,\n          folders: {\n            select: {\n              id: true,\n              name: true,\n              path: true,\n              parentId: true,\n            },\n          },\n          documents: {\n            select: {\n              id: true,\n              folderId: true,\n              document: {\n                select: {\n                  name: true,\n                  versions: {\n                    where: { isPrimary: true },\n                    select: {\n                      type: true,\n                      file: true,\n                      storageType: true,\n                      originalFile: true,\n                      contentType: true,\n                      fileSize: true,\n                    },\n                    take: 1,\n                  },\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).end(\"Dataroom not found\");\n      }\n\n      let downloadFolders = dataroom.folders;\n      let downloadDocuments = dataroom.documents;\n\n      // Build folder paths from the parentId hierarchy (source of truth)\n      // instead of using the materialized `path` field which can become stale\n      // after folder renames/moves\n      const computedPathMap = buildFolderPathsFromHierarchy(downloadFolders);\n      const folderMap = buildFolderNameMap(downloadFolders, computedPathMap);\n\n      // Construct folderStructure and fileKeys\n      const folderStructure: {\n        [key: string]: {\n          name: string;\n          path: string;\n          files: { name: string; key: string; size?: number }[];\n        };\n      } = {};\n      const fileKeys: string[] = [];\n\n      const addFileToStructure = (\n        path: string,\n        fileName: string,\n        fileKey: string,\n        fileSize?: number,\n      ) => {\n        const pathParts = path.split(\"/\").filter(Boolean);\n        let currentPath = \"\";\n\n        // Add folder information for each level of the path\n        pathParts.forEach((part, index) => {\n          currentPath += \"/\" + part;\n          const folderInfo = folderMap.get(currentPath);\n          if (!folderStructure[currentPath]) {\n            folderStructure[currentPath] = {\n              name: folderInfo ? folderInfo.name : part,\n              path: currentPath,\n              files: [],\n            };\n          }\n        });\n\n        // Add the file to the leaf folder\n        if (!folderStructure[path]) {\n          const folderInfo = folderMap.get(path) || {\n            name: \"Root\",\n            id: null,\n          };\n          folderStructure[path] = {\n            name: folderInfo.name,\n            path: path,\n            files: [],\n          };\n        }\n        folderStructure[path].files.push({\n          name: fileName,\n          key: fileKey,\n          size: fileSize,\n        });\n        fileKeys.push(fileKey);\n      };\n\n      downloadDocuments\n        .filter((doc) => !doc.folderId)\n        .filter((doc) => doc.document.versions[0].type !== \"notion\")\n        .filter((doc) => doc.document.versions[0].storageType !== \"VERCEL_BLOB\")\n        .forEach((doc) =>\n          addFileToStructure(\n            \"/\",\n            doc.document.name,\n            doc.document.versions[0].originalFile ??\n              doc.document.versions[0].file,\n            doc.document.versions[0].fileSize\n              ? Number(doc.document.versions[0].fileSize)\n              : undefined,\n          ),\n        );\n\n      // Pre-index documents by folderId for O(1) lookup per folder\n      const docsByFolderId = new Map<string, typeof downloadDocuments>();\n      for (const doc of downloadDocuments) {\n        if (!doc.folderId) continue;\n        const list = docsByFolderId.get(doc.folderId) ?? [];\n        list.push(doc);\n        docsByFolderId.set(doc.folderId, list);\n      }\n\n      downloadFolders.forEach((folder) => {\n        // Use the computed path from parentId hierarchy instead of the stored path\n        const folderPath = computedPathMap.get(folder.id) ?? folder.path;\n\n        const folderDocs = (docsByFolderId.get(folder.id) ?? [])\n          .filter((doc) => doc.document.versions[0].type !== \"notion\")\n          .filter(\n            (doc) => doc.document.versions[0].storageType !== \"VERCEL_BLOB\",\n          );\n\n        folderDocs &&\n          folderDocs.forEach((doc) =>\n            addFileToStructure(\n              folderPath,\n              doc.document.name,\n              doc.document.versions[0].originalFile ??\n                doc.document.versions[0].file,\n              doc.document.versions[0].fileSize\n                ? Number(doc.document.versions[0].fileSize)\n                : undefined,\n            ),\n          );\n\n        // If the folder is empty, ensure it's still added to the structure\n        if (folderDocs && folderDocs.length === 0) {\n          addFileToStructure(folderPath, \"\", \"\");\n        }\n      });\n\n      if (fileKeys.length === 0) {\n        return res.status(404).json({ error: \"No files to download\" });\n      }\n\n      // Get team-specific storage config\n      const storageConfig = await getTeamStorageConfigById(teamId);\n\n      // Get user email for notification\n      const user = await prisma.user.findUnique({\n        where: { id: userId },\n        select: { email: true },\n      });\n\n      // Create download job in Redis\n      const job = await downloadJobStore.createJob({\n        type: \"bulk\",\n        status: \"PENDING\",\n        dataroomId: dataroom.id,\n        dataroomName: dataroom.name,\n        totalFiles: fileKeys.length,\n        processedFiles: 0,\n        progress: 0,\n        teamId: teamId,\n        userId: userId,\n        emailNotification: !!user?.email,\n        emailAddress: user?.email ?? undefined,\n      });\n\n      // Trigger the async bulk download task\n      const handle = await bulkDownloadTask.trigger(\n        {\n          jobId: job.id,\n          dataroomId: dataroom.id,\n          dataroomName: dataroom.name,\n          teamId: teamId,\n          folderStructure: folderStructure,\n          fileKeys: fileKeys,\n          sourceBucket: storageConfig.bucket,\n          watermarkConfig: { enabled: false }, // No watermark for team member downloads\n          userId: userId,\n          emailNotification: !!user?.email,\n          emailAddress: user?.email ?? undefined,\n        },\n        {\n          idempotencyKey: job.id,\n          tags: [\n            `team_${teamId}`,\n            `dataroom_${dataroom.id}`,\n            `job_${job.id}`,\n            `user_${userId}`,\n          ],\n        },\n      );\n\n      // Update job with trigger run ID\n      await downloadJobStore.updateJob(job.id, {\n        triggerRunId: handle.id,\n      });\n\n      // Return job ID immediately (async response)\n      return res.status(202).json({\n        jobId: job.id,\n        status: \"PENDING\",\n        message: \"Download started. You will be notified when ready.\",\n      });\n    } catch (error) {\n      console.error(\"Error starting bulk download:\", error);\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/download/jobs.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { generateFreshPresignedUrl } from \"@/lib/files/bulk-download-presign\";\nimport prisma from \"@/lib/prisma\";\nimport {\n  type DownloadJob,\n  downloadJobStore,\n} from \"@/lib/redis-download-job-store\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id: dataroomId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  if (req.method === \"GET\") {\n    try {\n      // Verify team access\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: { teamId: true },\n      });\n\n      if (!teamAccess) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      // Get download jobs for this dataroom\n      const jobs = await downloadJobStore.getDataroomJobs(\n        dataroomId,\n        teamId,\n        10,\n      );\n\n      // Filter to only show relevant jobs for this user\n      const userJobs = jobs.filter((job) => {\n        // Show user's own jobs\n        if (job.userId === userId) {\n          // Always show pending/processing jobs\n          if (job.status === \"PENDING\" || job.status === \"PROCESSING\") {\n            return true;\n          }\n\n          // Show completed jobs that haven't expired\n          if (job.status === \"COMPLETED\" && job.expiresAt) {\n            return new Date(job.expiresAt) > new Date();\n          }\n\n          // Show failed jobs from the last hour\n          if (job.status === \"FAILED\") {\n            const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);\n            return new Date(job.createdAt) > oneHourAgo;\n          }\n        }\n\n        return false;\n      });\n\n      // Generate fresh presigned URLs for completed jobs\n      const jobsWithFreshUrls = await Promise.all(\n        userJobs.map(async (job: DownloadJob) => {\n          if (\n            job.status === \"COMPLETED\" &&\n            job.downloadS3Keys?.length &&\n            job.downloadUrls?.length\n          ) {\n            try {\n              const freshUrls = await Promise.all(\n                job.downloadS3Keys.map((s3Key) =>\n                  generateFreshPresignedUrl(teamId, s3Key),\n                ),\n              );\n              return { ...job, downloadUrls: freshUrls };\n            } catch {\n              return job;\n            }\n          }\n          return job;\n        }),\n      );\n\n      return res.status(200).json(jobsWithFreshUrls);\n    } catch (error) {\n      console.error(\"Error fetching download jobs:\", error);\n      return res.status(500).json({\n        error: \"Failed to fetch download jobs\",\n        details: (error as Error).message,\n      });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/duplicate.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { getLimits } from \"@/ee/limits/server\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport {\n  Dataroom,\n  DataroomBrand,\n  DataroomDocument,\n  DataroomFolder,\n} from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\ninterface DataroomWithContents extends Dataroom {\n  documents: DataroomDocument[];\n  folders: DataroomFolderWithContents[];\n  brand: Partial<DataroomBrand> | null;\n}\n\ninterface DataroomFolderWithContents extends DataroomFolder {\n  documents: DataroomDocument[];\n  childFolders: DataroomFolderWithContents[];\n}\n\n// Function to fetch the existing data room structure\nasync function fetchDataroomContents(\n  dataroomId: string,\n): Promise<DataroomWithContents> {\n  const dataroom = await prisma.dataroom.findUnique({\n    where: { id: dataroomId },\n    include: {\n      documents: true,\n      folders: {\n        where: { parentId: null }, // Only get root folders initially\n        include: { documents: true },\n      },\n      brand: true,\n    },\n  });\n\n  if (!dataroom) {\n    throw new Error(`Dataroom with id ${dataroomId} not found`);\n  }\n\n  // Recursive function to fetch folder contents\n  async function getFolderContents(\n    folderId: string,\n  ): Promise<DataroomFolderWithContents> {\n    const folder = await prisma.dataroomFolder.findUnique({\n      where: { id: folderId },\n      include: {\n        documents: true,\n        childFolders: {\n          include: { documents: true },\n        },\n      },\n    });\n\n    if (!folder) {\n      throw new Error(`Folder with id ${folderId} not found`);\n    }\n\n    const childFolders = await Promise.all(\n      folder.childFolders.map(async (childFolder) => {\n        const nestedContents = await getFolderContents(childFolder.id);\n        return nestedContents;\n      }),\n    );\n\n    return {\n      ...folder,\n      documents: folder.documents,\n      childFolders: childFolders,\n    };\n  }\n\n  // Transform root folders by fetching their complete contents\n  const foldersWithContents = await Promise.all(\n    dataroom.folders.map((folder) => getFolderContents(folder.id)),\n  );\n\n  return {\n    ...dataroom,\n    documents: dataroom.documents.filter((doc) => !doc.folderId),\n    folders: foldersWithContents,\n    brand: dataroom.brand,\n  };\n}\n\n// Recursive function to duplicate folders and documents\nasync function duplicateFolders(\n  dataroomId: string,\n  folder: DataroomFolderWithContents,\n  parentFolderId?: string,\n) {\n  const newFolder = await prisma.dataroomFolder.create({\n    data: {\n      name: folder.name,\n      path: folder.path,\n      parentId: parentFolderId,\n      dataroomId: dataroomId,\n    },\n    select: { id: true },\n  });\n\n  // Duplicate documents for the current folder\n  await Promise.allSettled(\n    folder.documents.map((doc) =>\n      prisma.dataroomDocument.create({\n        data: {\n          documentId: doc.documentId,\n          dataroomId: dataroomId,\n          folderId: newFolder.id,\n        },\n      }),\n    ),\n  );\n\n  // Duplicate child folders recursively\n  await Promise.allSettled(\n    folder.childFolders.map((childFolder) =>\n      duplicateFolders(dataroomId, childFolder, newFolder.id),\n    ),\n  );\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/duplicate\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        include: {\n          _count: {\n            select: {\n              datarooms: true,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (team.plan.includes(\"drtrial\")) {\n        return res.status(403).json({\n          message:\n            \"You've reached the limit of datarooms. Consider upgrading your plan.\",\n        });\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. Duplicating dataroom is not available.\",\n        });\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n        select: { id: true },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ message: \"Dataroom not found\" });\n      }\n\n      // Check if the team has reached the limit of datarooms\n      const limits = await getLimits({ teamId, userId });\n      if (limits && team._count.datarooms >= limits.datarooms) {\n        console.log(\n          \"Dataroom limit reached\",\n          limits.datarooms,\n          team._count.datarooms,\n        );\n        return res.status(400).json({\n          message:\n            \"You've reached the limit of datarooms. Consider upgrading your plan.\",\n        });\n      }\n\n      // Fetch the existing data room structure\n      const dataroomContents = await fetchDataroomContents(dataroomId);\n\n      // Create a new data room\n      const pId = newId(\"dataroom\");\n      const newDataroom = await prisma.dataroom.create({\n        data: {\n          pId: pId,\n          name: dataroomContents.name + \" (Copy)\",\n          teamId: dataroomContents.teamId,\n          documents: {\n            create: dataroomContents.documents.map((doc) => ({\n              documentId: doc.documentId,\n            })),\n          },\n          folders: {\n            create: [],\n          },\n          brand: {\n            create: {\n              banner: dataroomContents.brand?.banner,\n              logo: dataroomContents.brand?.logo,\n              accentColor: dataroomContents.brand?.accentColor,\n              applyAccentColorToDataroomView:\n                (dataroomContents.brand as any)?.applyAccentColorToDataroomView ??\n                false,\n              brandColor: dataroomContents.brand?.brandColor,\n            },\n          },\n        },\n      });\n\n      // Changed this section to properly await all folder duplications\n      await Promise.all(\n        dataroomContents.folders\n          .filter((folder) => !folder.parentId)\n          .map((folder) => duplicateFolders(newDataroom.id, folder)),\n      );\n\n      res.status(201).json(newDataroom);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ message: \"Error duplicating dataroom\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/export-visits.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { jobStore } from \"@/lib/redis-job-store\";\nimport { exportVisitsTask } from \"@/lib/trigger/export-visits\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team access\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: { plan: true },\n      });\n\n      if (!team) {\n        return res.status(404).end(\"Team not found\");\n      }\n\n      // Get existing exports for this dataroom\n      const existingExports = await jobStore.getResourceJobs(\n        dataroomId,\n        teamId,\n        \"dataroom\",\n        undefined,\n        10,\n      );\n\n      return res.status(200).json(existingExports);\n    } catch (error) {\n      console.error(\"Error fetching existing exports:\", error);\n      return res\n        .status(500)\n        .json({ message: \"Failed to fetch existing exports\" });\n    }\n  }\n\n  if (req.method !== \"POST\") {\n    // Changed to POST to trigger background job\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  // get dataroom id and teamId from query params\n  const { teamId, id: dataroomId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    // Fetching Team based on team.id\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n      select: { plan: true },\n    });\n\n    if (!team) {\n      return res.status(404).end(\"Team not found\");\n    }\n\n    if (team.plan === \"free\") {\n      return res\n        .status(403)\n        .json({ message: \"This feature is not available for your plan\" });\n    }\n\n    // Fetching Dataroom based on dataroom.id\n    const dataroom = await prisma.dataroom.findUnique({\n      where: { id: dataroomId, teamId: teamId },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n\n    if (!dataroom) {\n      return res.status(404).end(\"Dataroom not found\");\n    }\n\n    // Create export job record\n    const exportJob = await jobStore.createJob({\n      type: \"dataroom\",\n      resourceId: dataroomId,\n      resourceName: dataroom.name,\n      userId,\n      teamId,\n      status: \"PENDING\",\n    });\n\n    // Trigger the background task\n    const handle = await exportVisitsTask.trigger(\n      {\n        type: \"dataroom\",\n        teamId,\n        resourceId: dataroomId,\n        userId,\n        exportId: exportJob.id,\n      },\n      {\n        idempotencyKey: exportJob.id,\n        tags: [`team_${teamId}`, `user_${userId}`, `export_${exportJob.id}`],\n      },\n    );\n\n    // Update the job with the trigger run ID for cancellation\n    const updatedJob = await jobStore.updateJob(exportJob.id, {\n      triggerRunId: handle.id,\n    });\n\n    return res.status(200).json({\n      exportId: updatedJob?.id || exportJob.id,\n      status: updatedJob?.status || exportJob.status,\n      message:\n        \"Export job created successfully. You will be notified when it's ready.\",\n    });\n  } catch (error) {\n    console.error(\"Error creating export job:\", error);\n    return res.status(500).json({ message: \"Something went wrong\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/faqs/[faqId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport publishFAQRoute from \"@/ee/features/conversations/api/team-faqs-route\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return await publishFAQRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/faqs/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport publishFAQRoute from \"@/ee/features/conversations/api/team-faqs-route\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return await publishFAQRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/[...name].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { folderPathSchema } from \"@/lib/zod/schemas/folders\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/folders/:name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const {\n      teamId,\n      id: dataroomId,\n      name,\n    } = req.query as { teamId: string; id: string; name: string[] };\n\n    // Validate that name is an array of strings using shared Zod schema\n    const nameValidation = folderPathSchema.safeParse(name);\n    if (!nameValidation.success) {\n      return res.status(400).json({\n        error: \"Invalid folder path format\",\n        details: nameValidation.error.issues.map((issue) => issue.message),\n      });\n    }\n\n    const validatedName = nameValidation.data;\n    const path = \"/\" + validatedName.join(\"/\"); // construct the materialized path\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const parentFolder = await prisma.dataroomFolder.findUnique({\n        where: {\n          dataroomId_path: {\n            dataroomId,\n            path,\n          },\n        },\n        select: {\n          id: true,\n          parentId: true,\n        },\n      });\n\n      if (!parentFolder) {\n        return res.status(404).end(\"Parent Folder not found\");\n      }\n\n      const folders = await prisma.dataroomFolder.findMany({\n        where: {\n          dataroomId,\n          parentId: parentFolder.id,\n        },\n        orderBy: {\n          name: \"asc\",\n        },\n        select: {\n          id: true,\n          name: true,\n          path: true,\n          parentId: true,\n          dataroomId: true,\n          orderIndex: true,\n          hierarchicalIndex: true,\n          icon: true,\n          color: true,\n          createdAt: true,\n          updatedAt: true,\n          _count: {\n            select: { documents: true, childFolders: true },\n          },\n        },\n      });\n\n      return res.status(200).json(folders);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching folders\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/documents/[...name].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { sortItemsByIndexAndName } from \"@/lib/utils/sort-items-by-index-name\";\nimport { folderPathSchema } from \"@/lib/zod/schemas/folders\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/folders/documents/:name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const {\n      teamId,\n      id: dataroomId,\n      name,\n    } = req.query as { teamId: string; id: string; name: string[] };\n\n    // Validate that name is an array of strings using shared Zod schema\n    const nameValidation = folderPathSchema.safeParse(name);\n    if (!nameValidation.success) {\n      return res.status(400).json({\n        error: \"Invalid folder path format\",\n        details: nameValidation.error.issues.map((issue) => issue.message),\n      });\n    }\n\n    const validatedName = nameValidation.data;\n    const path = \"/\" + validatedName.join(\"/\"); // construct the materialized path\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const folder = await prisma.dataroomFolder.findUnique({\n        where: {\n          dataroomId_path: {\n            dataroomId,\n            path,\n          },\n        },\n        select: {\n          id: true,\n          parentId: true,\n        },\n      });\n\n      if (!folder) {\n        return res.status(404).end(\"Folder not found\");\n      }\n\n      const documents = await prisma.dataroomDocument.findMany({\n        where: {\n          dataroomId: dataroomId,\n          folderId: folder.id,\n        },\n        orderBy: [\n          { orderIndex: \"asc\" },\n          {\n            document: {\n              name: \"asc\",\n            },\n          },\n        ],\n        select: {\n          id: true,\n          dataroomId: true,\n          folderId: true,\n          createdAt: true,\n          updatedAt: true,\n          orderIndex: true,\n          hierarchicalIndex: true,\n          document: {\n            select: {\n              id: true,\n              name: true,\n              type: true,\n              advancedExcelEnabled: true,\n              versions: {\n                select: { id: true, hasPages: true },\n              },\n              isExternalUpload: true,\n              _count: {\n                select: {\n                  views: { where: { dataroomId } },\n                  versions: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      const sortedDocuments = sortItemsByIndexAndName(documents);\n\n      return res.status(200).json(sortedDocuments);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res\n        .status(500)\n        .json({ error: \"Error fetching dataroom folder documents\" });\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { DefaultPermissionStrategy, ItemType } from \"@prisma/client\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nasync function applyFolderPermissions(\n  dataroomId: string,\n  folderId: string,\n  folderPath: string,\n): Promise<void> {\n  try {\n    await applyDefaultFolderPermissions(dataroomId, folderId, folderPath);\n  } catch (error) {\n    console.error(\"Error applying folder permissions:\", error);\n    throw error;\n  }\n}\n\nasync function applyDefaultFolderPermissions(\n  dataroomId: string,\n  folderId: string,\n  folderPath?: string,\n) {\n  const [dataroom, viewerGroups, permissionGroups] = await Promise.all([\n    prisma.dataroom.findUnique({\n      where: { id: dataroomId },\n      select: {\n        defaultPermissionStrategy: true,\n        teamId: true,\n      },\n    }),\n    prisma.viewerGroup.findMany({\n      where: { dataroomId },\n      select: {\n        id: true,\n      },\n    }),\n    prisma.permissionGroup.findMany({\n      where: { dataroomId },\n      select: {\n        id: true,\n        name: true,\n      },\n    }),\n  ]);\n\n  if (!dataroom) return;\n\n  if (\n    dataroom.defaultPermissionStrategy ===\n      DefaultPermissionStrategy.INHERIT_FROM_PARENT &&\n    folderPath\n  ) {\n    // If we have a folder path, inherit from parent folder\n    await inheritFolderPermissionsFromParent(dataroomId, folderId, folderPath);\n    return;\n  }\n\n  // Fallback to default behavior (for root folders or non-inherit strategies)\n  const allPermissionGroupData: {\n    groupId: string;\n    itemId: string;\n    itemType: ItemType;\n    canView: boolean;\n    canDownload: boolean;\n    canDownloadOriginal: boolean;\n  }[] = [];\n\n  if (permissionGroups.length > 0) {\n    if (\n      dataroom.defaultPermissionStrategy ===\n      DefaultPermissionStrategy.INHERIT_FROM_PARENT\n    ) {\n      permissionGroups.forEach((group) => {\n        allPermissionGroupData.push({\n          groupId: group.id,\n          itemId: folderId,\n          itemType: ItemType.DATAROOM_FOLDER,\n          canView: true, // Root folders get view permissions by default\n          canDownload: false,\n          canDownloadOriginal: false,\n        });\n      });\n    }\n    // For other strategies (ASK_EVERY_TIME, HIDDEN_BY_DEFAULT), don't auto-create permissions\n  }\n\n  const viewerGroupData = viewerGroups.map((group) => ({\n    groupId: group.id,\n    itemId: folderId,\n    itemType: ItemType.DATAROOM_FOLDER,\n    canView:\n      dataroom.defaultPermissionStrategy ===\n      DefaultPermissionStrategy.INHERIT_FROM_PARENT,\n    canDownload: false,\n  }));\n\n  await Promise.all([\n    viewerGroupData.length > 0 &&\n      dataroom.defaultPermissionStrategy ===\n        DefaultPermissionStrategy.INHERIT_FROM_PARENT &&\n      prisma.viewerGroupAccessControls.createMany({\n        data: viewerGroupData,\n        skipDuplicates: true,\n      }),\n    allPermissionGroupData.length > 0 &&\n      prisma.permissionGroupAccessControls.createMany({\n        data: allPermissionGroupData,\n        skipDuplicates: true,\n      }),\n  ]);\n}\n\nasync function inheritFolderPermissionsFromParent(\n  dataroomId: string,\n  folderId: string,\n  folderPath: string,\n) {\n  // Get parent folder path\n  const pathSegments = folderPath.split(\"/\").filter(Boolean);\n  const parentPath = \"/\" + pathSegments.slice(0, -1).join(\"/\");\n\n  // If this is a root folder, apply default permissions\n  if (parentPath === \"/\") {\n    await applyDefaultFolderPermissions(dataroomId, folderId);\n    return;\n  }\n\n  const parentFolder = await prisma.dataroomFolder.findUnique({\n    where: {\n      dataroomId_path: { dataroomId, path: parentPath },\n    },\n    select: { id: true },\n  });\n\n  if (!parentFolder) {\n    // If no parent folder found, apply default permissions\n    await applyDefaultFolderPermissions(dataroomId, folderId);\n    return;\n  }\n\n  // Get existing permissions for the parent folder\n  const [parentViewerPermissions, parentPermissionGroupPermissions] =\n    await Promise.all([\n      prisma.viewerGroupAccessControls.findMany({\n        where: {\n          itemId: parentFolder.id,\n          itemType: ItemType.DATAROOM_FOLDER,\n        },\n        select: { groupId: true, canView: true, canDownload: true },\n      }),\n      prisma.permissionGroupAccessControls.findMany({\n        where: {\n          itemId: parentFolder.id,\n          itemType: ItemType.DATAROOM_FOLDER,\n        },\n        select: {\n          groupId: true,\n          canView: true,\n          canDownload: true,\n          canDownloadOriginal: true,\n        },\n      }),\n    ]);\n\n  // Apply parent permissions to the new folder\n  await prisma.$transaction(async (tx) => {\n    const viewerGroupPermissionsToCreate: any[] = [];\n    const permissionGroupPermissionsToCreate: any[] = [];\n\n    parentViewerPermissions.forEach((parentPerm) => {\n      viewerGroupPermissionsToCreate.push({\n        groupId: parentPerm.groupId,\n        itemId: folderId,\n        itemType: ItemType.DATAROOM_FOLDER,\n        canView: parentPerm.canView,\n        canDownload: parentPerm.canDownload,\n      });\n    });\n\n    parentPermissionGroupPermissions.forEach((parentPerm) => {\n      permissionGroupPermissionsToCreate.push({\n        groupId: parentPerm.groupId,\n        itemId: folderId,\n        itemType: ItemType.DATAROOM_FOLDER,\n        canView: parentPerm.canView,\n        canDownload: parentPerm.canDownload,\n        canDownloadOriginal: parentPerm.canDownloadOriginal,\n      });\n    });\n\n    if (viewerGroupPermissionsToCreate.length > 0) {\n      await tx.viewerGroupAccessControls.createMany({\n        data: viewerGroupPermissionsToCreate,\n        skipDuplicates: true,\n      });\n    }\n\n    if (permissionGroupPermissionsToCreate.length > 0) {\n      await tx.permissionGroupAccessControls.createMany({\n        data: permissionGroupPermissionsToCreate,\n        skipDuplicates: true,\n      });\n    }\n  });\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/folders\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const {\n      teamId,\n      id: dataroomId,\n      root,\n      include_documents,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      root?: string;\n      include_documents?: string;\n    };\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      /** if root is present then only get root folders */\n      if (root === \"true\") {\n        const folders = await prisma.dataroomFolder.findMany({\n          where: {\n            dataroomId,\n            parentId: null,\n          },\n          orderBy: [{ orderIndex: \"asc\" }, { name: \"asc\" }],\n          select: {\n            id: true,\n            name: true,\n            path: true,\n            parentId: true,\n            dataroomId: true,\n            orderIndex: true,\n            hierarchicalIndex: true,\n            icon: true,\n            color: true,\n            createdAt: true,\n            updatedAt: true,\n            _count: {\n              select: { documents: true, childFolders: true },\n            },\n          },\n        });\n\n        return res.status(200).json(folders);\n      }\n\n      if (include_documents === \"true\") {\n        const dataroomFolders = await prisma.dataroom.findUnique({\n          where: {\n            id: dataroomId,\n          },\n          select: {\n            documents: {\n              where: { folderId: null },\n              orderBy: [{ orderIndex: \"asc\" }, { document: { name: \"asc\" } }],\n              select: {\n                id: true,\n                folderId: true,\n                hierarchicalIndex: true,\n                document: {\n                  select: {\n                    id: true,\n                    name: true,\n                    type: true,\n                  },\n                },\n              },\n            },\n            folders: {\n              select: {\n                id: true,\n                name: true,\n                path: true,\n                parentId: true,\n                dataroomId: true,\n                orderIndex: true,\n                hierarchicalIndex: true,\n                icon: true,\n                color: true,\n                createdAt: true,\n                updatedAt: true,\n                documents: {\n                  select: {\n                    id: true,\n                    folderId: true,\n                    hierarchicalIndex: true,\n                    document: {\n                      select: {\n                        id: true,\n                        name: true,\n                        type: true,\n                      },\n                    },\n                  },\n                  orderBy: [\n                    { orderIndex: \"asc\" },\n                    { document: { name: \"asc\" } },\n                  ],\n                },\n              },\n              orderBy: [{ orderIndex: \"asc\" }, { name: \"asc\" }],\n            },\n          },\n        });\n\n        const folders = [\n          ...(dataroomFolders?.documents ?? []),\n          ...(dataroomFolders?.folders ?? []),\n        ];\n\n        return res.status(200).json(folders);\n      }\n\n      const folders = await prisma.dataroomFolder.findMany({\n        where: {\n          dataroomId,\n        },\n        orderBy: [\n          { orderIndex: \"asc\" },\n          {\n            name: \"asc\",\n          },\n        ],\n        select: {\n          id: true,\n          name: true,\n          path: true,\n          parentId: true,\n          dataroomId: true,\n          orderIndex: true,\n          hierarchicalIndex: true,\n          icon: true,\n          color: true,\n          createdAt: true,\n          updatedAt: true,\n          documents: {\n            select: {\n              orderIndex: true,\n              id: true,\n              folderId: true,\n              hierarchicalIndex: true,\n              document: {\n                select: {\n                  id: true,\n                  name: true,\n                  type: true,\n                },\n              },\n            },\n            orderBy: [\n              { orderIndex: \"asc\" },\n              {\n                document: {\n                  name: \"asc\",\n                },\n              },\n            ],\n          },\n          childFolders: {\n            include: {\n              documents: {\n                select: {\n                  orderIndex: true,\n                  id: true,\n                  folderId: true,\n                  document: {\n                    select: {\n                      id: true,\n                      name: true,\n                      type: true,\n                    },\n                  },\n                },\n                orderBy: [\n                  { orderIndex: \"asc\" },\n                  {\n                    document: {\n                      name: \"asc\",\n                    },\n                  },\n                ],\n              },\n            },\n          },\n        },\n      });\n\n      return res.status(200).json(folders);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching folders\" });\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/folders\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const { name, path, icon, color } = req.body as {\n      name: string;\n      path?: string;\n      icon?: string;\n      color?: string;\n    };\n\n    const parentFolderPath = path ? \"/\" + path : \"/\";\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const parentFolder = await prisma.dataroomFolder.findUnique({\n        where: {\n          dataroomId_path: {\n            dataroomId: dataroomId,\n            path: parentFolderPath,\n          },\n        },\n        select: {\n          id: true,\n          name: true,\n          path: true,\n        },\n      });\n\n      // Duplicate name handling\n      let folderName = name;\n      let counter = 1;\n      const MAX_RETRIES = 50;\n\n      // Split path into segments\n      // Slugify the final folder name\n      const pathSegments = path ? path.split(\"/\").filter(Boolean) : [];\n      const basePath =\n        pathSegments.length > 0 ? \"/\" + pathSegments.join(\"/\") + \"/\" : \"/\";\n\n      let childFolderPath = basePath + safeSlugify(folderName);\n\n      while (counter <= MAX_RETRIES) {\n        const existingFolder = await prisma.dataroomFolder.findUnique({\n          where: {\n            dataroomId_path: {\n              dataroomId: dataroomId,\n              path: childFolderPath,\n            },\n          },\n        });\n        if (!existingFolder) break;\n        folderName = `${name} (${counter})`;\n        childFolderPath = basePath + safeSlugify(folderName);\n        counter++;\n      }\n\n      if (counter > MAX_RETRIES) {\n        return res.status(400).json({\n          error: \"Failed to create folder\",\n          message: \"Too many folders with similar names\",\n        });\n      }\n\n      const folder = await prisma.dataroomFolder.create({\n        data: {\n          name: folderName,\n          path: childFolderPath,\n          parentId: parentFolder?.id ?? null,\n          dataroomId: dataroomId,\n          icon: icon ?? null,\n          color: color ?? null,\n        },\n      });\n\n      await applyFolderPermissions(dataroomId, folder.id, childFolderPath);\n\n      const folderWithDocs = {\n        ...folder,\n        documents: [],\n        childFolders: [],\n        parentFolderPath: parentFolderPath,\n      };\n\n      res.status(201).json(folderWithDocs);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ error: \"Error creating folder\" });\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/manage/[folderId]/dataroom-to-dataroom.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\ninterface FolderWithContents {\n  id: string;\n  name: string;\n  documents: { documentId: string }[];\n  childFolders: FolderWithContents[];\n}\n\nasync function fetchFolderContents(\n  folderId: string,\n): Promise<FolderWithContents> {\n  const folder = await prisma.dataroomFolder.findUnique({\n    where: { id: folderId },\n    include: {\n      documents: { select: { documentId: true } },\n      childFolders: true,\n    },\n  });\n\n  if (!folder) {\n    throw new Error(`Folder with id ${folderId} not found`);\n  }\n\n  const childFolders = await Promise.all(\n    folder.childFolders.map((childFolder) =>\n      fetchFolderContents(childFolder.id),\n    ),\n  );\n\n  return {\n    id: folder.id,\n    name: folder.name,\n    documents: folder.documents,\n    childFolders: childFolders,\n  };\n}\n\nasync function createDataroomStructure(\n  dataroomId: string,\n  folder: FolderWithContents,\n  parentPath: string = \"\",\n  parentFolderId?: string,\n): Promise<void> {\n  const currentPath = `${parentPath}/${safeSlugify(folder.name)}`;\n\n  const dataroomFolder = await prisma.dataroomFolder.create({\n    data: {\n      dataroomId,\n      path: currentPath,\n      name: folder.name,\n      parentId: parentFolderId,\n      documents: {\n        create: folder.documents.map((doc) => ({\n          documentId: doc.documentId,\n          dataroomId: dataroomId,\n        })),\n      },\n    },\n  });\n\n  await Promise.all(\n    folder.childFolders.map((childFolder) =>\n      createDataroomStructure(\n        dataroomId,\n        childFolder,\n        currentPath,\n        dataroomFolder.id,\n      ),\n    ),\n  );\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/dataroomId/:id/folders/manage/:folderId/dataroom-to-dataroom\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      folderId,\n      id: roomId,\n    } = req.query as {\n      teamId: string;\n      folderId: string;\n      id: string;\n    };\n    const { dataroomId } = req.body as { dataroomId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n          datarooms: {\n            some: {\n              id: roomId,\n              folders: {\n                some: {\n                  id: {\n                    equals: folderId,\n                  },\n                },\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (\n        (team.plan === \"free\" || team.plan === \"pro\") &&\n        !team.plan.includes(\"drtrial\")\n      ) {\n        return res.status(403).json({\n          message: \"Upgrade your plan to use datarooms.\",\n        });\n      }\n\n      try {\n        const folderContents = await fetchFolderContents(folderId);\n        await createDataroomStructure(dataroomId, folderContents);\n      } catch (error) {\n        return res.status(500).json({\n          message: \"Document already exists in dataroom!\",\n        });\n      }\n\n      return res.status(200).json({\n        message: \"Folder added to dataroom!\",\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/manage/[folderId]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/datarooms/[:id]/folders/manage\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      folderId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      folderId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).json({ message: \"Unauthorized\" });\n      }\n\n      if (teamAccess.role !== \"ADMIN\" && teamAccess.role !== \"MANAGER\") {\n        return res.status(403).json({\n          message:\n            \"You are not permitted to perform this action. Only admin and managers can delete dataroom folders.\",\n        });\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ message: \"Dataroom not found\" });\n      }\n\n      const folder = await prisma.dataroomFolder.findUnique({\n        where: {\n          id: folderId,\n          dataroomId: dataroomId,\n        },\n      });\n\n      if (!folder) {\n        return res.status(404).json({\n          message: \"Folder not found\",\n        });\n      }\n\n      // Delete the folder and its contents recursively\n      await deleteFolderAndContents(folderId);\n\n      return res.status(204).end(); // 204 No Content response for successful deletes\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow DELETE requests\n    res.setHeader(\"Allow\", [\"DELETE\"]);\n    return res\n      .status(405)\n      .json({ message: `Method ${req.method} Not Allowed` });\n  }\n}\n\nasync function deleteFolderAndContents(folderId: string) {\n  const childFoldersToDelete = await prisma.dataroomFolder.findMany({\n    where: {\n      parentId: folderId,\n    },\n  });\n\n  console.log(\"Deleting folder and contents\", childFoldersToDelete);\n\n  for (const folder of childFoldersToDelete) {\n    await deleteFolderAndContents(folder.id);\n  }\n\n  await prisma.dataroomDocument.deleteMany({\n    where: {\n      folderId: folderId,\n    },\n  });\n\n  await prisma.dataroomFolder.delete({\n    where: {\n      id: folderId,\n    },\n  });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/manage/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport {\n  ALLOWED_FOLDER_COLORS,\n  ALLOWED_FOLDER_ICONS,\n} from \"@/lib/constants/folder-constants\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/datarooms/:id/folders/manage\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const { folderId, name, icon, color } = req.body as {\n      folderId: string;\n      name: string;\n      icon?: string | null;\n      color?: string | null;\n    };\n\n    try {\n      // Validate icon if provided\n      if (icon !== undefined && icon !== null && !ALLOWED_FOLDER_ICONS.includes(icon as any)) {\n        return res.status(400).json({ message: \"Invalid folder icon\" });\n      }\n\n      // Validate color if provided\n      if (color !== undefined && color !== null && !ALLOWED_FOLDER_COLORS.includes(color as any)) {\n        return res.status(400).json({ message: \"Invalid folder color\" });\n      }\n\n      // Validate name\n      if (!name || name.trim().length === 0) {\n        return res.status(400).json({ message: \"Folder name is required\" });\n      }\n\n      if (name.trim().length > 256) {\n        return res.status(400).json({ message: \"Folder name must be 256 characters or less\" });\n      }\n\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const folder = await prisma.dataroomFolder.findUnique({\n        where: {\n          id: folderId,\n          dataroomId: dataroomId,\n        },\n        select: {\n          name: true,\n          path: true,\n          icon: true,\n          color: true,\n        },\n      });\n\n      if (!folder) {\n        return res.status(404).json({ message: \"Folder not found\" });\n      }\n\n      // take the old path and replace the last part with the new name\n      const oldPath = folder.path;\n      const newPathParts = folder.path.split(\"/\");\n      newPathParts.pop();\n      newPathParts.push(safeSlugify(name.trim()));\n      const newPath = newPathParts.join(\"/\");\n\n      // Build update data object with only changed fields\n      const updateData: {\n        name: string;\n        path: string;\n        icon?: string | null;\n        color?: string | null;\n      } = {\n        name: name.trim(),\n        path: newPath,\n      };\n\n      // Only include icon and color in update if they were provided\n      if (icon !== undefined) {\n        updateData.icon = icon;\n      }\n      if (color !== undefined) {\n        updateData.color = color;\n      }\n\n      // Use a transaction to update descendant paths and the folder atomically\n      const updatedFolder = await prisma.$transaction(async (tx) => {\n        // If the path is changing, we need to update all descendant folder paths\n        if (oldPath !== newPath) {\n          // Fetch all descendant folders whose path starts with the old path\n          const descendantFolders = await tx.dataroomFolder.findMany({\n            where: {\n              dataroomId: dataroomId,\n              path: { startsWith: `${oldPath}/` },\n            },\n            select: {\n              id: true,\n              path: true,\n            },\n          });\n\n          // Update all descendant paths by replacing the old prefix with the new one\n          // Update descendants first to avoid unique constraint violations\n          if (descendantFolders.length > 0) {\n            const descendantUpdates = descendantFolders.map((descendant) => {\n              // Replace the old path prefix with the new path\n              const relativePath = descendant.path.slice(oldPath.length);\n              const newDescendantPath = `${newPath}${relativePath}`;\n\n              return tx.dataroomFolder.update({\n                where: { id: descendant.id },\n                data: { path: newDescendantPath },\n              });\n            });\n\n            await Promise.all(descendantUpdates);\n          }\n        }\n\n        // Now update the renamed folder itself\n        return tx.dataroomFolder.update({\n          where: {\n            id: folderId,\n          },\n          data: updateData,\n        });\n      });\n\n      // Get parent folder path for cache invalidation\n      const parentFolderPath = folder.path.substring(\n        0,\n        folder.path.lastIndexOf(\"/\"),\n      );\n\n      return res.status(200).json({\n        message: \"Folder updated successfully\",\n        parentFolderPath: parentFolderPath || \"/\",\n        folder: updatedFolder,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow PUT requests\n    res.setHeader(\"Allow\", [\"PUT\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/move.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/datarooms/:id/folders/move\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const { selectedFolder, folderIds, selectedFolderPath } = req.body as {\n      folderIds: string[];\n      selectedFolder: string | null;\n      selectedFolderPath: string;\n    };\n\n    // Ensure the user is an admin of the team\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      include: {\n        datarooms: {\n          where: { id: dataroomId },\n        },\n        users: {\n          where: {\n            role: { in: [\"ADMIN\", \"MANAGER\"] },\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team || team.users.length === 0 || team.datarooms.length === 0) {\n      return res.status(403).end(\"Forbidden\");\n    }\n\n    try {\n      let updatedFolders: any[] = [];\n      await prisma.$transaction(async (prisma) => {\n        const foldersToMove = await prisma.dataroomFolder.findMany({\n          where: {\n            id: { in: folderIds },\n            dataroomId: dataroomId,\n          },\n        });\n\n        // Prevent moving a folder into itself or one of its descendants.\n        // Single query to fetch the full parentId map, then walk in memory.\n        if (selectedFolder) {\n          const allFolders = await prisma.dataroomFolder.findMany({\n            where: { dataroomId },\n            select: { id: true, parentId: true },\n          });\n          const parentMap = new Map(\n            allFolders.map((f) => [f.id, f.parentId]),\n          );\n          const folderIdSet = new Set(folderIds);\n          const visited = new Set<string>();\n          let currentId: string | null = selectedFolder;\n          while (currentId) {\n            if (folderIdSet.has(currentId)) {\n              throw new Error(\"MOVE_INVALID_PARENT\");\n            }\n            if (visited.has(currentId)) break;\n            visited.add(currentId);\n            currentId = parentMap.get(currentId) ?? null;\n          }\n        }\n\n        const existingFolders = await prisma.dataroomFolder.findMany({\n          where: {\n            dataroomId: dataroomId,\n            parentId: selectedFolder,\n          },\n          select: { name: true },\n        });\n        if (existingFolders.length > 0) {\n          const existingFolderNames = new Set(\n            existingFolders.map((f) => f.name),\n          );\n          const duplicateNames = foldersToMove\n            .map((folder) => folder.name)\n            .filter((name) => existingFolderNames.has(name));\n\n          if (duplicateNames.length > 0) {\n            throw new Error(\n              `MOVE_DUPLICATE_NAMES:${duplicateNames.join(\", \")}`,\n            );\n          }\n        }\n        // Fetch all nested subfolders of the selected folders (excluding the folders themselves)\n        const allSubfolders = await prisma.dataroomFolder.findMany({\n          where: {\n            dataroomId: dataroomId,\n            OR: foldersToMove.map((folder) => ({\n              path: { startsWith: `${folder.path}/` },\n            })),\n          },\n        });\n\n        const folderPathUpdates = new Map();\n\n        // Generate new paths for the folders being moved\n        foldersToMove.forEach((folder) => {\n          const newPath =\n            selectedFolderPath !== \"/\" && selectedFolderPath !== undefined\n              ? `${selectedFolderPath}/${safeSlugify(folder.name)}`\n              : `/${safeSlugify(folder.name)}`;\n\n          folderPathUpdates.set(folder.id, newPath);\n        });\n\n        // Update all subfolder paths dynamically\n        const updates = allSubfolders.map((subfolder) => {\n          // Find the parent folder it belongs to\n          const parentFolder = foldersToMove.find((folder) =>\n            subfolder.path.startsWith(folder.path),\n          );\n\n          if (!parentFolder) return null;\n\n          // Get the new base path for the parent\n          const newParentPath = folderPathUpdates.get(parentFolder.id);\n\n          // Calculate the new subfolder path by replacing the old path with the new one\n          const relativePath = subfolder.path\n            .replace(parentFolder.path, \"\")\n            .trim();\n          const newSubfolderPath = `${newParentPath}${relativePath}`;\n\n          return prisma.dataroomFolder.update({\n            where: { id: subfolder.id, dataroomId: dataroomId },\n            data: { path: newSubfolderPath },\n          });\n        });\n        // Update each folder individually with its new path\n        const updateMainFolders = folderIds.map((folderId) =>\n          prisma.dataroomFolder.update({\n            where: {\n              id: folderId,\n              dataroomId: dataroomId,\n            },\n            data: {\n              parentId: selectedFolder,\n              path: folderPathUpdates.get(folderId),\n              orderIndex: null,\n            },\n          }),\n        );\n\n        await Promise.all(updates);\n        updatedFolders = await Promise.all(updateMainFolders);\n      });\n\n      if (updatedFolders.length === 0) {\n        return res.status(404).end(\"No folder were updated\");\n      }\n\n      // Get new path for folder unless folderId is null\n      let folder: { path: string } | null = null;\n      if (selectedFolder) {\n        folder = await prisma.dataroomFolder.findUnique({\n          where: { id: selectedFolder, dataroomId: dataroomId },\n          select: { path: true },\n        });\n      }\n      return res.status(200).json({\n        message: \"Folder moved successfully\",\n        updatedCount: updatedFolders.length,\n        newPath: folder?.path,\n      });\n    } catch (error) {\n      if (error instanceof Error) {\n        if (error.message === \"MOVE_INVALID_PARENT\") {\n          return res.status(400).json({\n            message:\n              \"Cannot move a folder into itself or one of its subfolders\",\n          });\n        }\n        if (error.message.startsWith(\"MOVE_DUPLICATE_NAMES:\")) {\n          const names = error.message.slice(\"MOVE_DUPLICATE_NAMES:\".length);\n          return res.status(409).json({\n            message: `Cannot move folders: Duplicate names found inside target folder - ${names}`,\n          });\n        }\n      }\n      console.error(error);\n      return res.status(500).end(\"Failed to move folder\");\n    }\n  } else {\n    // We only allow PATCH requests\n    res.setHeader(\"Allow\", [\"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/folders/parents/[...name].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { folderPathSchema } from \"@/lib/zod/schemas/folders\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/folders/parents/:name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const {\n      teamId,\n      id: dataroomId,\n      name,\n    } = req.query as { teamId: string; id: string; name: string[] };\n\n    // Validate that name is an array of strings using shared Zod schema\n    const nameValidation = folderPathSchema.safeParse(name);\n    if (!nameValidation.success) {\n      return res.status(400).json({\n        error: \"Invalid folder path format\",\n        details: nameValidation.error.issues.map((issue) => issue.message),\n      });\n    }\n\n    const validatedName = nameValidation.data;\n    let folderNames = [];\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      for (let i = 0; i < validatedName.length; i++) {\n        const path = \"/\" + validatedName.slice(0, i + 1).join(\"/\"); // construct the materialized path\n        console.log(\"path\", path);\n\n        const folder = await prisma.dataroomFolder.findUnique({\n          where: {\n            dataroomId_path: {\n              dataroomId,\n              path,\n            },\n          },\n          select: {\n            id: true,\n            parentId: true,\n            name: true,\n            hierarchicalIndex: true,\n          },\n        });\n\n        if (!folder) {\n          return res.status(404).end(\"Parent Folder not found\");\n        }\n\n        folderNames.push({\n          name: folder.name,\n          path: path,\n          hierarchicalIndex: folder.hierarchicalIndex,\n        });\n      }\n\n      return res.status(200).json(folderNames);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching folders\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/generate-index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { ItemType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { generateDataroomIndex } from \"@/lib/dataroom/index-generator\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser, LinkWithDataroom } from \"@/lib/types\";\nimport { IndexFileFormat } from \"@/lib/types/index-file\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId, id: dataroomId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const { format = \"excel\", linkId } = req.body as {\n    format: IndexFileFormat;\n    linkId: string;\n  };\n\n  try {\n    // Check if user has access to the team\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    if (!team.plan.includes(\"datarooms\") && !team.plan.includes(\"trial\")) {\n      return res.status(401).json({\n        error: \"Please upgrade to a Data Rooms plan to generate an index\",\n      });\n    }\n\n    // Get the dataroom link with all necessary data\n    const link = await prisma.link.findUnique({\n      where: {\n        id: linkId,\n      },\n      select: {\n        id: true,\n        dataroomId: true,\n        linkType: true,\n        url: true,\n        name: true,\n        slug: true,\n        expiresAt: true,\n        createdAt: true,\n        updatedAt: true,\n        teamId: true,\n        isArchived: true,\n        domainId: true,\n        domainSlug: true,\n        groupId: true,\n        permissionGroupId: true,\n        dataroom: {\n          select: {\n            id: true,\n            name: true,\n            teamId: true,\n            documents: {\n              include: {\n                document: {\n                  include: {\n                    versions: { where: { isPrimary: true } },\n                  },\n                },\n              },\n            },\n            folders: true,\n            updatedAt: true,\n            createdAt: true,\n          },\n        },\n      },\n    });\n\n    if (!link || !link.dataroom || link.dataroom.id !== dataroomId) {\n      return res.status(404).json({ error: \"Link not found\" });\n    }\n\n    // check if link is expired or archived\n    if (link.expiresAt && link.expiresAt < new Date()) {\n      return res.status(404).json({ error: \"Link expired\" });\n    }\n\n    if (link.isArchived) {\n      return res.status(404).json({ error: \"Link archived\" });\n    }\n\n    // Check if the link is a group link and remove the folder/documents from the dataroom if not part of the group permissions\n    if (link.groupId) {\n      const groupAccessControls =\n        await prisma.viewerGroupAccessControls.findMany({\n          where: {\n            groupId: link.groupId,\n            OR: [{ canView: true }, { canDownload: true }],\n          },\n          select: {\n            itemId: true,\n            itemType: true,\n          },\n        });\n\n      const allowedDocuments = groupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_DOCUMENT)\n        .map((control) => control.itemId);\n      const allowedFolders = groupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_FOLDER)\n        .map((control) => control.itemId);\n\n      link.dataroom.documents = link.dataroom.documents.filter((doc) =>\n        allowedDocuments.includes(doc.id),\n      );\n      link.dataroom.folders = link.dataroom.folders.filter((folder) =>\n        allowedFolders.includes(folder.id),\n      );\n    }\n\n    // Check if the link has permission group restrictions and filter accordingly\n    if (link.permissionGroupId) {\n      const permissionGroupAccessControls =\n        await prisma.permissionGroupAccessControls.findMany({\n          where: {\n            groupId: link.permissionGroupId,\n            OR: [{ canView: true }, { canDownload: true }],\n          },\n          select: {\n            itemId: true,\n            itemType: true,\n          },\n        });\n\n      const allowedDocuments = permissionGroupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_DOCUMENT)\n        .map((control) => control.itemId);\n      const allowedFolders = permissionGroupAccessControls\n        .filter((control) => control.itemType === ItemType.DATAROOM_FOLDER)\n        .map((control) => control.itemId);\n\n      link.dataroom.documents = link.dataroom.documents.filter((doc) =>\n        allowedDocuments.includes(doc.id),\n      );\n      link.dataroom.folders = link.dataroom.folders.filter((folder) =>\n        allowedFolders.includes(folder.id),\n      );\n    }\n\n    // Map updatedAt to lastUpdatedAt for the dataroom and transform document versions\n    // @ts-ignore\n    const linkWithDataroom: LinkWithDataroom = {\n      ...link,\n      dataroom: {\n        ...link.dataroom,\n        createdAt: link.dataroom.createdAt,\n        lastUpdatedAt: link.dataroom.updatedAt,\n        documents: link.dataroom.documents.map((doc) => ({\n          id: doc.id,\n          folderId: doc.folderId,\n          orderIndex: doc.orderIndex,\n          updatedAt: doc.updatedAt,\n          createdAt: doc.createdAt,\n          hierarchicalIndex: doc.hierarchicalIndex,\n          document: {\n            id: doc.document.id,\n            name: doc.document.name,\n            versions: doc.document.versions.map((version) => ({\n              id: version.id,\n              versionNumber: version.versionNumber,\n              type: version.contentType || \"unknown\",\n              hasPages: version.hasPages,\n              file: version.file,\n              isVertical: version.isVertical,\n              numPages: version.numPages,\n              updatedAt: version.updatedAt,\n              fileSize:\n                typeof version.fileSize === \"bigint\"\n                  ? Number(version.fileSize)\n                  : version.fileSize,\n            })),\n          },\n        })),\n      },\n    };\n\n    const { dataroomIndex } = await getFeatureFlags({\n      teamId: link.dataroom.teamId,\n    });\n\n    // Generate the index file using the appropriate generator\n    const { data, filename, mimeType } = await generateDataroomIndex(\n      linkWithDataroom,\n      {\n        format,\n        baseUrl: link.domainId\n          ? `${link.domainSlug}/${link.slug}`\n          : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`,\n        showHierarchicalIndex: dataroomIndex,\n      },\n    );\n\n    // Set response headers for file download\n    res.setHeader(\"Content-Type\", mimeType);\n    res.setHeader(\"Content-Disposition\", `attachment; filename=${filename}`);\n\n    // Send the file\n    return res.send(data);\n  } catch (error) {\n    console.error(\"Request error\", error);\n    return res.status(500).json({ error: \"Error generating index\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/export-visits.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { jobStore } from \"@/lib/redis-job-store\";\nimport { exportVisitsTask } from \"@/lib/trigger/export-visits\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team access\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: { plan: true },\n      });\n\n      if (!team) {\n        return res.status(404).end(\"Team not found\");\n      }\n\n      // Get existing exports for this dataroom group\n      const existingExports = await jobStore.getResourceJobs(\n        dataroomId,\n        teamId,\n        \"dataroom\",\n        groupId,\n        10,\n      );\n\n      return res.status(200).json(existingExports);\n    } catch (error) {\n      console.error(\"Error fetching existing exports:\", error);\n      return res\n        .status(500)\n        .json({ message: \"Failed to fetch existing exports\" });\n    }\n  }\n\n  if (req.method !== \"POST\") {\n    // Changed to POST to trigger background job\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  // get dataroom id and teamId from query params\n  const {\n    teamId,\n    id: dataroomId,\n    groupId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    groupId: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    // Fetching Team based on team.id\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n      select: { plan: true },\n    });\n\n    if (!team) {\n      return res.status(404).end(\"Team not found\");\n    }\n\n    if (team.plan === \"free\") {\n      return res\n        .status(403)\n        .json({ message: \"This feature is not available for your plan\" });\n    }\n\n    // Fetching Dataroom based on dataroom.id\n    const dataroom = await prisma.dataroom.findUnique({\n      where: { id: dataroomId, teamId: teamId },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n\n    if (!dataroom) {\n      return res.status(404).end(\"Dataroom not found\");\n    }\n\n    // Create export job record\n    const exportJob = await jobStore.createJob({\n      type: \"dataroom\",\n      resourceId: dataroomId,\n      resourceName: dataroom.name,\n      groupId,\n      userId,\n      teamId,\n      status: \"PENDING\",\n    });\n\n    // Trigger the background task\n    const handle = await exportVisitsTask.trigger(\n      {\n        type: \"dataroom\",\n        teamId,\n        resourceId: dataroomId,\n        groupId,\n        userId,\n        exportId: exportJob.id,\n      },\n      {\n        idempotencyKey: exportJob.id,\n        tags: [`team_${teamId}`, `user_${userId}`, `export_${exportJob.id}`],\n      },\n    );\n\n    // Update the job with the trigger run ID for cancellation\n    const updatedJob = await jobStore.updateJob(exportJob.id, {\n      triggerRunId: handle.id,\n    });\n\n    return res.status(200).json({\n      exportId: updatedJob?.id || exportJob.id,\n      status: updatedJob?.status || exportJob.status,\n      message:\n        \"Export job created successfully. You will be notified when it's ready.\",\n    });\n  } catch (error) {\n    console.error(\"Error creating export job:\", error);\n    return res.status(500).json({ message: \"Something went wrong\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/groups/:groupId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const group = await prisma.viewerGroup.findUnique({\n        where: {\n          id: groupId,\n          dataroomId: dataroomId,\n        },\n        include: {\n          members: {\n            include: {\n              viewer: {\n                include: {\n                  invitations: {\n                    where: {\n                      groupId: groupId,\n                    },\n                    orderBy: {\n                      sentAt: \"desc\",\n                    },\n                    take: 1,\n                  },\n                },\n              },\n            },\n          },\n          accessControls: true,\n        },\n      });\n\n      return res.status(200).json(group);\n    } catch (error) {\n      log({\n        message: `Failed to get group for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/datarooms/:id/groups/:groupId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n    };\n\n    const { name, allowAll, domains } = req.body as {\n      name?: string;\n      allowAll?: boolean;\n      domains?: string[];\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n      if (!teamAccess) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const group = await prisma.viewerGroup.update({\n        where: {\n          id: groupId,\n          dataroomId: dataroomId,\n          teamId: teamId,\n          team: {\n            users: {\n              some: {\n                userId: userId,\n              },\n            },\n          },\n        },\n        data: {\n          ...(name && { name }),\n          ...(typeof allowAll === \"boolean\" && { allowAll }),\n          ...(domains && { domains }),\n        },\n      });\n\n      return res.status(200).json(group);\n    } catch (error) {\n      log({\n        message: `Failed to update group for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{groupId: ${groupId}, teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/datarooms/:id/groups/:groupId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // delete links associated with the group\n      await prisma.link.deleteMany({\n        where: {\n          groupId: groupId,\n          dataroomId: dataroomId,\n        },\n      });\n\n      // delete group\n      await prisma.viewerGroup.delete({\n        where: {\n          id: groupId,\n          dataroomId: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      res.status(200).json({ success: true });\n      return;\n    } catch (error) {\n      log({\n        message: `Failed to delete group for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{groupId: ${groupId}, teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET, PATCH, DELETE requests\n    res.setHeader(\"Allow\", [\"GET\", \"PATCH\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/invite.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport handleRoute from \"@/ee/features/dataroom-invitations/api/group-invite\";\n\nexport const config = {\n  maxDuration: 300,\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/links.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser, LinkWithViews } from \"@/lib/types\";\nimport { decryptEncrpytedPassword, log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/groups/:groupId/links\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: (session.user as CustomUser).id,\n            },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      let links = await prisma.link.findMany({\n        where: {\n          groupId: groupId,\n          dataroomId: dataroomId,\n          linkType: \"DATAROOM_LINK\",\n          deletedAt: null,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        include: {\n          views: {\n            where: {\n              viewType: \"DATAROOM_VIEW\",\n            },\n            orderBy: {\n              viewedAt: \"desc\",\n            },\n            take: 1,\n          },\n          customFields: true,\n          visitorGroups: {\n            select: { visitorGroupId: true },\n          },\n          _count: {\n            select: { views: { where: { viewType: \"DATAROOM_VIEW\" } } },\n          },\n        },\n      });\n\n      let extendedLinks: LinkWithViews[] = links as LinkWithViews[];\n      if (extendedLinks && extendedLinks.length > 0) {\n        extendedLinks = await Promise.all(\n          extendedLinks.map(async (link) => {\n            // Decrypt the password if it exists\n            if (link.password !== null) {\n              link.password = decryptEncrpytedPassword(link.password);\n            }\n            // Get the upload folder name if it exists\n            if (link.enableUpload && link.uploadFolderId !== null) {\n              const folder = await prisma.dataroomFolder.findUnique({\n                where: {\n                  id: link.uploadFolderId,\n                },\n                select: {\n                  name: true,\n                },\n              });\n              link.uploadFolderName = folder?.name;\n            }\n            // Get the tags for the link\n            const tags = await prisma.tag.findMany({\n              where: {\n                items: {\n                  some: {\n                    linkId: link.id,\n                    itemType: \"LINK_TAG\",\n                  },\n                },\n              },\n              select: {\n                id: true,\n                name: true,\n                color: true,\n                description: true,\n              },\n            });\n\n            return {\n              ...link,\n              tags,\n            };\n          }),\n        );\n      }\n\n      return res.status(200).json(extendedLinks);\n    } catch (error) {\n      log({\n        message: `Failed to get links for dataroom: _${dataroomId}_,group: _${groupId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, groupId: ${groupId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/members/[memberId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/datarooms/:id/groups/:groupId/members/:memberId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n      memberId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n      memberId: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).json(\"Unauthorized\");\n      }\n\n      await prisma.viewerGroupMembership.delete({\n        where: { id: memberId },\n      });\n      return res.status(204).end();\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/members/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/groups/:groupId/members\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n    };\n\n    const { emails, domains, allowAll } = req.body as {\n      emails: string[];\n      domains: string[];\n      allowAll: boolean;\n    };\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if the group belongs to the dataroom\n      const group = await prisma.viewerGroup.findUnique({\n        where: {\n          id: groupId,\n          dataroomId: dataroomId,\n        },\n      });\n\n      if (!group) {\n        return res.status(404).end(\"Group not found\");\n      }\n\n      // First, create or connect viewers\n      await prisma.viewer.createMany({\n        data: emails.map((email) => ({\n          email,\n          teamId,\n        })),\n        skipDuplicates: true,\n      });\n\n      const viewers = await prisma.viewer.findMany({\n        where: {\n          teamId: teamId,\n          email: {\n            in: emails,\n          },\n        },\n        select: { id: true },\n      });\n\n      // Then, create the membership\n      const members = await prisma.viewerGroupMembership.createMany({\n        data: viewers.map((viewer) => ({\n          groupId: groupId,\n          viewerId: viewer.id,\n        })),\n      });\n\n      // Update the group with the new domains and allowAll setting\n      await prisma.viewerGroup.update({\n        where: { id: groupId },\n        data: { domains, allowAll },\n      });\n\n      res.status(201).json(members);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ error: \"Error creating folder\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/permissions.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { ItemType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// Helper function to get parent folder IDs for a given document in a dataroom\nasync function getParentFolderIds(\n  dataroomId: string,\n  documentId: string,\n): Promise<string[]> {\n  // Get the dataroom document to find which folder it's in\n  const dataroomDocument = await prisma.dataroomDocument.findUnique({\n    where: { id: documentId },\n    select: { folderId: true },\n  });\n\n  if (!dataroomDocument?.folderId) {\n    return []; // Document is at root level\n  }\n\n  // Get the folder and walk up the hierarchy\n  const parentFolders: string[] = [];\n  let currentFolderId: string | null = dataroomDocument.folderId;\n\n  while (currentFolderId) {\n    parentFolders.push(currentFolderId);\n\n    const folder: { parentId: string | null } | null =\n      await prisma.dataroomFolder.findUnique({\n        where: { id: currentFolderId },\n        select: { parentId: true },\n      });\n\n    currentFolderId = folder?.parentId || null;\n  }\n\n  return parentFolders;\n}\n\n// Helper function to ensure parent folders are visible when child documents are made visible\nasync function ensureParentFoldersVisible(\n  dataroomId: string,\n  groupId: string,\n  permissions: Record<\n    string,\n    { itemType: ItemType; view: boolean; download: boolean }\n  >,\n): Promise<void> {\n  const foldersToMakeVisible = new Set<string>();\n\n  // Find all documents that are being made visible\n  const visibleDocuments = Object.entries(permissions)\n    .filter(\n      ([_, perm]) => perm.itemType === ItemType.DATAROOM_DOCUMENT && perm.view,\n    )\n    .map(([itemId, _]) => itemId);\n\n  // Get parent folder IDs for all visible documents\n  for (const documentId of visibleDocuments) {\n    const parentFolders = await getParentFolderIds(dataroomId, documentId);\n    parentFolders.forEach((folderId) => foldersToMakeVisible.add(folderId));\n  }\n\n  // Also handle folders that are being made visible - ensure their parent folders are visible too\n  const visibleFolders = Object.entries(permissions)\n    .filter(\n      ([_, perm]) => perm.itemType === ItemType.DATAROOM_FOLDER && perm.view,\n    )\n    .map(([itemId, _]) => itemId);\n\n  for (const folderId of visibleFolders) {\n    let currentFolderId: string | null = folderId;\n\n    while (currentFolderId) {\n      const folder: { parentId: string | null } | null =\n        await prisma.dataroomFolder.findUnique({\n          where: { id: currentFolderId },\n          select: { parentId: true },\n        });\n\n      if (folder?.parentId) {\n        foldersToMakeVisible.add(folder.parentId);\n        currentFolderId = folder.parentId;\n      } else {\n        break;\n      }\n    }\n  }\n\n  // Update or create permissions for parent folders to make them visible\n  if (foldersToMakeVisible.size > 0) {\n    const foldersToUpdate = Array.from(foldersToMakeVisible);\n\n    await prisma.$transaction(async (tx) => {\n      for (const folderId of foldersToUpdate) {\n        await tx.viewerGroupAccessControls.upsert({\n          where: {\n            groupId_itemId: {\n              groupId: groupId,\n              itemId: folderId,\n            },\n          },\n          create: {\n            groupId: groupId,\n            itemId: folderId,\n            itemType: ItemType.DATAROOM_FOLDER,\n            canView: true,\n            canDownload: false,\n          },\n          update: {\n            canView: true, // Always ensure parent folders are visible\n            // Don't change canDownload - preserve existing setting\n          },\n        });\n      }\n    });\n  }\n}\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  // POST /api/teams/:teamId/datarooms/:id/groups/:groupId/permissions\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    res.status(401).end(\"Unauthorized\");\n    return;\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const {\n    teamId,\n    id: dataroomId,\n    groupId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    groupId: string;\n  };\n\n  try {\n    const { permissions } = req.body as {\n      permissions: Record<\n        string,\n        { itemType: ItemType; view: boolean; download: boolean }\n      >;\n    };\n\n    // Validate input\n    if (!permissions) {\n      return res.status(400).json({ message: \"Missing required fields\" });\n    }\n\n    // Check if the user is part of the team\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(403).json({ message: \"Unauthorized\" });\n    }\n\n    const existingPermissions = await prisma.viewerGroupAccessControls.findMany(\n      {\n        where: {\n          groupId,\n          group: { dataroomId },\n        },\n        select: { itemId: true, itemType: true },\n      },\n    );\n\n    const existingSet = new Set(\n      existingPermissions.map((p) => `${p.itemId}-${p.itemType}`),\n    );\n\n    const toUpdate: {\n      groupId: string;\n      itemId: string;\n      itemType: ItemType;\n      canView: boolean;\n      canDownload: boolean;\n    }[] = [];\n    const toCreate: {\n      groupId: string;\n      itemId: string;\n      itemType: ItemType;\n      canView: boolean;\n      canDownload: boolean;\n    }[] = [];\n\n    Object.entries(permissions).forEach(([itemId, itemPermissions]) => {\n      const key = `${itemId}-${itemPermissions.itemType}`;\n      const data = {\n        groupId,\n        itemId,\n        itemType: itemPermissions.itemType,\n        canView: itemPermissions.view,\n        canDownload: itemPermissions.download,\n      };\n\n      if (existingSet.has(key)) {\n        toUpdate.push(data);\n      } else {\n        toCreate.push(data);\n      }\n    });\n\n    console.log(\"toUpdate\", toUpdate);\n    console.log(\"toCreate\", toCreate);\n\n    // Perform operations\n    await prisma.$transaction(async (tx) => {\n      if (toUpdate.length > 0) {\n        await Promise.all(\n          toUpdate.map((item) =>\n            tx.viewerGroupAccessControls.update({\n              where: {\n                groupId_itemId: {\n                  groupId: item.groupId,\n                  itemId: item.itemId,\n                },\n              },\n              data: {\n                canView: item.canView,\n                canDownload: item.canDownload,\n              },\n            }),\n          ),\n        );\n      }\n\n      if (toCreate.length > 0) {\n        await tx.viewerGroupAccessControls.createMany({\n          data: toCreate,\n        });\n      }\n    });\n\n    // After saving permissions, ensure parent folders are visible for any items that were made visible\n    await ensureParentFoldersVisible(dataroomId, groupId, permissions);\n\n    res.status(200).json({ message: \"Permissions updated successfully\" });\n  } catch (error) {\n    console.error(\"Error updating permissions:\", error);\n    res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/uninvited.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport handleRoute from \"@/ee/features/dataroom-invitations/api/uninvited\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/[groupId]/views/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/groups/:groupId/views\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      groupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      groupId: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          pauseStartsAt: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const pauseStartedAt = team.pauseStartsAt;\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n        select: {\n          id: true,\n          teamId: true,\n          name: true,\n          views: {\n            where: {\n              viewType: \"DATAROOM_VIEW\",\n              groupId: groupId,\n              ...(pauseStartedAt && {\n                viewedAt: {\n                  lt: pauseStartedAt,\n                },\n              }),\n            },\n            orderBy: {\n              viewedAt: \"desc\",\n            },\n            include: {\n              link: {\n                select: {\n                  name: true,\n                },\n              },\n              agreementResponse: {\n                select: {\n                  id: true,\n                  agreementId: true,\n                  agreement: {\n                    select: {\n                      name: true,\n                    },\n                  },\n                },\n              },\n            },\n          },\n        },\n      });\n\n      const users = await prisma.user.findMany({\n        where: {\n          teams: {\n            some: {\n              teamId: teamId,\n            },\n          },\n        },\n        select: {\n          email: true,\n        },\n      });\n\n      // Calculate hidden views due to pause (views after pause date)\n      const hiddenViewsFromPause = pauseStartedAt\n        ? await prisma.view.count({\n            where: {\n              dataroomId: dataroomId,\n              viewType: \"DATAROOM_VIEW\",\n              groupId: groupId,\n              viewedAt: {\n                gte: pauseStartedAt,\n              },\n            },\n          })\n        : 0;\n\n      const views = dataroom?.views || [];\n\n      const returnViews = views.map((view) => {\n        return {\n          ...view,\n          dataroomName: dataroom?.name,\n          internal: users.some((user) => user.email === view.viewerEmail),\n        };\n      });\n\n      return res.status(200).json({\n        views: returnViews,\n        hiddenFromPause: hiddenViewsFromPause,\n      });\n    } catch (error) {\n      log({\n        message: `Failed to get views for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/groups/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { ItemType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/groups?documentId=:documentId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const documentId = req.query?.documentId as string;\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // First, verify dataroom exists and get basic info\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n        select: {\n          id: true,\n          teamId: true,\n          name: true,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).end(\"Dataroom not found\");\n      }\n\n      // Then, get viewer groups with optimized separate queries\n      const viewerGroups = await prisma.viewerGroup.findMany({\n        where: {\n          dataroomId: dataroomId,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        include: {\n          ...(documentId\n            ? {\n                accessControls: {\n                  where: {\n                    itemId: documentId,\n                    itemType: ItemType.DATAROOM_DOCUMENT,\n                  },\n                  select: {\n                    id: true,\n                    canView: true,\n                    canDownload: true,\n                    itemId: true,\n                  },\n                },\n              }\n            : {}),\n        },\n      });\n\n      // Get counts separately with efficient GROUP BY queries\n      const groupIds = viewerGroups.map((g) => g.id);\n\n      const [memberCounts, viewCounts] = await Promise.all([\n        prisma.viewerGroupMembership.groupBy({\n          by: [\"groupId\"],\n          where: {\n            groupId: { in: groupIds },\n          },\n          _count: { id: true },\n        }),\n        prisma.view.groupBy({\n          by: [\"groupId\"],\n          where: {\n            groupId: { in: groupIds },\n          },\n          _count: { id: true },\n        }),\n      ]);\n\n      // Create lookup maps for counts\n      const memberCountMap = new Map(\n        memberCounts.map((mc) => [mc.groupId, mc._count.id]),\n      );\n      const viewCountMap = new Map(\n        viewCounts.map((vc) => [vc.groupId, vc._count.id]),\n      );\n\n      // Combine viewer groups with their counts\n      const viewerGroupsWithCounts = viewerGroups.map((group) => ({\n        ...group,\n        _count: {\n          members: memberCountMap.get(group.id) || 0,\n          views: viewCountMap.get(group.id) || 0,\n        },\n      }));\n\n      return res.status(200).json(viewerGroupsWithCounts);\n    } catch (error) {\n      log({\n        message: `Failed to get groups for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/groups\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const { name } = req.body as { name: string };\n\n    try {\n      // Check if the user is part of the team\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error: \"Team is currently paused. Creating groups is not available.\",\n        });\n      }\n\n      const group = await prisma.viewerGroup.create({\n        data: {\n          name: name,\n          dataroomId,\n          teamId,\n        },\n      });\n\n      res.status(201).json(group);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ error: \"Error creating folder\" });\n    }\n  } else {\n    // We only allow GET, POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { DefaultPermissionStrategy } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n      if (!teamAccess) {  \n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId,\n        },\n        include: {\n          _count: { select: { viewerGroups: true, permissionGroups: true } },\n          tags: {\n            include: {\n              tag: {\n                select: {\n                  id: true,\n                  name: true,\n                  color: true,\n                  description: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({\n          error: \"Not Found\",\n          message: \"The requested dataroom does not exist\",\n        });\n      }\n\n      return res.status(200).json(dataroom);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/datarooms/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const {\n        name,\n        internalName,\n        enableChangeNotifications,\n        defaultPermissionStrategy,\n        allowBulkDownload,\n        showLastUpdated,\n        tags,\n        agentsEnabled,\n        introductionEnabled,\n        introductionContent,\n      } = req.body as {\n        name?: string;\n        internalName?: string | null;\n        enableChangeNotifications?: boolean;\n        defaultPermissionStrategy?: DefaultPermissionStrategy;\n        allowBulkDownload?: boolean;\n        showLastUpdated?: boolean;\n        tags?: string[];\n        agentsEnabled?: boolean;\n        introductionEnabled?: boolean;\n        introductionContent?: any;\n      };\n\n      const featureFlags = await getFeatureFlags({ teamId: team.id });\n      const isDataroomsPlus = team.plan.includes(\"datarooms-plus\") || team.plan.includes(\"datarooms-premium\");\n      const isTrial = team.plan.includes(\"drtrial\");\n\n      if (\n        enableChangeNotifications !== undefined &&\n        !isDataroomsPlus &&\n        !isTrial &&\n        !featureFlags.roomChangeNotifications\n      ) {\n        return res.status(403).json({\n          message: \"This feature is not available in your plan\",\n        });\n      }\n\n      if (agentsEnabled !== undefined && !featureFlags.ai) {\n        return res.status(403).json({\n          message: \"This feature is not available in your plan\",\n        });\n      }\n\n      const updatedDataroom = await prisma.$transaction(async (tx) => {\n        const dataroom = await tx.dataroom.update({\n          where: {\n            id: dataroomId,\n          },\n          data: {\n            ...(name && { name }),\n            ...(internalName !== undefined && { \n              internalName: internalName === null || internalName === \"\" ? null : internalName.trim() \n            }),\n            ...(typeof enableChangeNotifications === \"boolean\" && {\n              enableChangeNotifications,\n            }),\n            ...(defaultPermissionStrategy && { defaultPermissionStrategy }),\n            ...(typeof allowBulkDownload === \"boolean\" && {\n              allowBulkDownload,\n            }),\n            ...(typeof showLastUpdated === \"boolean\" && {\n              showLastUpdated,\n            }),\n            ...(typeof agentsEnabled === \"boolean\" && {\n              agentsEnabled,\n            }),\n            ...(typeof introductionEnabled === \"boolean\" && {\n              introductionEnabled,\n            }),\n            ...(introductionContent !== undefined && {\n              introductionContent,\n            }),\n          },\n        });\n\n        // Handle tags if provided\n        if (tags !== undefined) {\n          // Validate that all tags exist and belong to the same team\n          if (tags.length > 0) {\n            const validTags = await tx.tag.findMany({\n              where: {\n                id: { in: tags },\n                teamId: teamId,\n              },\n              select: { id: true },\n            });\n            const validTagIds = new Set(validTags.map((t) => t.id));\n            const invalidTags = tags.filter((id) => !validTagIds.has(id));\n            if (invalidTags.length > 0) {\n              throw new Error(`Invalid tag IDs: ${invalidTags.join(\", \")}`);\n            }\n          }\n\n          // First, delete all existing tags for this dataroom\n          await tx.tagItem.deleteMany({\n            where: {\n              dataroomId: dataroomId,\n              itemType: \"DATAROOM_TAG\",\n            },\n          });\n\n          // Then create the new tags (if any)\n          if (tags.length > 0) {\n            await tx.tagItem.createMany({\n              data: tags.map((tagId: string) => ({\n                tagId,\n                itemType: \"DATAROOM_TAG\",\n                dataroomId: dataroomId,\n                taggedBy: userId,\n              })),\n            });\n          }\n        }\n\n        // Fetch the updated dataroom with tags\n        const dataroomTags = await tx.tag.findMany({\n          where: {\n            items: {\n              some: { dataroomId: dataroom.id },\n            },\n          },\n          select: {\n            id: true,\n            name: true,\n            color: true,\n            description: true,\n          },\n        });\n\n        return { ...dataroom, tags: dataroomTags };\n      });\n\n      return res.status(200).json(updatedDataroom);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/datarooms/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (teamAccess.role !== \"ADMIN\" && teamAccess.role !== \"MANAGER\") {\n        return res.status(403).json({\n          message:\n            \"You are not permitted to perform this action. Only admin and managers can delete datarooms.\",\n        });\n      }\n\n      await prisma.dataroom.delete({\n        where: {\n          id: dataroomId,\n          teamId,\n        },\n      });\n\n      return res.status(204).end();\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET, and PATCH requests\n    res.setHeader(\"Allow\", [\"GET\", \"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/links/[linkId]/invite.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport handleRoute from \"@/ee/features/dataroom-invitations/api/link-invite\";\n\nexport const config = {\n  maxDuration: 300,\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  return handleRoute(req, res);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/links.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser, LinkWithViews } from \"@/lib/types\";\nimport { decryptEncrpytedPassword, log } from \"@/lib/utils\";\n\nimport { authOptions } from \"../../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/links\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const links = await prisma.link.findMany({\n        where: {\n          dataroomId,\n          linkType: \"DATAROOM_LINK\",\n          teamId: teamId,\n          deletedAt: null, // exclude deleted links\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n        include: {\n          views: {\n            where: {\n              viewType: \"DATAROOM_VIEW\",\n            },\n            orderBy: {\n              viewedAt: \"desc\",\n            },\n            take: 1,\n          },\n          customFields: true,\n          visitorGroups: {\n            select: { visitorGroupId: true },\n          },\n          _count: {\n            select: { views: { where: { viewType: \"DATAROOM_VIEW\" } } },\n          },\n        },\n      });\n\n      let extendedLinks: LinkWithViews[] = links as LinkWithViews[];\n      // Decrypt the password for each link\n      if (extendedLinks && extendedLinks.length > 0) {\n        extendedLinks = await Promise.all(\n          extendedLinks.map(async (link) => {\n            // Decrypt the password if it exists\n            if (link.password !== null) {\n              link.password = decryptEncrpytedPassword(link.password);\n            }\n            if (link.enableUpload && link.uploadFolderId !== null) {\n              const folder = await prisma.dataroomFolder.findUnique({\n                where: {\n                  id: link.uploadFolderId,\n                },\n                select: {\n                  name: true,\n                },\n              });\n              link.uploadFolderName = folder?.name;\n            }\n            const tags = await prisma.tag.findMany({\n              where: {\n                items: {\n                  some: {\n                    linkId: link.id,\n                    itemType: \"LINK_TAG\",\n                  },\n                },\n              },\n              select: {\n                id: true,\n                name: true,\n                color: true,\n                description: true,\n              },\n            });\n\n            return {\n              ...link,\n              tags,\n            };\n          }),\n        );\n      }\n\n      // console.log(\"links\", links);\n      return res.status(200).json(extendedLinks);\n    } catch (error) {\n      log({\n        message: `Failed to get links for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/permission-groups/[permissionGroupId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { ItemType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// Zod schema for validating permissions\nconst itemPermissionSchema = z.object({\n  view: z.boolean(),\n  download: z.boolean(),\n  itemType: z.nativeEnum(ItemType),\n});\n\nconst permissionsSchema = z.record(z.string(), itemPermissionSchema);\n\nconst patchPermissionGroupSchema = z.object({\n  name: z.string().optional(),\n  description: z.string().optional(),\n});\n\n// Helper function to get parent folder IDs for a given document in a dataroom\nasync function getParentFolderIds(\n  dataroomId: string,\n  documentId: string,\n): Promise<string[]> {\n  // Get the dataroom document to find which folder it's in\n  const dataroomDocument = await prisma.dataroomDocument.findUnique({\n    where: { id: documentId },\n    select: { folderId: true },\n  });\n\n  if (!dataroomDocument?.folderId) {\n    return []; // Document is at root level\n  }\n\n  // Get the folder and walk up the hierarchy\n  const parentFolders: string[] = [];\n  let currentFolderId: string | null = dataroomDocument.folderId;\n\n  while (currentFolderId) {\n    parentFolders.push(currentFolderId);\n\n    const folder: { parentId: string | null } | null =\n      await prisma.dataroomFolder.findUnique({\n        where: { id: currentFolderId },\n        select: { parentId: true },\n      });\n\n    currentFolderId = folder?.parentId || null;\n  }\n\n  return parentFolders;\n}\n\n// Helper function to ensure parent folders are visible when child documents are made visible\nasync function ensureParentFoldersVisible(\n  dataroomId: string,\n  groupId: string,\n  permissions: Record<\n    string,\n    { itemType: ItemType; view: boolean; download: boolean }\n  >,\n): Promise<void> {\n  const foldersToMakeVisible = new Set<string>();\n\n  // Find all documents that are being made visible\n  const visibleDocuments = Object.entries(permissions)\n    .filter(\n      ([_, perm]) => perm.itemType === ItemType.DATAROOM_DOCUMENT && perm.view,\n    )\n    .map(([itemId, _]) => itemId);\n\n  // Get parent folder IDs for all visible documents\n  for (const documentId of visibleDocuments) {\n    const parentFolders = await getParentFolderIds(dataroomId, documentId);\n    parentFolders.forEach((folderId) => foldersToMakeVisible.add(folderId));\n  }\n\n  // Also handle folders that are being made visible - ensure their parent folders are visible too\n  const visibleFolders = Object.entries(permissions)\n    .filter(\n      ([_, perm]) => perm.itemType === ItemType.DATAROOM_FOLDER && perm.view,\n    )\n    .map(([itemId, _]) => itemId);\n\n  for (const folderId of visibleFolders) {\n    let currentFolderId: string | null = folderId;\n\n    while (currentFolderId) {\n      const folder: { parentId: string | null } | null =\n        await prisma.dataroomFolder.findUnique({\n          where: { id: currentFolderId },\n          select: { parentId: true },\n        });\n\n      if (folder?.parentId) {\n        foldersToMakeVisible.add(folder.parentId);\n        currentFolderId = folder.parentId;\n      } else {\n        break;\n      }\n    }\n  }\n\n  // Update or create permissions for parent folders to make them visible\n  if (foldersToMakeVisible.size > 0) {\n    const foldersToUpdate = Array.from(foldersToMakeVisible);\n\n    await prisma.$transaction(async (tx) => {\n      for (const folderId of foldersToUpdate) {\n        await tx.permissionGroupAccessControls.upsert({\n          where: {\n            groupId_itemId: {\n              groupId: groupId,\n              itemId: folderId,\n            },\n          },\n          create: {\n            groupId: groupId,\n            itemId: folderId,\n            itemType: ItemType.DATAROOM_FOLDER,\n            canView: true,\n            canDownload: false,\n            canDownloadOriginal: false,\n          },\n          update: {\n            canView: true, // Always ensure parent folders are visible\n            // Don't change canDownload - preserve existing setting\n          },\n        });\n      }\n    });\n  }\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/permission-groups/:permissionGroupId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      permissionGroupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      permissionGroupId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team membership\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: { userId },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Verify dataroom exists and belongs to team\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Fetch permission group and its access controls\n      const permissionGroup = await prisma.permissionGroup.findUnique({\n        where: {\n          id: permissionGroupId,\n          dataroomId: dataroomId,\n          teamId: teamId,\n        },\n        include: {\n          accessControls: true,\n        },\n      });\n\n      if (!permissionGroup) {\n        return res.status(404).json({ error: \"Permission group not found\" });\n      }\n\n      return res.status(200).json({ permissionGroup });\n    } catch (error) {\n      return errorhandler(error, res);\n    }\n  } else if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/datarooms/:id/permission-groups/:permissionGroupId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      permissionGroupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      permissionGroupId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team membership\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: { userId },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Verify dataroom exists and belongs to team\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Verify permission group exists and belongs to dataroom\n      const permissionGroup = await prisma.permissionGroup.findUnique({\n        where: {\n          id: permissionGroupId,\n          dataroomId: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!permissionGroup) {\n        return res.status(404).json({ error: \"Permission group not found\" });\n      }\n\n      // Validate and update permission group\n      const validationResult = patchPermissionGroupSchema.safeParse(req.body);\n      if (!validationResult.success) {\n        return res.status(400).json({\n          error: \"Invalid request body\",\n          details: validationResult.error.issues,\n        });\n      }\n\n      const { name, description } = validationResult.data;\n\n      const updatedPermissionGroup = await prisma.permissionGroup.update({\n        where: {\n          id: permissionGroupId,\n        },\n        data: {\n          ...(name && { name }),\n          ...(description !== undefined && { description }),\n        },\n      });\n\n      return res.status(200).json({ permissionGroup: updatedPermissionGroup });\n    } catch (error) {\n      return errorhandler(error, res);\n    }\n  } else if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/datarooms/:id/permission-groups/:permissionGroupId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      permissionGroupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      permissionGroupId: string;\n    };\n\n    const { permissions } = req.body;\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team membership\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: { userId },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Verify dataroom exists and belongs to team\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Verify permission group exists and belongs to dataroom\n      const permissionGroup = await prisma.permissionGroup.findUnique({\n        where: {\n          id: permissionGroupId,\n          dataroomId: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!permissionGroup) {\n        return res.status(404).json({ error: \"Permission group not found\" });\n      }\n\n      // Validate permissions payload using Zod\n      if (!permissions) {\n        return res.status(400).json({ error: \"Permissions are required\" });\n      }\n\n      // Validate schema structure\n      const validationResult = permissionsSchema.safeParse(permissions);\n      if (!validationResult.success) {\n        return res.status(400).json({\n          error: \"Invalid permissions format\",\n          details: validationResult.error.issues,\n        });\n      }\n\n      const validatedPermissions = validationResult.data;\n\n      const updatedPermissionGroup = await prisma.$transaction(async (tx) => {\n        // Get existing permissions for comparison\n        const existingPermissions =\n          await tx.permissionGroupAccessControls.findMany({\n            where: {\n              groupId: permissionGroupId,\n            },\n          });\n\n        // Create maps for efficient lookup\n        const existingMap = new Map(\n          existingPermissions.map((p) => [`${p.itemId}-${p.itemType}`, p]),\n        );\n\n        // Categorize permissions into updates vs creates\n        const toUpdate: Array<{\n          itemId: string;\n          itemType: ItemType;\n          canView: boolean;\n          canDownload: boolean;\n          canDownloadOriginal: boolean;\n        }> = [];\n\n        const toCreate: Array<{\n          groupId: string;\n          itemId: string;\n          itemType: ItemType;\n          canView: boolean;\n          canDownload: boolean;\n          canDownloadOriginal: boolean;\n        }> = [];\n\n        // Categorize permissions into updates vs creates\n        Object.entries(validatedPermissions).forEach(([itemId, permission]) => {\n          const key = `${itemId}-${permission.itemType}`;\n          const existing = existingMap.get(key);\n\n          const permissionData = {\n            itemId,\n            itemType: permission.itemType,\n            canView: permission.view,\n            canDownload: permission.download,\n            canDownloadOriginal: false,\n          };\n\n          if (existing) {\n            // Check if anything actually changed\n            if (\n              existing.canView !== permission.view ||\n              existing.canDownload !== permission.download\n            ) {\n              toUpdate.push(permissionData);\n            }\n          } else {\n            toCreate.push({\n              groupId: permissionGroupId,\n              ...permissionData,\n            });\n          }\n        });\n\n        // Perform updates\n        if (toUpdate.length > 0) {\n          await Promise.all(\n            toUpdate.map((item) =>\n              tx.permissionGroupAccessControls.updateMany({\n                where: {\n                  groupId: permissionGroupId,\n                  itemId: item.itemId,\n                  itemType: item.itemType,\n                },\n                data: {\n                  canView: item.canView,\n                  canDownload: item.canDownload,\n                  canDownloadOriginal: item.canDownloadOriginal,\n                },\n              }),\n            ),\n          );\n        }\n\n        // Perform creates\n        if (toCreate.length > 0) {\n          await tx.permissionGroupAccessControls.createMany({\n            data: toCreate,\n          });\n        }\n\n        // Remove permissions that are no longer in the new set\n        const newItemKeys = new Set(\n          Object.entries(validatedPermissions).map(\n            ([itemId, permission]) => `${itemId}-${permission.itemType}`,\n          ),\n        );\n\n        const toDelete = existingPermissions.filter(\n          (p) => !newItemKeys.has(`${p.itemId}-${p.itemType}`),\n        );\n\n        if (toDelete.length > 0) {\n          await tx.permissionGroupAccessControls.deleteMany({\n            where: {\n              id: {\n                in: toDelete.map((p) => p.id),\n              },\n            },\n          });\n        }\n\n        return permissionGroup;\n      });\n\n      // After saving permissions, ensure parent folders are visible for any items that were made visible\n      await ensureParentFoldersVisible(\n        dataroomId,\n        permissionGroupId,\n        validatedPermissions,\n      );\n\n      return res.status(200).json({ permissionGroup: updatedPermissionGroup });\n    } catch (error) {\n      return errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/datarooms/:id/permission-groups/:permissionGroupId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      permissionGroupId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      permissionGroupId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team membership\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: { userId },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Verify dataroom exists and belongs to team\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Verify permission group exists and belongs to dataroom\n      const permissionGroup = await prisma.permissionGroup.findUnique({\n        where: {\n          id: permissionGroupId,\n          dataroomId: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!permissionGroup) {\n        return res.status(404).json({ error: \"Permission group not found\" });\n      }\n\n      // Delete the permission group (this will cascade delete access controls)\n      await prisma.permissionGroup.delete({\n        where: {\n          id: permissionGroupId,\n        },\n      });\n\n      return res.status(200).json({ message: \"Permission group deleted\" });\n    } catch (error) {\n      return errorhandler(error, res);\n    }\n  }\n\n  // We only allow GET, PATCH, PUT, and DELETE requests\n  res.setHeader(\"Allow\", [\"GET\", \"PATCH\", \"PUT\", \"DELETE\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/permission-groups/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { ItemType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/permission-groups\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // First, get permission groups without expensive nested data\n      const permissionGroups = await prisma.permissionGroup.findMany({\n        where: {\n          dataroomId: dataroomId,\n          teamId: teamId,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      // Then, get nested data efficiently with separate queries\n      const groupIds = permissionGroups.map((g) => g.id);\n\n      const [accessControls, links] = await Promise.all([\n        prisma.permissionGroupAccessControls.findMany({\n          where: {\n            groupId: { in: groupIds },\n          },\n        }),\n        prisma.link.findMany({\n          where: {\n            permissionGroupId: { in: groupIds },\n            deletedAt: null,\n          },\n          select: {\n            id: true,\n            name: true,\n            permissionGroupId: true,\n          },\n        }),\n      ]);\n\n      // Create lookup maps for nested data\n      const accessControlsMap = new Map<string, any[]>();\n      const linksMap = new Map<string, any[]>();\n\n      // Group access controls by groupId\n      accessControls.forEach((ac) => {\n        if (!accessControlsMap.has(ac.groupId)) {\n          accessControlsMap.set(ac.groupId, []);\n        }\n        accessControlsMap.get(ac.groupId)!.push(ac);\n      });\n\n      // Group links by permissionGroupId\n      links.forEach((link) => {\n        if (link.permissionGroupId && !linksMap.has(link.permissionGroupId)) {\n          linksMap.set(link.permissionGroupId, []);\n        }\n        if (link.permissionGroupId) {\n          linksMap.get(link.permissionGroupId)!.push({\n            id: link.id,\n            name: link.name,\n          });\n        }\n      });\n\n      // Combine permission groups with their nested data\n      const permissionGroupsWithData = permissionGroups.map((group) => ({\n        ...group,\n        accessControls: accessControlsMap.get(group.id) || [],\n        links: linksMap.get(group.id) || [],\n        _count: {\n          accessControls: (accessControlsMap.get(group.id) || []).length,\n          links: (linksMap.get(group.id) || []).length,\n        },\n      }));\n\n      return res.status(200).json(permissionGroupsWithData);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/permission-groups\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const { permissions, linkId } = req.body;\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team membership\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: { userId },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Verify dataroom exists and belongs to team\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Create permission group and access controls in a transaction\n      const result = await prisma.$transaction(async (tx) => {\n        // Create the permission group\n        const permissionGroup = await tx.permissionGroup.create({\n          data: {\n            name: `Link Permissions ${Date.now()}`,\n            description: \"Auto-generated permission group for link\",\n            dataroomId: dataroomId,\n            teamId: teamId,\n          },\n        });\n\n        // Prepare access control data for batch insert\n        const accessControlData = Object.entries(permissions).map(\n          ([itemId, permission]) => {\n            const perm = permission as {\n              view: boolean;\n              download: boolean;\n              itemType: ItemType;\n            };\n            return {\n              groupId: permissionGroup.id,\n              itemId: itemId,\n              itemType: perm.itemType,\n              canView: perm.view,\n              canDownload: perm.download,\n              canDownloadOriginal: false,\n            };\n          },\n        );\n\n        // Create all access controls in a single batch operation\n        await tx.permissionGroupAccessControls.createMany({\n          data: accessControlData,\n        });\n\n        // Fetch the created access controls for return data\n        const accessControls = await tx.permissionGroupAccessControls.findMany({\n          where: {\n            groupId: permissionGroup.id,\n          },\n        });\n\n        // Update the link with the permission group\n        if (linkId) {\n          await tx.link.update({\n            where: { id: linkId, teamId: teamId },\n            data: {\n              permissionGroupId: permissionGroup.id,\n            },\n          });\n        }\n\n        return {\n          permissionGroup,\n          accessControls,\n        };\n      });\n\n      return res.status(200).json(result);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n\n  // We only allow GET and POST requests\n  res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/reorder.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\n\ntype OrderItem = {\n  id: string;\n  category: \"folder\" | \"document\";\n  orderIndex: number;\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { id: dataroomId } = req.query as { teamId: string; id: string };\n  const newOrder: OrderItem[] = req.body;\n\n  if (\n    !Array.isArray(newOrder) ||\n    !dataroomId ||\n    typeof dataroomId !== \"string\"\n  ) {\n    return res.status(400).json({ message: \"Invalid input\" });\n  }\n\n  try {\n    await prisma.$transaction(async (prisma) => {\n      for (const item of newOrder) {\n        if (item.category === \"folder\") {\n          await prisma.dataroomFolder.update({\n            where: { id: item.id },\n            data: { orderIndex: item.orderIndex },\n          });\n        } else {\n          await prisma.dataroomDocument.update({\n            where: { id: item.id },\n            data: { orderIndex: item.orderIndex },\n          });\n        }\n      }\n    });\n\n    res.status(200).json({ message: \"Order updated successfully\" });\n  } catch (error) {\n    console.error(\"Error updating order:\", error);\n    res.status(500).json({ message: \"Error updating order\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/stats.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { View } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { getTotalDataroomDuration } from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../../../auth/[...nextauth]\";\n\nexport const config = {\n  maxDuration: 120,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id/stats\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      excludeTeamMembers,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      excludeTeamMembers?: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n        },\n        include: {\n          views: true,\n        },\n      });\n\n      const users = await prisma.user.findMany({\n        where: {\n          teams: {\n            some: {\n              teamId: teamId,\n            },\n          },\n        },\n        select: {\n          email: true,\n        },\n      });\n\n      const views = dataroom?.views;\n\n      // if there are no views, return an empty array\n      if (!views) {\n        return res.status(200).json({\n          views: [],\n          duration: { data: [] },\n          total_duration: 0,\n          avgCompletionRate: 0,\n        });\n      }\n\n      const dataroomViews = views.filter(\n        (view) => view.viewType === \"DATAROOM_VIEW\",\n      );\n      const documentViews = views.filter(\n        (view) => view.viewType === \"DOCUMENT_VIEW\",\n      );\n\n      // exclude views from the team's members\n      let excludedViews: View[] = [];\n      if (excludeTeamMembers) {\n        excludedViews = documentViews.filter((view) => {\n          return users.some((user) => user.email === view.viewerEmail);\n        });\n      }\n\n      const filteredViews = documentViews.filter(\n        (view) => !excludedViews.map((view) => view.id).includes(view.id),\n      );\n\n      const duration = await getTotalDataroomDuration({\n        dataroomId: dataroomId,\n        excludedLinkIds: [],\n        excludedViewIds: excludedViews.map((view) => view.id),\n        since: 0,\n      });\n\n      const total_duration = duration.data.reduce(\n        (totalDuration, data) => totalDuration + data.sum_duration,\n        0,\n      );\n\n      const stats = {\n        dataroomViews: dataroomViews,\n        documentViews: documentViews,\n        duration: duration.data,\n        total_duration,\n      };\n\n      return res.status(200).json(stats);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/users/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { sendViewerInvitation } from \"@/lib/api/notification-helper\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/:id/users\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauhorized\");\n    }\n\n    // INFO: This endpoint is not available anymore\n    return res.status(404).json(\"Not available\");\n\n    // const { teamId, id: dataroomId } = req.query as {\n    //   teamId: string;\n    //   id: string;\n    // };\n\n    // const { emails } = req.body as { emails: string[] };\n\n    // if (!emails) {\n    //   return res.status(400).json(\"Email is missing in request body\");\n    // }\n\n    // if (emails.length > 5) {\n    //   return res\n    //     .status(400)\n    //     .json(\"You can only send invitations to 5 emails at a time.\");\n    // }\n\n    // try {\n    //   const team = await prisma.team.findUnique({\n    //     where: {\n    //       id: teamId,\n    //       plan: {\n    //         notIn: [\"free\", \"free+drtrial\"],\n    //       },\n    //       users: {\n    //         some: {\n    //           userId: (session.user as CustomUser).id,\n    //         },\n    //       },\n    //     },\n    //     select: {\n    //       id: true,\n    //     },\n    //   });\n\n    //   if (!team) {\n    //     return res.status(403).end(\"Unauthorized to access this team\");\n    //   }\n\n    //   const dataroom = await prisma.dataroom.findUnique({\n    //     where: {\n    //       id: dataroomId,\n    //       teamId: teamId,\n    //     },\n    //     include: {\n    //       viewers: true,\n    //     },\n    //   });\n\n    //   if (!dataroom) {\n    //     return res.status(404).end(\"Dataroom not found\");\n    //   }\n\n    //   await prisma.viewer.createMany({\n    //     data: emails.map((email) => ({\n    //       email,\n    //       dataroomId,\n    //       teamId,\n    //       invitedAt: new Date(),\n    //     })),\n    //     skipDuplicates: true,\n    //   });\n\n    //   const viewers = await prisma.viewer.findMany({\n    //     where: {\n    //       dataroomId,\n    //       email: {\n    //         in: emails,\n    //       },\n    //     },\n    //     select: {\n    //       id: true,\n    //       email: true,\n    //     },\n    //   });\n\n    //   // create a new link for the invited group\n    //   const link = await prisma.link.create({\n    //     data: {\n    //       dataroomId,\n    //       linkType: \"DATAROOM_LINK\",\n    //       name: `Invited ${new Date().toLocaleString()}`,\n    //       enableFeedback: false,\n    //       teamId,\n    //     },\n    //     select: {\n    //       id: true,\n    //     },\n    //   });\n\n    //   console.time(\"sendemail\");\n    //   await sendViewerInvitation({\n    //     dataroomId,\n    //     linkId: link.id,\n    //     viewerIds: viewers.map((v) => v.id),\n    //     senderUserId: (session.user as CustomUser).id,\n    //   });\n    //   console.timeEnd(\"sendemail\");\n\n    //   return res.status(200).json(\"Invitation sent!\");\n    // } catch (error) {\n    //   errorhandler(error, res);\n    // }\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/viewers/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/viewers\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: { userId },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const viewers = await prisma.viewer.findMany({\n        where: {\n          teamId: teamId,\n          views: {\n            some: {\n              dataroomId: dataroomId,\n              viewType: \"DATAROOM_VIEW\",\n            },\n          },\n        },\n        select: {\n          id: true,\n          teamId: true,\n          email: true,\n          verified: true,\n          views: {\n            where: {\n              dataroomId: dataroomId,\n              viewType: \"DATAROOM_VIEW\",\n            },\n            orderBy: {\n              viewedAt: \"desc\",\n            },\n            select: {\n              id: true,\n              viewedAt: true,\n              downloadedAt: true,\n              viewerName: true,\n            },\n          },\n        },\n      });\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n        select: {\n          name: true,\n        },\n      });\n\n      const users = await prisma.user.findMany({\n        where: {\n          teams: {\n            some: {\n              teamId: teamId,\n            },\n          },\n        },\n        select: {\n          email: true,\n        },\n      });\n\n      const returnViews = viewers.map((viewer) => {\n        // Get the name from the most recent view that has a name\n        const viewerName = viewer.views.find((v) => v.viewerName)?.viewerName;\n        \n        return {\n          ...viewer,\n          dataroomName: dataroom?.name,\n          lastViewedAt:\n            viewer.views.length > 0 ? viewer.views[0].viewedAt : null,\n          viewerName: viewerName || null,\n          internal: users.some((user) => user.email === viewer.email), // set internal to true if view.viewerEmail is in the users list\n        };\n      });\n\n      return res.status(200).json(returnViews);\n    } catch (error) {\n      log({\n        message: `Failed to get viewers for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/views/[viewId]/custom-fields.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/views/:viewId/custom-fields\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (team.plan.includes(\"free\")) {\n        return res.status(403).end(\"Forbidden\");\n      }\n\n      const customFields = await prisma.customFieldResponse.findFirst({\n        where: {\n          viewId: viewId,\n          view: {\n            dataroomId: dataroomId,\n          },\n        },\n        select: {\n          data: true,\n        },\n      });\n\n      const data = customFields?.data;\n\n      return res.status(200).json(data);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/views/[viewId]/document-stats.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { getDataroomViewDocumentStats, getViewPageDuration } from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n\n    const documentViewId = req.query.documentViewId as string | undefined;\n    const documentId = req.query.documentId as string | undefined;\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: { id: true },\n      });\n\n      if (!team) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: { id: dataroomId, teamId },\n        select: { id: true },\n      });\n\n      if (!dataroom) {\n        return res.status(403).end(\"Unauthorized to access this dataroom\");\n      }\n\n      const view = await prisma.view.findUnique({\n        where: { id: viewId, dataroomId },\n        select: { id: true },\n      });\n\n      if (!view) {\n        return res.status(403).end(\"Unauthorized to access this view\");\n      }\n\n      // If documentViewId and documentId are provided, return per-page stats\n      if (documentViewId && documentId) {\n        const documentView = await prisma.view.findUnique({\n          where: {\n            id: documentViewId,\n            dataroomId,\n            viewType: \"DOCUMENT_VIEW\",\n          },\n          select: { id: true },\n        });\n\n        if (!documentView) {\n          return res\n            .status(403)\n            .end(\"Unauthorized to access this document view\");\n        }\n\n        const duration = await getViewPageDuration({\n          documentId,\n          viewId: documentViewId,\n          since: 0,\n        });\n\n        return res.status(200).json({ duration });\n      }\n\n      // Otherwise return summary stats for all document views under this dataroom view\n      const documentViews = await prisma.view.findMany({\n        where: {\n          dataroomViewId: viewId,\n          dataroomId,\n          viewType: \"DOCUMENT_VIEW\",\n        },\n        select: {\n          id: true,\n          documentId: true,\n          document: {\n            select: {\n              id: true,\n              name: true,\n              versions: {\n                where: { isPrimary: true },\n                take: 1,\n                select: { numPages: true },\n              },\n            },\n          },\n        },\n      });\n\n      if (!documentViews.length) {\n        return res.status(200).json({ documentStats: [] });\n      }\n\n      const viewIds = documentViews.map((v) => v.id).join(\",\");\n\n      const tinybirdStats = await getDataroomViewDocumentStats({ viewIds });\n\n      const statsMap = new Map(\n        tinybirdStats.data.map((s) => [`${s.viewId}:${s.documentId}`, s]),\n      );\n\n      const documentStats = documentViews.map((dv) => {\n        const stats = statsMap.get(`${dv.id}:${dv.documentId}`);\n        const totalPages = dv.document?.versions?.[0]?.numPages ?? 0;\n        const pagesViewed = stats?.pages_viewed ?? 0;\n        const completionRate =\n          totalPages > 0 ? Math.round((pagesViewed / totalPages) * 100) : 0;\n\n        return {\n          viewId: dv.id,\n          documentId: dv.documentId,\n          totalDuration: stats?.sum_duration ?? 0,\n          pagesViewed,\n          totalPages,\n          completionRate,\n        };\n      });\n\n      return res.status(200).json({ documentStats });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/views/[viewId]/history.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/views/:viewId/history\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: (session.user as CustomUser).id,\n            },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const [documentViews, uploadedDocumentViews] = await Promise.all([\n        prisma.view.findMany({\n          where: {\n            dataroomViewId: viewId,\n            dataroomId: dataroomId,\n            viewType: \"DOCUMENT_VIEW\",\n          },\n          orderBy: {\n            viewedAt: \"asc\",\n          },\n          select: {\n            id: true,\n            viewedAt: true,\n            downloadedAt: true,\n            downloadType: true,\n            downloadMetadata: true,\n            document: {\n              select: {\n                id: true,\n                name: true,\n              },\n            },\n          },\n        }),\n        prisma.documentUpload.findMany({\n          where: {\n            viewId: viewId,\n          },\n          select: {\n            uploadedAt: true,\n            documentId: true,\n            originalFilename: true,\n          },\n        }),\n      ]);\n\n      return res.status(200).json({ documentViews, uploadedDocumentViews });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/views/[viewId]/user-agent.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { getViewUserAgent } from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/views/:viewId/user-agent\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: dataroomId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (team.plan.includes(\"free\")) {\n        return res.status(403).end(\"Forbidden\");\n      }\n      const userAgent = await getViewUserAgent({\n        viewId: viewId,\n      });\n\n      const userAgentData = userAgent.data[0];\n\n      if (!userAgentData) {\n        return res.status(404).end(\"No user agent data found\");\n      }\n\n      // Include country and city for business and datarooms plans\n      if (team.plan.includes(\"business\") || team.plan.includes(\"datarooms\")) {\n        return res.status(200).json(userAgentData);\n      } else {\n        // For other plans, exclude country and city\n        const { country, city, ...remainingResponse } = userAgentData;\n        return res.status(200).json(remainingResponse);\n      }\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/views/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms/:id/views\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: dataroomId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: (session.user as CustomUser).id,\n            },\n          },\n        },\n        select: {\n          id: true,\n          pauseStartsAt: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      const dataroom = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroomId,\n          teamId: teamId,\n        },\n        select: {\n          id: true,\n          teamId: true,\n          name: true,\n          views: {\n            where: {\n              viewType: \"DATAROOM_VIEW\",\n              ...(team.pauseStartsAt && {\n                viewedAt: {\n                  lt: team.pauseStartsAt,\n                },\n              }),\n            },\n            orderBy: {\n              viewedAt: \"desc\",\n            },\n            include: {\n              link: {\n                select: {\n                  name: true,\n                },\n              },\n              agreementResponse: {\n                select: {\n                  id: true,\n                  agreementId: true,\n                  agreement: {\n                    select: {\n                      name: true,\n                    },\n                  },\n                },\n              },\n            },\n          },\n        },\n      });\n\n      const users = await prisma.user.findMany({\n        where: {\n          teams: {\n            some: {\n              teamId: teamId,\n            },\n          },\n        },\n        select: {\n          email: true,\n        },\n      });\n\n      // Calculate hidden views due to pause (views after pause date)\n      const hiddenViewsFromPause = team.pauseStartsAt\n        ? await prisma.view.count({\n            where: {\n              dataroomId: dataroomId,\n              viewType: \"DATAROOM_VIEW\",\n              viewedAt: {\n                gte: team.pauseStartsAt,\n              },\n            },\n          })\n        : 0;\n\n      const views = dataroom?.views || [];\n\n      const returnViews = views.map((view) => {\n        return {\n          ...view,\n          dataroomName: dataroom?.name,\n          internal: users.some((user) => user.email === view.viewerEmail), // set internal to true if view.viewerEmail is in the users list\n        };\n      });\n\n      return res.status(200).json({\n        views: returnViews,\n        hiddenFromPause: hiddenViewsFromPause,\n      });\n    } catch (error) {\n      log({\n        message: `Failed to get views for dataroom: _${dataroomId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/[id]/views-count.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const groupId = req.query.groupId as string | undefined;\n  const userId = (session.user as CustomUser).id;\n\n  if (req.method === \"GET\") {\n    try {\n      // Verify user has access to the dataroom\n      const dataroom = await prisma.dataroom.findFirst({\n        where: {\n          id: id,\n          teamId: teamId,\n          team: {\n            users: {\n              some: {\n                userId: userId,\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({ error: \"Dataroom not found\" });\n      }\n\n      // Build the where clause for views\n      const whereClause = {\n        dataroomId: id,\n        ...(groupId && { groupId }),\n      };\n\n      const viewCount = await prisma.view.count({\n        where: whereClause,\n      });\n\n      return res.status(200).json({ count: viewCount });\n    } catch (error) {\n      console.error(\"Error fetching view count:\", error);\n      return res.status(500).json({ error: \"Failed to fetch view count\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"GET\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/create-from-folder.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getLimits } from \"@/ee/limits/server\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { DataroomFolder, Document, Folder } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// Define types\ninterface FolderWithContents extends Folder {\n  documents: Omit<Document, \"folderId\">[];\n  childFolders: Omit<FolderWithContents, \"parentId\">[];\n}\n\n// Recursive function to fetch all folders, child folders, and documents\nasync function fetchFolderContents(\n  folderId: string,\n): Promise<FolderWithContents> {\n  const folder = await prisma.folder.findUnique({\n    where: {\n      id: folderId,\n    },\n    include: {\n      documents: true,\n      childFolders: true,\n    },\n  });\n\n  if (!folder) {\n    throw new Error(`Folder with id ${folderId} not found`);\n  }\n\n  const childFolders = await Promise.all(\n    folder.childFolders.map(async (childFolder) => {\n      const nestedChildFolders = await fetchFolderContents(childFolder.id);\n      return nestedChildFolders;\n    }),\n  );\n\n  // Remove parentId from top-level child folders and folderId from top-level documents\n  const modifiedDocuments = folder.documents.map((doc) => {\n    return {\n      ...doc,\n      folderId: null,\n    };\n  });\n\n  const modifiedChildFolders = childFolders.map((childFolder) => {\n    return {\n      ...childFolder,\n      parentId: null,\n      childFolders: childFolder.childFolders,\n      documents: childFolder.documents,\n    };\n  });\n\n  return {\n    ...folder,\n    documents: modifiedDocuments,\n    childFolders: modifiedChildFolders,\n  };\n}\n\n// Recursive function to create data room folders and documents\nasync function createDataroomFolders(\n  dataroomId: string,\n  folder: Omit<FolderWithContents, \"parentId\">,\n  originalBasePath: string,\n  parentFolderId?: string,\n) {\n  let dataroomFolder: DataroomFolder | undefined = undefined;\n  if (originalBasePath !== folder.path) {\n    // Skip the root folder\n\n    dataroomFolder = await prisma.dataroomFolder.create({\n      data: {\n        name: folder.name,\n        path: folder.path.replace(originalBasePath, \"\"),\n        parentId: parentFolderId,\n        dataroomId: dataroomId,\n      },\n    });\n\n    // Create documents for the current folder\n    await Promise.allSettled(\n      folder.documents.map((doc) => {\n        return prisma.dataroomDocument.create({\n          data: {\n            documentId: doc.id,\n            dataroomId: dataroomId,\n            folderId: dataroomFolder?.id,\n          },\n        });\n      }),\n    );\n  }\n\n  // Create child folders recursively\n  await Promise.allSettled(\n    folder.childFolders.map((childFolder) =>\n      createDataroomFolders(\n        dataroomId,\n        childFolder,\n        originalBasePath,\n        dataroomFolder?.id,\n      ),\n    ),\n  );\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/create-from-folder\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const { folderId } = req.body as { folderId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n          _count: {\n            select: {\n              datarooms: true,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const limits = await getLimits({ teamId, userId });\n      const stripedTeamPlan = team.plan.replace(\"+old\", \"\");\n\n      if (\n        !team.plan.includes(\"drtrial\") &&\n        [\"business\", \"datarooms\", \"datarooms-plus\", \"datarooms-premium\"].includes(stripedTeamPlan) &&\n        limits &&\n        team._count.datarooms >= limits.datarooms\n      ) {\n        return res.status(403).json({\n          message:\n            \"You've reached the limit of datarooms. Consider upgrading your plan.\",\n        });\n      }\n\n      if (team.plan.includes(\"drtrial\") && team._count.datarooms > 0) {\n        return res\n          .status(400)\n          .json({ message: \"Trial data room already exists\" });\n      }\n\n      if ([\"free\", \"pro\"].includes(team.plan) && !team.plan.includes(\"drtrial\")) {\n        return res\n          .status(400)\n          .json({ message: \"You need a Business plan to create a data room\" });\n      }\n\n      // Fetch the folder structure\n      const folderContents = await fetchFolderContents(folderId);\n\n      // Create the data room\n      const pId = newId(\"dataroom\");\n      const dataroom = await prisma.dataroom.create({\n        data: {\n          pId: pId,\n          name: folderContents.name,\n          teamId: teamId,\n          documents: {\n            create: folderContents.documents.map((doc) => ({\n              documentId: doc.id,\n            })),\n          },\n          folders: {\n            create: [],\n          },\n        },\n        select: { id: true },\n      });\n\n      // Start the recursive creation with the root folder\n      await createDataroomFolders(\n        dataroom.id,\n        folderContents,\n        folderContents.path,\n      );\n\n      const dataroomWithCount = await prisma.dataroom.findUnique({\n        where: {\n          id: dataroom.id,\n        },\n        include: {\n          _count: { select: { documents: true } },\n        },\n      });\n\n      res.status(201).json(dataroomWithCount);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ error: \"Error creating dataroom\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/generate-ai-structure.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport {\n  getDataroomSystemPrompt,\n  getDataroomUserPrompt,\n} from \"@/ee/features/templates/lib/prompts\";\nimport { openai } from \"@ai-sdk/openai\";\nimport { generateObject } from \"ai\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\nexport const config = {\n  maxDuration: 120,\n};\n\n// Non-recursive folder schema with fixed depth (max 2 levels)\n// This avoids the \"Recursive reference detected\" error from the AI SDK\n// which cannot convert z.lazy() recursive schemas to JSON Schema properly\n// Limited to top-level folders + 1 level of subfolders for simplicity\n\n// Level 2 (subfolders) - no further nesting allowed\nconst subfolderSchema = z.object({\n  name: z.string().min(1).max(100),\n});\n\n// Level 1 (top-level folders) - subfolders is required but can be empty array\nconst folderSchema = z.object({\n  name: z.string().min(1).max(100),\n  subfolders: z.array(subfolderSchema).max(5), // Required, but can be empty []\n});\n\nconst dataroomStructureSchema = z.object({\n  name: z.string().min(1).max(255),\n  folders: z.array(folderSchema).min(1).max(8),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/generate-ai-structure\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { description } = req.body as { description: string };\n\n    if (\n      !description ||\n      typeof description !== \"string\" ||\n      description.trim().length === 0\n    ) {\n      return res.status(400).json({\n        message: \"Description is required\",\n      });\n    }\n\n    // Validate description length and content\n    if (description.length > 2000) {\n      return res.status(400).json({\n        message:\n          \"Description is too long. Please keep it under 2000 characters.\",\n      });\n    }\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New dataroom creation is not available.\",\n        });\n      }\n\n      // Use AI SDK with structured outputs to generate folder structure\n      // This automatically validates the response format and reduces parsing errors\n      const [systemPrompt, userPrompt] = await Promise.all([\n        getDataroomSystemPrompt(),\n        getDataroomUserPrompt(description),\n      ]);\n\n      const result = await generateObject({\n        model: openai(\"gpt-4o-mini\"),\n        schema: dataroomStructureSchema,\n        messages: [\n          { role: \"system\", content: systemPrompt },\n          { role: \"user\", content: userPrompt },\n        ],\n        temperature: 0.3,\n        providerOptions: {\n          openai: {\n            maxOutputTokens: 600,\n          },\n        },\n      });\n\n      // Validate folder depth (max 2 levels: top-level + 1 subfolder level)\n      const validateFolderStructure = (folder: any): boolean => {\n        if (folder.subfolders) {\n          // Enforce subfolder limits (max 5 subfolders per folder)\n          if (folder.subfolders.length > 5) return false;\n          // Ensure subfolders don't have their own subfolders (max 2 levels)\n          for (const sub of folder.subfolders) {\n            if (sub.subfolders && sub.subfolders.length > 0) return false;\n          }\n        }\n        return true;\n      };\n\n      if (\n        !result.object.folders.every((folder) => validateFolderStructure(folder))\n      ) {\n        return res.status(500).json({\n          message:\n            \"Generated folder structure exceeds maximum depth (2 levels)\",\n        });\n      }\n\n      // Additional safety: ensure we don't have too many top-level folders\n      if (result.object.folders.length > 8) {\n        return res.status(500).json({\n          message:\n            \"Generated folder structure has too many top-level folders (max 8)\",\n        });\n      }\n\n      res.status(200).json({\n        name: result.object.name.trim(),\n        folders: result.object.folders,\n        message: \"Folder structure generated successfully\",\n      });\n    } catch (error) {\n      console.error(\"Error generating AI folder structure:\", error);\n\n      // Provide more specific error messages based on error type\n      let errorMessage = \"Error generating folder structure\";\n      let statusCode = 500;\n\n      if (error instanceof Error) {\n        // Check for OpenAI API errors\n        if (\n          error.message.includes(\"API key\") ||\n          error.message.includes(\"authentication\")\n        ) {\n          errorMessage = \"AI service configuration error\";\n        } else if (\n          error.message.includes(\"rate limit\") ||\n          error.message.includes(\"quota\")\n        ) {\n          errorMessage =\n            \"AI service is temporarily unavailable. Please try again later.\";\n          statusCode = 429;\n        } else if (error.message.includes(\"timeout\")) {\n          errorMessage =\n            \"Request timed out. Please try again with a shorter description.\";\n          statusCode = 504;\n        } else if (process.env.NODE_ENV !== \"production\") {\n          // Only show detailed error in development\n          errorMessage = error.message;\n        }\n      }\n\n      return res.status(statusCode).json({\n        message: errorMessage,\n      });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/generate-ai.ts",
    "content": "import type { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { FolderTemplate } from \"@/ee/features/templates/constants/dataroom-templates\";\nimport { getLimits } from \"@/ee/limits/server\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/generate-ai\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { name, folders } = req.body as {\n      name: string;\n      folders: FolderTemplate[];\n    };\n\n    // Validate input\n    if (!name || typeof name !== \"string\" || name.trim().length === 0) {\n      return res.status(400).json({\n        message: \"Dataroom name is required\",\n      });\n    }\n\n    if (name.length > 255) {\n      return res.status(400).json({\n        message: \"Dataroom name is too long\",\n      });\n    }\n\n    if (!Array.isArray(folders) || folders.length === 0) {\n      return res.status(400).json({\n        message: \"Folder structure is required\",\n      });\n    }\n\n    // Validate folder structure (max 2 levels: top-level + 1 subfolder level)\n    const validateFolder = (folder: any, depth = 0): boolean => {\n      if (depth >= 2) return false; // Max 2 levels deep\n      if (!folder.name || typeof folder.name !== \"string\") return false;\n      if (folder.name.length > 255) return false;\n      if (folder.subfolders) {\n        if (!Array.isArray(folder.subfolders)) return false;\n        // Limit subfolders to 5 per folder\n        if (folder.subfolders.length > 5) return false;\n        return folder.subfolders.every((sub: any) =>\n          validateFolder(sub, depth + 1),\n        );\n      }\n      return true;\n    };\n\n    // Limit top-level folders to 8\n    if (folders.length > 8) {\n      return res.status(400).json({\n        message: \"Too many top-level folders (maximum 8)\",\n      });\n    }\n\n    if (!folders.every((folder) => validateFolder(folder))) {\n      return res.status(400).json({\n        message: \"Invalid folder structure\",\n      });\n    }\n\n    try {\n      // Check if the user is part of the team (without plan restriction first)\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team has access to datarooms (allow trial plans, paid plans, and free plan for onboarding)\n      const allowedPlans = [\n        \"business\",\n        \"datarooms\",\n        \"datarooms-plus\",\n        \"datarooms-premium\",\n        \"business+old\",\n        \"datarooms+old\",\n        \"datarooms-plus+old\",\n        \"datarooms-premium+old\",\n        \"datarooms+drtrial\",\n        \"business+drtrial\",\n        \"datarooms-plus+drtrial\",\n        \"datarooms-premium+drtrial\",\n        \"free+drtrial\",\n      ];\n\n      // Allow free plan (for onboarding) or any plan that includes allowed plans or drtrial\n      const hasAccess =\n        team.plan === \"free\" || // Allow free plan during onboarding\n        team.plan.includes(\"drtrial\") || // Allow any trial plan\n        allowedPlans.some((plan) => team.plan.includes(plan));\n\n      if (!hasAccess) {\n        return res.status(403).json({\n          message:\n            \"This feature requires a datarooms plan. Please upgrade to access AI-generated data rooms.\",\n        });\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New dataroom creation is not available.\",\n        });\n      }\n\n      // Limits: Check if the user has reached the limit of datarooms in the team\n      const dataroomCount = await prisma.dataroom.count({\n        where: {\n          teamId: teamId,\n        },\n      });\n\n      const limits = await getLimits({ teamId, userId });\n\n      // Allow first dataroom creation on free plan during onboarding\n      const isFreePlan = team.plan === \"free\" || team.plan === \"free+drtrial\";\n      const isFirstDataroom = dataroomCount === 0;\n\n      if (\n        limits &&\n        !(isFreePlan && isFirstDataroom) &&\n        dataroomCount >= limits.datarooms\n      ) {\n        return res\n          .status(403)\n          .json({ message: \"You have reached the limit of datarooms\" });\n      }\n\n      const pId = newId(\"dataroom\");\n      const dataroomName = name.trim();\n\n      // Create the dataroom and folders in a transaction to prevent hanging results\n      const dataroom = await prisma.$transaction(async (tx) => {\n        // Create the dataroom\n        const createdDataroom = await tx.dataroom.create({\n          data: {\n            name: dataroomName,\n            teamId: teamId,\n            pId: pId,\n          },\n        });\n\n        // Helper function to create folders recursively\n        const createFolders = async (\n          folders: FolderTemplate[],\n          parentPath: string = \"\",\n          parentId: string | null = null,\n        ): Promise<void> => {\n          for (const folder of folders) {\n            const folderPath = parentPath + \"/\" + safeSlugify(folder.name);\n\n            // Create the folder\n            const createdFolder = await tx.dataroomFolder.create({\n              data: {\n                name: folder.name,\n                path: folderPath,\n                parentId: parentId,\n                dataroomId: createdDataroom.id,\n              },\n            });\n\n            // If the folder has subfolders, create them recursively\n            if (folder.subfolders && folder.subfolders.length > 0) {\n              await createFolders(\n                folder.subfolders,\n                folderPath,\n                createdFolder.id,\n              );\n            }\n          }\n        };\n\n        await createFolders(folders);\n\n        return createdDataroom;\n      });\n\n      const dataroomWithCount = {\n        ...dataroom,\n        _count: { documents: 0 },\n      };\n\n      res.status(201).json({\n        dataroom: dataroomWithCount,\n        message: \"Dataroom generated successfully with AI\",\n      });\n    } catch (error) {\n      console.error(\"Error generating dataroom with AI:\", error);\n      return res.status(500).json({ error: \"Error generating dataroom\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/generate.ts",
    "content": "// Re-export from ee folder\nexport { default } from \"@/ee/features/templates/api/datarooms/generate\";\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { getLimits } from \"@/ee/limits/server\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport const config = {\n  maxDuration: 180,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/datarooms\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, search, status, tags, simple } = req.query as {\n      teamId: string;\n      search?: string;\n      status?: string;\n      tags?: string;\n      simple?: string;\n    };\n\n    // Simple mode: return minimal data without filters, tags, or aggregations\n    const isSimpleMode = simple === \"true\";\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: {\n          teamId: true,\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Simple mode: return minimal data without filters, tags, or aggregations\n      if (isSimpleMode) {\n        const datarooms = await prisma.dataroom.findMany({\n          where: {\n            teamId: teamId,\n          },\n          select: {\n            id: true,\n            name: true,\n            internalName: true,\n            createdAt: true,\n          },\n          orderBy: {\n            createdAt: \"desc\",\n          },\n        });\n\n        return res.status(200).json({ datarooms });\n      }\n\n      const now = new Date();\n      const activeLinkFilter: Prisma.LinkWhereInput = {\n        linkType: \"DATAROOM_LINK\",\n        deletedAt: null,\n        isArchived: false,\n        OR: [{ expiresAt: null }, { expiresAt: { gte: now } }],\n      };\n\n      // Build where clause based on filters\n      const whereClause: Prisma.DataroomWhereInput = {\n        teamId: teamId,\n      };\n\n      // Search filter - search both name and internalName\n      if (search) {\n        whereClause.OR = [\n          {\n            name: {\n              contains: search,\n              mode: \"insensitive\",\n            },\n          },\n          {\n            internalName: {\n              contains: search,\n              mode: \"insensitive\",\n            },\n          },\n        ];\n      }\n\n      // Tags filter\n      if (tags) {\n        const tagNames = tags.split(\",\").filter(Boolean);\n        if (tagNames.length > 0) {\n          whereClause.tags = {\n            some: {\n              tag: {\n                name: {\n                  in: tagNames,\n                },\n              },\n            },\n          };\n        }\n      }\n\n      // if (status === \"active\") {\n      //   whereClause.links = { some: activeLinkFilter };\n      // } else if (status === \"inactive\") {\n      //   whereClause.links = { none: activeLinkFilter };\n      // }\n\n      const [totalCount, datarooms] = await Promise.all([\n        prisma.dataroom.count({\n          where: {\n            teamId: teamId,\n          },\n        }),\n        prisma.dataroom.findMany({\n          where: whereClause,\n          select: {\n            id: true,\n            name: true,\n            createdAt: true,\n            _count: {\n              select: { documents: true, views: true },\n            },\n            tags: {\n              include: {\n                tag: {\n                  select: {\n                    id: true,\n                    name: true,\n                    color: true,\n                    description: true,\n                  },\n                },\n              },\n            },\n          },\n          orderBy: {\n            createdAt: \"desc\",\n          },\n        }),\n      ]);\n\n      const dataroomIds = datarooms.map((dataroom) => dataroom.id);\n      const [activeLinkCounts, lastViewedAtByDataroom] = dataroomIds.length\n        ? await Promise.all([\n            prisma.link.groupBy({\n              by: [\"dataroomId\"],\n              where: {\n                dataroomId: { in: dataroomIds },\n                ...activeLinkFilter,\n              },\n              _count: {\n                _all: true,\n              },\n            }),\n            prisma.view.groupBy({\n              by: [\"dataroomId\"],\n              where: {\n                dataroomId: { in: dataroomIds },\n              },\n              _max: {\n                viewedAt: true,\n              },\n            }),\n          ])\n        : [[], []];\n\n      const activeLinkCountMap = new Map(\n        activeLinkCounts.map((entry) => [entry.dataroomId, entry._count._all]),\n      );\n      const lastViewedAtMap = new Map(\n        lastViewedAtByDataroom.map((entry) => [\n          entry.dataroomId,\n          entry._max.viewedAt,\n        ]),\n      );\n\n      const dataroomsWithStats = datarooms.map((dataroom) => ({\n        ...dataroom,\n        activeLinkCount: activeLinkCountMap.get(dataroom.id) ?? 0,\n        lastViewedAt: lastViewedAtMap.get(dataroom.id) ?? null,\n      }));\n\n      return res.status(200).json({\n        datarooms: dataroomsWithStats,\n        totalCount,\n      });\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching datarooms\" });\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    const { teamId } = req.query as { teamId: string };\n    const { name, internalName } = req.body as { name: string; internalName?: string };\n\n    try {\n      // Check if the user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          plan: {\n            in: [\n              \"business\",\n              \"datarooms\",\n              \"datarooms-plus\",\n              \"datarooms-premium\",\n              \"business+old\",\n              \"datarooms+old\",\n              \"datarooms-plus+old\",\n              \"datarooms-premium+old\",\n              \"free+drtrial\",\n              \"datarooms+drtrial\",\n              \"business+drtrial\",\n              \"datarooms-plus+drtrial\",\n              \"datarooms-premium+drtrial\",\n            ],\n          },\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New dataroom creation is not available.\",\n        });\n      }\n\n      // Limits: Check if the user has reached the limit of datarooms in the team\n      const dataroomCount = await prisma.dataroom.count({\n        where: {\n          teamId: teamId,\n        },\n      });\n\n      const limits = await getLimits({ teamId, userId });\n\n      if (limits && dataroomCount >= limits.datarooms) {\n        return res\n          .status(403)\n          .json({ message: \"You have reached the limit of datarooms\" });\n      }\n\n      const pId = newId(\"dataroom\");\n\n      const dataroom = await prisma.dataroom.create({\n        data: {\n          name: name,\n          teamId: teamId,\n          pId: pId,\n          ...(internalName && { internalName: internalName.trim() }),\n        },\n      });\n\n      const dataroomWithCount = {\n        ...dataroom,\n        _count: { documents: 0 },\n      };\n\n      res.status(201).json({ dataroom: dataroomWithCount });\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ error: \"Error creating dataroom\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/datarooms/trial.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { sendDataroomTrialWelcome } from \"@/lib/emails/send-dataroom-trial\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport {\n  sendDataroomTrial24hReminderEmailTask,\n  sendDataroomTrialExpiredEmailTask,\n  sendDataroomTrialInfoEmailTask,\n} from \"@/lib/trigger/send-scheduled-email\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log, logStore } from \"@/lib/utils\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/datarooms/trial\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const email = (session.user as CustomUser).email;\n\n    const { teamId } = req.query as { teamId: string };\n    const { name, fullName, companyName, useCase, companySize, tools } =\n      req.body as {\n        name: string;\n        fullName: string;\n        companyName: string;\n        useCase: string;\n        companySize: string;\n        tools: string;\n      };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n          _count: {\n            select: {\n              datarooms: true,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (team.plan.includes(\"drtrial\") || team._count.datarooms > 0) {\n        return res\n          .status(400)\n          .json({ message: \"Trial data room already exists\" });\n      }\n\n      await log({\n        message: `Dataroom Trial: ${teamId} \\n\\nEmail: ${email} \\nName: ${fullName} \\nCompany Name: ${companyName} \\nUse Case: ${useCase} \\nCompany Size: ${companySize} \\nTools: ${tools}`,\n        type: \"trial\",\n        mention: true,\n      });\n\n      await logStore({\n        object: {\n          teamId: teamId,\n          email: email,\n          fullName: fullName,\n          companyName: companyName,\n          useCase: useCase,\n          companySize: companySize,\n          tools: tools,\n        },\n      });\n\n      await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          plan: `${team.plan}+drtrial`,\n        },\n      });\n\n      const pId = newId(\"dataroom\");\n\n      const dataroom = await prisma.dataroom.create({\n        data: {\n          name: name,\n          teamId: teamId,\n          pId: pId,\n        },\n      });\n\n      const dataroomWithCount = {\n        ...dataroom,\n        _count: { documents: 0 },\n      };\n\n      /** Emails\n       *\n       * 1. Send welcome email\n       * 2. Send dataroom info email after 1 day\n       * 3. Send expired trial email after 7 days\n       */\n      waitUntil(sendDataroomTrialWelcome({ fullName, to: email! }));\n      waitUntil(\n        sendDataroomTrialInfoEmailTask.trigger(\n          { to: email!, useCase },\n          { delay: \"1d\" },\n        ),\n      );\n      waitUntil(\n        sendDataroomTrial24hReminderEmailTask.trigger(\n          { to: email!, name: fullName.split(\" \")[0], teamId },\n          { delay: \"6d\" },\n        ),\n      );\n      waitUntil(\n        sendDataroomTrialExpiredEmailTask.trigger(\n          { to: email!, name: fullName.split(\" \")[0], teamId },\n          { delay: \"7d\" },\n        ),\n      );\n\n      res.status(201).json(dataroomWithCount);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ error: \"Error creating dataroom\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/add-to-dataroom.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport {\n  addFileToVectorStoreTask,\n  processDocumentForAITask,\n  SUPPORTED_AI_CONTENT_TYPES,\n} from \"@/ee/features/ai/lib/trigger\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents/:id/add-to-dataroom\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const { dataroomId } = req.body as { dataroomId: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n          documents: {\n            some: {\n              id: {\n                equals: docId,\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (\n        (team.plan === \"free\" || team.plan === \"pro\") &&\n        !team.plan.includes(\"drtrial\")\n      ) {\n        return res.status(403).json({\n          message: \"Upgrade your plan to use datarooms.\",\n        });\n      }\n\n      // Fetch dataroom with AI settings\n      const dataroom = await prisma.dataroom.findUnique({\n        where: { id: dataroomId },\n        select: {\n          id: true,\n          teamId: true,\n          name: true,\n          agentsEnabled: true,\n          vectorStoreId: true,\n        },\n      });\n\n      if (!dataroom) {\n        return res.status(404).json({\n          message: \"Dataroom not found!\",\n        });\n      }\n\n      // Fetch document with primary version\n      const document = await prisma.document.findUnique({\n        where: { id: docId },\n        include: {\n          versions: {\n            where: { isPrimary: true },\n            take: 1,\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).json({\n          message: \"Document not found!\",\n        });\n      }\n\n      let dataroomDocument;\n      try {\n        dataroomDocument = await prisma.dataroomDocument.create({\n          data: {\n            documentId: docId,\n            dataroomId,\n          },\n        });\n      } catch (error) {\n        return res.status(500).json({\n          message: \"Document already exists in dataroom!\",\n        });\n      }\n\n      // Auto-index document if dataroom has AI agents enabled\n      if (dataroom.agentsEnabled && dataroom.vectorStoreId) {\n        const primaryVersion = document.versions[0];\n        const contentType = primaryVersion?.contentType || \"\";\n\n        // Check if AI feature is enabled for the team\n        const features = await getFeatureFlags({ teamId });\n\n        if (features.ai && primaryVersion && SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {\n          const filePath =\n            primaryVersion.originalFile && contentType !== \"application/pdf\"\n              ? primaryVersion.originalFile\n              : primaryVersion.file;\n\n          const fileMetadata = {\n            teamId: dataroom.teamId,\n            documentId: document.id,\n            documentName: document.name,\n            versionId: primaryVersion.id,\n            dataroomId: dataroom.id,\n            dataroomDocumentId: dataroomDocument.id,\n            dataroomFolderId: \"root\",\n          };\n\n          try {\n            // If document already has fileId, just add to vector store\n            if (primaryVersion.fileId) {\n              waitUntil(\n                addFileToVectorStoreTask.trigger({\n                  fileId: primaryVersion.fileId,\n                  vectorStoreId: dataroom.vectorStoreId,\n                  metadata: fileMetadata,\n                }),\n              );\n            } else {\n              // Trigger full processing\n              waitUntil(\n                processDocumentForAITask.trigger(\n                  {\n                    documentId: document.id,\n                    documentVersionId: primaryVersion.id,\n                    teamId: dataroom.teamId,\n                    vectorStoreId: dataroom.vectorStoreId,\n                    documentName: document.name,\n                    filePath,\n                    storageType: primaryVersion.storageType,\n                    contentType,\n                    metadata: fileMetadata,\n                  },\n                  {\n                    idempotencyKey: `ai-index-dataroom-${dataroomId}-${primaryVersion.id}`,\n                    tags: [\n                      `team_${teamId}`,\n                      `dataroom_${dataroomId}`,\n                      `document_${document.id}`,\n                      `version_${primaryVersion.id}`,\n                    ],\n                  },\n                ),\n              );\n            }\n          } catch (error) {\n            console.error(\"Error triggering AI indexing for document:\", error);\n            // Don't fail the document add, just log the error\n          }\n        }\n      }\n\n      return res.status(200).json({\n        message: \"Document added to dataroom!\",\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/advanced-mode.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { copyFileToBucketServer } from \"@/lib/files/copy-file-to-bucket-server\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { supportsAdvancedExcelMode } from \"@/lib/utils/get-content-type\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents/:id/advanced-mode\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const { enabled } = req.body as { enabled: boolean };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        select: {\n          id: true,\n          advancedExcelEnabled: true,\n        },\n      });\n\n      if (!document) {\n        return res.status(404).end(\"Document not found\");\n      }\n\n      const documentVersion = await prisma.documentVersion.findFirst({\n        where: {\n          documentId: docId,\n          isPrimary: true,\n          type: \"sheet\",\n        },\n        select: {\n          id: true,\n          file: true,\n          storageType: true,\n          contentType: true,\n          numPages: true,\n        },\n      });\n\n      if (!documentVersion) {\n        return res.status(404).end(\"Document version not found\");\n      }\n\n      if (!supportsAdvancedExcelMode(documentVersion.contentType)) {\n        return res.status(400).json({\n          message:\n            \"Advanced mode is only available for Excel files (.xls, .xlsx, .xlsm).\",\n        });\n      }\n\n      // If enabling advanced mode, copy file to bucket\n      if (enabled && !document.advancedExcelEnabled) {\n        await copyFileToBucketServer({\n          filePath: documentVersion.file,\n          storageType: documentVersion.storageType,\n          teamId,\n        });\n      }\n\n      const documentPromise = prisma.document.update({\n        where: { id: docId },\n        data: { advancedExcelEnabled: enabled },\n      });\n\n      const documentVersionPromise = enabled\n        ? prisma.documentVersion.update({\n            where: { id: documentVersion.id },\n            data: { numPages: 1 },\n          })\n        : Promise.resolve();\n\n      await Promise.all([documentPromise, documentVersionPromise]);\n\n      await fetch(\n        `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&documentId=${docId}`,\n      );\n\n      return res.status(200).json({\n        message: enabled\n          ? `Document updated to advanced Excel mode!`\n          : `Document updated to standard Excel mode!`,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId]/images.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const {\n    teamId,\n    id: docId,\n    annotationId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    annotationId: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // Verify user has access to document\n    const document = await prisma.document.findUnique({\n      where: {\n        id: docId,\n        teamId,\n      },\n      select: { id: true },\n    });\n\n    if (!document) {\n      return res.status(404).json({ error: \"Document not found\" });\n    }\n\n    // Verify annotation exists and belongs to document\n    const annotation = await prisma.documentAnnotation.findFirst({\n      where: {\n        id: annotationId,\n        documentId: docId,\n        teamId,\n      },\n    });\n\n    if (!annotation) {\n      return res.status(404).json({ error: \"Annotation not found\" });\n    }\n\n    const { filename, url, size, mimeType } = req.body;\n\n    if (!filename || !url || !mimeType) {\n      return res.status(400).json({\n        error: \"Missing required fields: filename, url, mimeType\",\n      });\n    }\n\n    // Validate file type\n    if (!mimeType.startsWith(\"image/\")) {\n      return res.status(400).json({ error: \"Only image files are allowed\" });\n    }\n\n    // Save image record to database\n    const image = await prisma.annotationImage.create({\n      data: {\n        filename,\n        url,\n        size,\n        mimeType,\n        annotationId,\n      },\n    });\n\n    return res.status(201).json(image);\n  } catch (error) {\n    log({\n      message: `Failed to upload image for annotation: _${annotationId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}, docId: ${docId}}\\``,\n      type: \"error\",\n    });\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/annotations/[annotationId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nconst updateAnnotationSchema = z.object({\n  title: z\n    .string()\n    .min(1, \"Title is required\")\n    .max(100, \"Title must be less than 100 characters\")\n    .optional(),\n  content: z.record(z.any()).optional(), // Rich text content as JSON\n  pages: z\n    .array(z.number().min(1))\n    .min(1, \"At least one page must be selected\")\n    .optional(),\n  isVisible: z.boolean().optional(),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const {\n    teamId,\n    id: docId,\n    annotationId,\n  } = req.query as {\n    teamId: string;\n    id: string;\n    annotationId: string;\n  };\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // Verify user has access to document\n    const document = await prisma.document.findUnique({\n      where: {\n        id: docId,\n        teamId,\n      },\n      select: { id: true },\n    });\n\n    if (!document) {\n      return res.status(404).json({ error: \"Document not found\" });\n    }\n\n    // Verify annotation exists and belongs to document\n    const annotation = await prisma.documentAnnotation.findFirst({\n      where: {\n        id: annotationId,\n        documentId: docId,\n        teamId,\n      },\n    });\n\n    if (!annotation) {\n      return res.status(404).json({ error: \"Annotation not found\" });\n    }\n\n    if (req.method === \"GET\") {\n      // GET /api/teams/:teamId/documents/:id/annotations/:annotationId\n      const fullAnnotation = await prisma.documentAnnotation.findUnique({\n        where: { id: annotationId, documentId: docId, teamId },\n        include: {\n          images: true,\n          createdBy: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n            },\n          },\n        },\n      });\n\n      return res.status(200).json(fullAnnotation);\n    } else if (req.method === \"PUT\") {\n      // PUT /api/teams/:teamId/documents/:id/annotations/:annotationId\n      const validatedData = updateAnnotationSchema.parse(req.body);\n\n      const updatedAnnotation = await prisma.documentAnnotation.update({\n        where: { id: annotationId, documentId: docId, teamId },\n        data: validatedData,\n        include: {\n          images: true,\n          createdBy: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n            },\n          },\n        },\n      });\n\n      return res.status(200).json(updatedAnnotation);\n    } else if (req.method === \"DELETE\") {\n      // DELETE /api/teams/:teamId/documents/:id/annotations/:annotationId\n      await prisma.documentAnnotation.delete({\n        where: { id: annotationId, documentId: docId, teamId },\n      });\n\n      return res.status(204).end();\n    } else {\n      res.setHeader(\"Allow\", [\"GET\", \"PUT\", \"DELETE\"]);\n      return res.status(405).end(`Method ${req.method} Not Allowed`);\n    }\n  } catch (error) {\n    if (error instanceof z.ZodError) {\n      return res.status(400).json({\n        error: \"Invalid input\",\n        details: error.errors,\n      });\n    }\n\n    log({\n      message: `Failed to handle annotation ${req.method} for document: _${docId}_ and annotation: _${annotationId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n      type: \"error\",\n    });\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/annotations/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nconst createAnnotationSchema = z.object({\n  title: z\n    .string()\n    .min(1, \"Title is required\")\n    .max(100, \"Title must be less than 100 characters\"),\n  content: z.record(z.any()).nullable().optional(), // Rich text content as JSON - allow null/omitted\n  pages: z\n    .array(z.number().int().min(1))\n    .min(1, \"At least one page must be selected\"),\n  isVisible: z.boolean().default(true),\n});\n\nconst updateAnnotationSchema = createAnnotationSchema.partial();\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id/annotations\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Validate access; avoid heavy includes\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        select: { id: true },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      const annotations = await prisma.documentAnnotation.findMany({\n        where: { documentId: docId, teamId },\n        include: {\n          images: true,\n          createdBy: { select: { id: true, name: true, email: true } },\n        },\n        orderBy: { createdAt: \"desc\" },\n      });\n\n      return res.status(200).json(annotations);\n    } catch (error) {\n      log({\n        message: `Failed to get annotations for document: _${docId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents/:id/annotations\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const validatedData = createAnnotationSchema.parse(req.body);\n\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        select: { id: true },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      const annotation = await prisma.documentAnnotation.create({\n        data: {\n          ...validatedData,\n          content: validatedData.content ?? Prisma.JsonNull, // Convert undefined to Prisma.JsonNull\n          documentId: docId,\n          teamId,\n          createdById: userId,\n        },\n        include: {\n          images: true,\n          createdBy: {\n            select: {\n              id: true,\n              name: true,\n              email: true,\n            },\n          },\n        },\n      });\n\n      return res.status(201).json(annotation);\n    } catch (error) {\n      if (error instanceof z.ZodError) {\n        return res.status(400).json({\n          error: \"Invalid input\",\n          details: error.errors,\n        });\n      }\n\n      log({\n        message: `Failed to create annotation for document: _${docId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/change-orientation.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { version } from \"os\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // GET /api/teams/:teamId/documents/:id/update-name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const { versionId, isVertical } = req.body as {\n      versionId: string;\n      isVertical: boolean;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n          documents: {\n            some: {\n              id: {\n                equals: docId,\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      await prisma.documentVersion.update({\n        where: {\n          id: versionId,\n        },\n        data: {\n          isVertical,\n        },\n      });\n\n      await fetch(\n        `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&documentId=${docId}`,\n      );\n\n      return res.status(200).json({\n        message: `Document orientation changed to ${isVertical ? \"portrait\" : \"landscape\"}!`,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/check-notion-accessibility.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { parsePageId } from \"notion-utils\";\n\nimport notion from \"@/lib/notion\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId, id: documentId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n\n  try {\n    // Check if user has access to the team\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const documentVersion = await prisma.documentVersion.findFirst({\n      where: {\n        documentId: documentId,\n        isPrimary: true,\n      },\n      select: {\n        file: true,\n        type: true,\n      },\n    });\n\n    if (!documentVersion) {\n      return res.status(404).json({ message: \"Document version not found\" });\n    }\n\n    if (documentVersion.type !== \"notion\") {\n      return res\n        .status(400)\n        .json({ message: \"Document is not a Notion document\" });\n    }\n\n    // Check if the Notion page is publicly accessible\n    try {\n      const notionUrl = documentVersion.file;\n      const pageId = parsePageId(notionUrl, { uuid: false });\n      if (!pageId) {\n        return res.status(200).json({\n          isAccessible: false,\n          url: notionUrl,\n          error: \"Notion page URL is not valid\",\n          lastChecked: new Date().toISOString(),\n        });\n      }\n      try {\n        await notion.getPage(pageId);\n      } catch (error) {\n        console.error(\"Error checking Notion accessibility:\", error);\n        return res.status(200).json({\n          isAccessible: false,\n          url: documentVersion.file,\n          lastChecked: new Date().toISOString(),\n        });\n      }\n      return res.status(200).json({\n        isAccessible: true,\n        url: notionUrl,\n        statusCode: 200,\n        lastChecked: new Date().toISOString(),\n      });\n    } catch (error) {\n      console.error(\"Error checking Notion accessibility:\", error);\n      return res.status(200).json({\n        isAccessible: false,\n        url: documentVersion.file,\n        error: \"Failed to check accessibility\",\n        lastChecked: new Date().toISOString(),\n      });\n    }\n  } catch (error) {\n    console.error(\"Error:\", error);\n    return res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/duplicate.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { DocumentVersion } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { copyFileServer } from \"@/lib/files/copy-file-server\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // GET /api/teams/:teamId/documents/:id/duplicate\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n          documents: {\n            some: {\n              id: {\n                equals: docId,\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New document creation is not available.\",\n        });\n      }\n\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n        },\n        include: {\n          versions: {\n            where: { isPrimary: true },\n            orderBy: { createdAt: \"desc\" },\n            take: 1,\n            include: {\n              pages: true,\n            },\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).end(\"Document not found\");\n      }\n\n      const { documentId, ...documentVersion } = document.versions[0];\n\n      const { type, data } = await copyFileServer({\n        teamId: teamId,\n        filePath: documentVersion.file,\n        fileName: document.name,\n        storageType: documentVersion.storageType,\n      });\n\n      await prisma.document.create({\n        data: {\n          ...document,\n          name: `${document.name} (Copy)`,\n          id: undefined,\n          teamId: teamId,\n          ownerId: userId,\n          createdAt: undefined,\n          updatedAt: undefined,\n          file: document.file.replace(data?.fromLocation!, data?.toLocation!),\n          versions: {\n            create: {\n              ...documentVersion,\n              id: undefined,\n              versionNumber: 1,\n              createdAt: undefined,\n              updatedAt: undefined,\n              fileId: undefined,\n              file: documentVersion.file.replace(\n                data?.fromLocation!,\n                data?.toLocation!,\n              ),\n              pages: {\n                createMany: {\n                  data: documentVersion.pages.map((page) => ({\n                    ...page,\n                    id: undefined,\n                    file: page.file.replace(\n                      data?.fromLocation!,\n                      data?.toLocation!,\n                    ),\n                    metadata: page.metadata ?? {},\n                    pageLinks: page.pageLinks ?? [],\n                    versionId: undefined,\n                    createdAt: undefined,\n                    updatedAt: undefined,\n                  })),\n                },\n              },\n            },\n          },\n        },\n      });\n\n      return res.status(200).json({\n        message: \"Document duplicated successfully!\",\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/export-visits.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { jobStore } from \"@/lib/redis-job-store\";\nimport { exportVisitsTask } from \"@/lib/trigger/export-visits\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify team access\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: { plan: true },\n      });\n\n      if (!team) {\n        return res.status(404).end(\"Team not found\");\n      }\n\n      // Get existing exports for this document\n      const existingExports = await jobStore.getResourceJobs(\n        docId,\n        teamId,\n        \"document\",\n        undefined,\n        10,\n      );\n\n      return res.status(200).json(existingExports);\n    } catch (error) {\n      console.error(\"Error fetching existing exports:\", error);\n      return res\n        .status(500)\n        .json({ message: \"Failed to fetch existing exports\" });\n    }\n  }\n\n  if (req.method !== \"POST\") {\n    // Changed to POST to trigger background job\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  // get document id and teamId from query params\n  const { teamId, id: docId } = req.query as { teamId: string; id: string };\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    // Fetching Team based on team.id\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n      select: { plan: true },\n    });\n\n    if (!team) {\n      return res.status(404).end(\"Team not found\");\n    }\n\n    if (team.plan.includes(\"free\")) {\n      return res\n        .status(403)\n        .json({ message: \"This feature is not available for your plan\" });\n    }\n\n    // Fetching Document based on document.id\n    const document = await prisma.document.findUnique({\n      where: { id: docId, teamId: teamId },\n      select: {\n        id: true,\n        name: true,\n      },\n    });\n\n    if (!document) {\n      return res.status(404).end(\"Document not found\");\n    }\n\n    // Create export job record\n    const exportJob = await jobStore.createJob({\n      type: \"document\",\n      resourceId: docId,\n      resourceName: document.name,\n      userId,\n      teamId,\n      status: \"PENDING\",\n    });\n\n    // Trigger the background task\n    const handle = await exportVisitsTask.trigger(\n      {\n        type: \"document\",\n        teamId,\n        resourceId: docId,\n        userId,\n        exportId: exportJob.id,\n      },\n      {\n        idempotencyKey: exportJob.id,\n        tags: [`team_${teamId}`, `user_${userId}`, `export_${exportJob.id}`],\n      },\n    );\n\n    // Update the job with the trigger run ID for cancellation\n    const updatedJob = await jobStore.updateJob(exportJob.id, {\n      triggerRunId: handle.id,\n    });\n\n    return res.status(200).json({\n      exportId: updatedJob?.id || exportJob.id,\n      status: updatedJob?.status || exportJob.status,\n      message:\n        \"Export job created successfully. You will be notified when it's ready.\",\n    });\n  } catch (error) {\n    console.error(\"Error creating export job:\", error);\n    return res.status(500).json({ message: \"Something went wrong\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { TeamError, errorhandler } from \"@/lib/errorHandler\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport { deleteFile } from \"@/lib/files/delete-file-server\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { CustomUser } from \"@/lib/types\";\nimport { serializeFileSize } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Per-user, per-document rate limit to prevent abuse\n      // Default: 120 requests per minute per user per document\n      const { success, limit, remaining, reset } = await ratelimit(\n        120,\n        \"1 m\",\n      ).limit(`doc:${docId}:team:${teamId}:user:${userId}`);\n\n      res.setHeader(\"X-RateLimit-Limit\", limit.toString());\n      res.setHeader(\"X-RateLimit-Remaining\", remaining.toString());\n      res.setHeader(\"X-RateLimit-Reset\", reset.toString());\n      if (!success) {\n        return res.status(429).json({ error: \"Too many requests\" });\n      }\n\n      // First verify user has access to the team (lightweight query)\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: { teamId: true },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).json({ message: \"Unauthorized\" });\n      }\n\n      // Then fetch the specific document with its relationships (targeted query)\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        include: {\n          // Get the latest primary version of the document\n          versions: {\n            where: { isPrimary: true },\n            orderBy: { createdAt: \"desc\" },\n            take: 1,\n          },\n          folder: {\n            select: {\n              name: true,\n              path: true,\n            },\n          },\n          datarooms: {\n            select: {\n              dataroom: {\n                select: {\n                  id: true,\n                  name: true,\n                },\n              },\n              folder: {\n                select: {\n                  id: true,\n                  name: true,\n                  path: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!document || !document.versions || document.versions.length === 0) {\n        return res.status(404).json({\n          error: \"Not Found\",\n          message: \"The requested document does not exist\",\n        });\n      }\n\n      const pages = await prisma.documentPage.findMany({\n        where: {\n          versionId: document.versions[0].id,\n        },\n        select: {\n          pageLinks: true,\n        },\n      });\n\n      const hasPageLinks = pages.some(\n        (page) =>\n          page.pageLinks &&\n          Array.isArray(page.pageLinks) &&\n          (page.pageLinks as any[]).length > 0,\n      );\n\n      // Check that the user is owner of the document, otherwise return 401\n      // if (document.ownerId !== (session.user as CustomUser).id) {\n      //   return res.status(401).end(\"Unauthorized to access this document\");\n      // }\n\n      return res\n        .status(200)\n        .json(serializeFileSize({ ...document, hasPageLinks }));\n    } catch (error) {\n      if (error instanceof TeamError) {\n        return res.status(404).json({\n          error: \"Not Found\",\n          message: error.message,\n        });\n      }\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/document/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const { folderId, currentPathName } = req.body as {\n      folderId: string;\n      currentPathName: string;\n    };\n\n    const document = await prisma.document.update({\n      where: {\n        id: docId,\n        teamId: teamId,\n        team: {\n          users: {\n            some: {\n              role: \"ADMIN\",\n              userId: userId,\n            },\n          },\n        },\n      },\n      data: {\n        folderId: folderId,\n      },\n      select: {\n        folder: {\n          select: {\n            path: true,\n          },\n        },\n      },\n    });\n\n    if (!document) {\n      return res.status(404).json({ message: \"Document not found\" });\n    }\n\n    return res.status(200).json({\n      message: \"Document moved successfully\",\n      newPath: document.folder?.path,\n      oldPath: currentPathName,\n    });\n  } else if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/documents/:id\n    // Update document settings (e.g., agentsEnabled)\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Verify user has access to the team\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: { role: true },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).json({ message: \"Unauthorized\" });\n      }\n\n      // Extract allowed fields from request body\n      const { agentsEnabled } = req.body as {\n        agentsEnabled?: boolean;\n      };\n\n      if (agentsEnabled !== undefined) {\n        const features = await getFeatureFlags({ teamId });\n        if (!features.ai) {\n          return res\n            .status(403)\n            .json({ message: \"AI feature is not available\" });\n        }\n      }\n\n      // Build update data object with only provided fields\n      const updateData: { agentsEnabled?: boolean } = {};\n\n      if (typeof agentsEnabled === \"boolean\") {\n        updateData.agentsEnabled = agentsEnabled;\n      }\n\n      // Check if there's anything to update\n      if (Object.keys(updateData).length === 0) {\n        return res.status(400).json({ message: \"No valid fields to update\" });\n      }\n\n      // Update the document\n      const document = await prisma.document.update({\n        where: {\n          id: docId,\n          teamId: teamId,\n        },\n        data: updateData,\n        select: {\n          id: true,\n          agentsEnabled: true,\n        },\n      });\n\n      return res.status(200).json(document);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/document/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n      if (!teamAccess) {\n        return res.status(401).json({ message: \"Unauthorized\" });\n      }\n\n      if (teamAccess.role !== \"ADMIN\" && teamAccess.role !== \"MANAGER\") {\n        return res.status(403).json({\n          message:\n            \"You are not permitted to perform this action. Only admin and managers can delete documents.\",\n        });\n      }\n\n      const documentVersions = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId: teamId,\n        },\n        include: {\n          versions: {\n            select: {\n              id: true,\n              file: true,\n              type: true,\n              storageType: true,\n            },\n          },\n        },\n      });\n\n      if (!documentVersions) {\n        return res.status(404).json({ message: \"Document not found\" });\n      }\n\n      //if it is not notion document then only delete the document from storage\n      if (documentVersions.type !== \"notion\") {\n        // delete the files from storage\n        for (const version of documentVersions.versions) {\n          await deleteFile({\n            type: version.storageType,\n            data: version.file,\n            teamId,\n          });\n        }\n      }\n\n      // delete the document from database\n      await prisma.document.delete({\n        where: {\n          id: docId,\n        },\n      });\n\n      return res.status(204).end(); // 204 No Content response for successful deletes\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET, PUT, PATCH and DELETE requests\n    res.setHeader(\"Allow\", [\"GET\", \"PUT\", \"PATCH\", \"DELETE\"]);\n    return res\n      .status(405)\n      .json({ message: `Method ${req.method} Not Allowed` });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/links.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { decryptEncrpytedPassword, log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id/links\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // First, ensure the requester belongs to the team\n      const teamHasUser = await prisma.team.findFirst({\n        where: { id: teamId, users: { some: { userId } } },\n        select: { id: true },\n      });\n      if (!teamHasUser) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n      // Then check if document has any links to avoid expensive query\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        select: {\n          id: true,\n          ownerId: true,\n          _count: {\n            select: {\n              links: true,\n            },\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      // Early return for documents with no links\n      if (document._count.links === 0) {\n        return res.status(200).json([]);\n      }\n\n      // Only fetch full link data if we have links (target only this document)\n      // Optimized: Only fetch the most recent view per link instead of all views\n      const docWithLinks = await prisma.document.findUnique({\n        where: { id: docId, teamId },\n        select: {\n          id: true,\n          ownerId: true,\n          links: {\n            where: { deletedAt: null }, // exclude deleted links\n            orderBy: { createdAt: \"desc\" },\n            include: {\n              // Only fetch the most recent view (needed for \"last viewed\" display)\n              views: {\n                orderBy: { viewedAt: \"desc\" },\n                take: 1,\n              },\n              feedback: { select: { id: true, data: true } },\n              customFields: {\n                select: {\n                  orderIndex: true,\n                  label: true,\n                  identifier: true,\n                  placeholder: true,\n                  type: true,\n                  required: true,\n                },\n                orderBy: { orderIndex: \"asc\" },\n              },\n              visitorGroups: {\n                select: { visitorGroupId: true },\n              },\n              _count: { select: { views: true } },\n            },\n          },\n        },\n      });\n\n      const links = docWithLinks!.links;\n\n      // Early return if no links found\n      if (!links || links.length === 0) {\n        return res.status(200).json([]);\n      }\n\n      // Collect all link IDs for batch tag query\n      const linkIds = links.map((link) => link.id);\n\n      // Batch fetch all tags for all links in a single query (fixes N+1 problem)\n      const tagItems = await prisma.tagItem.findMany({\n        where: {\n          linkId: { in: linkIds },\n          itemType: \"LINK_TAG\",\n        },\n        select: {\n          linkId: true,\n          tag: {\n            select: {\n              id: true,\n              name: true,\n              color: true,\n              description: true,\n            },\n          },\n        },\n      });\n\n      // Group tags by linkId for O(1) lookup\n      const tagsByLinkId = tagItems.reduce(\n        (acc, item) => {\n          if (item.linkId) {\n            if (!acc[item.linkId]) {\n              acc[item.linkId] = [];\n            }\n            acc[item.linkId].push(item.tag);\n          }\n          return acc;\n        },\n        {} as Record<string, (typeof tagItems)[0][\"tag\"][]>,\n      );\n\n      // Map links with decrypted passwords and tags\n      const linksWithTags = links.map((link) => {\n        // Decrypt the password if it exists\n        const decryptedPassword =\n          link.password !== null\n            ? decryptEncrpytedPassword(link.password)\n            : null;\n\n        return {\n          ...link,\n          password: decryptedPassword,\n          tags: tagsByLinkId[link.id] || [],\n        };\n      });\n\n      return res.status(200).json(linksWithTags);\n    } catch (error) {\n      log({\n        message: `Failed to get links for document: _${docId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/overview.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getLimits } from \"@/ee/limits/server\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { serializeFileSize } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id: docId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    // First verify user has access to the team\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId,\n          },\n        },\n      },\n      select: { plan: true },\n    });\n\n    if (!team) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // Parallel fetch of core data\n    const [document, limits, featureFlags] = await Promise.all([\n      prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        select: {\n          id: true,\n          name: true,\n          description: true,\n          file: true,\n          originalFile: true,\n          type: true,\n          contentType: true,\n          storageType: true,\n          numPages: true,\n          ownerId: true,\n          teamId: true,\n          agentsEnabled: true,\n          advancedExcelEnabled: true,\n          downloadOnly: true,\n          createdAt: true,\n          updatedAt: true,\n          folderId: true,\n          isExternalUpload: true,\n          versions: {\n            where: { isPrimary: true },\n            orderBy: { createdAt: \"desc\" },\n            take: 1,\n          },\n          folder: {\n            select: {\n              name: true,\n              path: true,\n            },\n          },\n          datarooms: {\n            select: {\n              dataroom: {\n                select: {\n                  id: true,\n                  name: true,\n                },\n              },\n              folder: {\n                select: {\n                  id: true,\n                  name: true,\n                  path: true,\n                },\n              },\n            },\n          },\n          // Get counts without fetching full records\n          _count: {\n            select: {\n              links: true,\n              views: { where: { isArchived: false } },\n            },\n          },\n        },\n      }),\n      getLimits({ teamId, userId }),\n      getFeatureFlags({ teamId }),\n    ]);\n\n    if (!document || !document.versions || document.versions.length === 0) {\n      return res.status(404).json({\n        error: \"Not Found\",\n        message: \"The requested document does not exist\",\n      });\n    }\n\n    const primaryVersion = document.versions[0];\n    const hasLinks = document._count.links > 0;\n    const hasViews = document._count.views > 0;\n\n    // Check for page links only if needed\n    let hasPageLinks = false;\n    if (primaryVersion && team.plan.includes(\"free\")) {\n      const pageLinksCount = await prisma.documentPage.count({\n        where: {\n          versionId: primaryVersion.id,\n          pageLinks: {\n            not: Prisma.JsonNull,\n          },\n        },\n      });\n      hasPageLinks = pageLinksCount > 0;\n    }\n\n    // Basic response for instant loading\n    const response = {\n      document: {\n        ...serializeFileSize(document),\n        primaryVersion: serializeFileSize(primaryVersion),\n        hasPageLinks,\n        isEmpty: !hasLinks && !hasViews, // Flag for empty state optimization\n      },\n      limits: {\n        canAddLinks: limits?.links ? limits?.usage?.links < limits.links : true,\n        canAddDocuments: limits?.documents\n          ? limits?.usage?.documents < limits.documents\n          : true,\n        canAddUsers: limits?.users ? limits?.usage?.users < limits.users : true,\n      },\n      featureFlags: {\n        annotations: featureFlags.annotations,\n      },\n      team: {\n        plan: team?.plan || \"free\",\n        isTrial: team?.plan.includes(\"drtrial\") || false,\n      },\n      counts: {\n        links: document._count.links,\n        views: document._count.views,\n      },\n    };\n\n    // Set cache headers for faster subsequent loads\n    res.setHeader(\n      \"Cache-Control\",\n      \"private, max-age=60, stale-while-revalidate=300\",\n    );\n\n    return res.status(200).json(response);\n  } catch (error) {\n    console.error(\"Document overview error:\", error);\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/preview-data.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getTeamStorageConfigById } from \"@/ee/features/storage/config\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nimport { authOptions } from \"../../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const { id: documentId, teamId } = req.query as {\n    id: string;\n    teamId: string;\n  };\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: { userId },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(403).json({ message: \"Access denied\" });\n    }\n\n    // Fetch document and verify team membership\n    const document = await prisma.document.findUnique({\n      where: { id: documentId },\n      include: {\n        team: {\n          include: {\n            users: {\n              where: { userId },\n              select: { userId: true },\n            },\n          },\n        },\n        versions: {\n          where: { isPrimary: true },\n          select: {\n            id: true,\n            type: true,\n            hasPages: true,\n            numPages: true,\n            isVertical: true,\n            file: true,\n            storageType: true,\n            pages: {\n              orderBy: { pageNumber: \"asc\" },\n              select: {\n                file: true,\n                storageType: true,\n                pageNumber: true,\n                embeddedLinks: true,\n                pageLinks: true,\n                metadata: true,\n              },\n            },\n          },\n        },\n      },\n    });\n\n    // Check if document exists and user is team member\n    if (!document || document.team.users.length === 0) {\n      return res.status(403).json({ message: \"Access denied\" });\n    }\n\n    const primaryVersion = document.versions[0];\n    if (!primaryVersion) {\n      return res.status(404).json({ message: \"Document version not found\" });\n    }\n\n    // Prepare return data structure\n    const returnData = {\n      documentId,\n      documentName: document.name,\n      documentType: document.type,\n      fileType: primaryVersion.type,\n      isVertical: primaryVersion.isVertical,\n      numPages: primaryVersion.numPages,\n      advancedExcelEnabled: document.advancedExcelEnabled,\n      pages: undefined as any,\n      file: undefined as string | undefined,\n      sheetData: undefined as any,\n    };\n\n    const INITIAL_PAGES_TO_LOAD = 10;\n\n    // Handle different file types\n    if (primaryVersion.hasPages && primaryVersion.pages.length > 0) {\n      // Documents with pages (PDFs, docs, slides)\n      // Only sign URLs for the first batch of pages to avoid timeouts on large documents.\n      // Remaining page URLs are fetched on-demand by the client via preview-pages endpoint.\n      returnData.pages = await Promise.all(\n        primaryVersion.pages.map(async (page, index) => {\n          const { storageType, ...otherPageData } = page;\n          return {\n            ...otherPageData,\n            file:\n              index < INITIAL_PAGES_TO_LOAD\n                ? await getFile({ data: page.file, type: storageType })\n                : null,\n          };\n        }),\n      );\n    } else if (primaryVersion.type === \"image\") {\n      // Single image files\n      returnData.file = await getFile({\n        data: primaryVersion.file,\n        type: primaryVersion.storageType,\n      });\n      returnData.numPages = 1;\n    } else if (primaryVersion.type === \"sheet\") {\n      if (document.advancedExcelEnabled) {\n        // Advanced Excel mode: use Office Online viewer URL\n        if (!primaryVersion.file.includes(\"https://\")) {\n          const storageConfig = await getTeamStorageConfigById(document.teamId);\n          returnData.file = `https://${storageConfig.advancedDistributionHost}/${primaryVersion.file}`;\n        } else {\n          returnData.file = primaryVersion.file;\n        }\n        returnData.numPages = 1;\n      }\n      // Non-advanced sheets: return 200 with advancedExcelEnabled=false so\n      // PreviewViewer renders its inline fallback instead of showing an error.\n    } else if (primaryVersion.type === \"notion\") {\n      // Notion documents - preview not supported\n      return res.status(400).json({\n        message: \"Notion document preview coming soon\",\n      });\n    } else {\n      // Check if document should be processed but isn't\n      const shouldHavePages = [\"pdf\", \"docs\", \"slides\", \"cad\"].includes(\n        primaryVersion.type || \"\",\n      );\n\n      if (shouldHavePages) {\n        return res.status(400).json({\n          message: \"Document is still processing. Please wait and try again.\",\n        });\n      } else {\n        return res.status(400).json({\n          message: \"Preview not available for this document type\",\n        });\n      }\n    }\n\n    return res.status(200).json(returnData);\n  } catch (error) {\n    log({\n      message: \"Error fetching document preview data\",\n      type: \"error\",\n      mention: true,\n    });\n    console.error(error);\n    return res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/preview-pages.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { getFile } from \"@/lib/files/get-file\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nimport { authOptions } from \"../../../../auth/[...nextauth]\";\n\nconst MAX_PAGES_PER_REQUEST = 50;\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const { id: documentId, teamId } = req.query as {\n    id: string;\n    teamId: string;\n  };\n  const userId = (session.user as CustomUser).id;\n\n  const { pageNumbers } = req.body as { pageNumbers: number[] };\n\n  if (!pageNumbers || pageNumbers.length === 0) {\n    return res.status(400).json({ message: \"pageNumbers is required\" });\n  }\n\n  if (pageNumbers.length > MAX_PAGES_PER_REQUEST) {\n    return res.status(400).json({\n      message: `Cannot request more than ${MAX_PAGES_PER_REQUEST} pages at once.`,\n    });\n  }\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: { userId },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(403).json({ message: \"Access denied\" });\n    }\n\n    const document = await prisma.document.findUnique({\n      where: { id: documentId },\n      select: {\n        teamId: true,\n        versions: {\n          where: { isPrimary: true },\n          select: { id: true },\n          take: 1,\n        },\n      },\n    });\n\n    if (!document || document.teamId !== teamId) {\n      return res.status(403).json({ message: \"Access denied\" });\n    }\n\n    const primaryVersion = document.versions[0];\n    if (!primaryVersion) {\n      return res.status(404).json({ message: \"Document version not found\" });\n    }\n\n    const documentPages = await prisma.documentPage.findMany({\n      where: {\n        versionId: primaryVersion.id,\n        pageNumber: { in: pageNumbers },\n      },\n      select: {\n        file: true,\n        storageType: true,\n        pageNumber: true,\n      },\n    });\n\n    const pagesWithUrls = await Promise.all(\n      documentPages.map(async (page) => {\n        const { storageType, ...otherPage } = page;\n        return {\n          pageNumber: otherPage.pageNumber,\n          file: await getFile({ data: page.file, type: storageType }),\n        };\n      }),\n    );\n\n    return res.status(200).json({ pages: pagesWithUrls });\n  } catch (error) {\n    log({\n      message: \"Error fetching preview page URLs\",\n      type: \"error\",\n      mention: true,\n    });\n    console.error(error);\n    return res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/stats.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { View } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport {\n  getTotalAvgPageDuration,\n  getTotalDocumentDuration,\n} from \"@/lib/tinybird\";\nimport {\n  getVideoEventsByDocument,\n  getViewCompletionStats,\n} from \"@/lib/tinybird/pipes\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id/stats\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: docId,\n      excludeTeamMembers,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      excludeTeamMembers?: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamHasUser = await prisma.team.findUnique({\n        where: { id: teamId, users: { some: { userId } } },\n      });\n\n      if (!teamHasUser) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // First check if document exists and get basic info\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        select: {\n          id: true,\n          teamId: true,\n          numPages: true,\n          type: true,\n          versions: {\n            orderBy: { createdAt: \"desc\" },\n            select: {\n              versionNumber: true,\n              createdAt: true,\n              numPages: true,\n              type: true,\n              length: true,\n            },\n          },\n          _count: {\n            select: {\n              views: { where: { isArchived: false } },\n            },\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      // Early return for documents with no views - avoid expensive queries\n      if (document._count.views === 0) {\n        return res.status(200).json({\n          views: [],\n          duration: { data: [] },\n          total_duration: 0,\n          avgCompletionRate: 0,\n          totalViews: 0,\n        });\n      }\n\n      // Only fetch views and users if we have views\n      const [views, users] = await Promise.all([\n        prisma.view.findMany({\n          where: {\n            documentId: docId,\n          },\n        }),\n        excludeTeamMembers\n          ? prisma.user.findMany({\n              where: {\n                teams: {\n                  some: {\n                    teamId: teamId,\n                  },\n                },\n              },\n              select: {\n                email: true,\n              },\n            })\n          : Promise.resolve([]),\n      ]);\n\n      const activeViews = views.filter((view) => !view.isArchived);\n      const archivedViews = views.filter((view) => view.isArchived);\n\n      // exclude views from the team's members\n      let internalViews: View[] = [];\n      if (excludeTeamMembers) {\n        internalViews = activeViews.filter((view) => {\n          return users.some((user) => user.email === view.viewerEmail);\n        });\n      }\n\n      // combined archived and internal views\n      const allExcludedViews = [...internalViews, ...archivedViews];\n\n      // filter out the excluded views\n      const filteredViews = views.filter(\n        (view) => !allExcludedViews.map((view) => view.id).includes(view.id),\n      );\n\n      const [duration, totalDocumentDuration] = await Promise.all([\n        getTotalAvgPageDuration({\n          documentId: docId,\n          excludedLinkIds: \"\",\n          excludedViewIds: allExcludedViews.map((view) => view.id).join(\",\"),\n          since: 0,\n        }),\n        getTotalDocumentDuration({\n          documentId: docId,\n          excludedLinkIds: \"\",\n          excludedViewIds: allExcludedViews.map((view) => view.id).join(\",\"),\n          since: 0,\n        }),\n      ]);\n\n      // Calculate average completion rate for all filtered views\n      let avgCompletionRate = 0;\n      if (filteredViews.length > 0) {\n        if (document.type === \"video\") {\n          // For video documents, calculate based on unique watch time\n          const videoEvents = await getVideoEventsByDocument({\n            document_id: docId,\n          });\n\n          const completionRates = await Promise.all(\n            filteredViews.map(async (view) => {\n              const viewEvents =\n                videoEvents?.data.filter(\n                  (event: any) =>\n                    event.view_id === view.id &&\n                    [\"played\", \"muted\", \"unmuted\", \"rate_changed\"].includes(\n                      event.event_type,\n                    ) &&\n                    event.end_time > event.start_time &&\n                    event.end_time - event.start_time >= 1,\n                ) || [];\n\n              const uniqueTimestamps = new Set<number>();\n              viewEvents.forEach((event: any) => {\n                for (let t = event.start_time; t < event.end_time; t++) {\n                  uniqueTimestamps.add(Math.floor(t));\n                }\n              });\n\n              const videoLength = document.versions[0]?.length || 0;\n              return videoLength > 0\n                ? Math.min(100, (uniqueTimestamps.size / videoLength) * 100)\n                : 0;\n            }),\n          );\n\n          avgCompletionRate =\n            completionRates.reduce((sum, rate) => sum + rate, 0) /\n            completionRates.length;\n        } else {\n          // For document type, calculate based on pages viewed\n          const completionStats = await getViewCompletionStats({\n            documentId: docId,\n            excludedViewIds: allExcludedViews.map((v) => v.id).join(\",\"),\n            since: 0,\n          });\n\n          // Build lookup map for O(1) access: viewId -> { versionNumber, pages_viewed }\n          const statsMap = new Map(\n            completionStats.data.map((s) => [\n              s.viewId,\n              { versionNumber: s.versionNumber, pagesViewed: s.pages_viewed },\n            ]),\n          );\n\n          const completionRates = filteredViews.map((view) => {\n            const viewStats = statsMap.get(view.id);\n            if (!viewStats) return 0;\n\n            // Find the version that matches the versionNumber from Tinybird\n            const relevantVersion = document.versions.find(\n              (version) => version.versionNumber === viewStats.versionNumber,\n            );\n            const numPages =\n              relevantVersion?.numPages || document.numPages || 0;\n\n            return numPages > 0 ? (viewStats.pagesViewed / numPages) * 100 : 0;\n          });\n\n          avgCompletionRate =\n            completionRates.reduce((sum, rate) => sum + rate, 0) /\n            completionRates.length;\n        }\n      }\n\n      const stats = {\n        views: filteredViews,\n        duration,\n        total_duration:\n          (totalDocumentDuration.data[0].sum_duration * 1.0) /\n          filteredViews.length,\n        avgCompletionRate: Math.round(avgCompletionRate),\n        totalViews: filteredViews.length,\n      };\n\n      return res.status(200).json(stats);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/toggle-dark-mode.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"PATCH\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId, id: documentId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const { darkMode } = req.body as { darkMode: boolean };\n\n  try {\n    // Check if user has access to the team\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const documentVersion = await prisma.documentVersion.findFirst({\n      where: {\n        documentId: documentId,\n      },\n      select: {\n        file: true,\n        type: true,\n      },\n    });\n\n    if (!documentVersion) {\n      return res.status(404).json({ message: \"Document version not found\" });\n    }\n\n    if (documentVersion.type !== \"notion\") {\n      return res\n        .status(400)\n        .json({ message: \"Document is not a Notion document\" });\n    }\n\n    const notionUrl = new URL(documentVersion.file);\n    if (darkMode) {\n      notionUrl.searchParams.set(\"mode\", \"dark\");\n    } else {\n      notionUrl.searchParams.delete(\"mode\");\n    }\n\n    // Update document version\n    await prisma.documentVersion.updateMany({\n      where: {\n        documentId: documentId,\n      },\n      data: {\n        file: notionUrl.toString(),\n      },\n    });\n\n    await fetch(\n      `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&documentId=${documentId}`,\n    );\n\n    return res.status(200).json({\n      message: `Notion document theme changed to ${darkMode ? \"dark\" : \"light\"} mode`,\n    });\n  } catch (error) {\n    console.error(error);\n    return res.status(500).json({ message: \"Error updating document\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/toggle-download-only.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"PATCH\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId, id: documentId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const { downloadOnly } = req.body as { downloadOnly: boolean };\n\n  try {\n    // Check if user has access to the team\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    // Update document\n    await prisma.document.update({\n      where: {\n        id: documentId,\n        teamId: teamId,\n      },\n      data: {\n        downloadOnly: downloadOnly,\n      },\n    });\n\n    await fetch(\n      `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&documentId=${documentId}`,\n    );\n\n    return res.status(200).json({\n      message: `Document is now ${downloadOnly ? \"download only\" : \"viewable\"}`,\n    });\n  } catch (error) {\n    console.error(error);\n    return res.status(500).json({ message: \"Error updating document\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/update-link-url.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { get } from \"@vercel/edge-config\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { isTrustedTeam } from \"@/lib/edge-config/trusted-teams\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\nimport { linkUrlUpdateSchema } from \"@/lib/zod/url-validation\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"PATCH\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId, id: documentId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const { linkUrl } = req.body as { linkUrl: string };\n\n  const validationResult = await linkUrlUpdateSchema.safeParseAsync(linkUrl);\n  if (!validationResult.success) {\n    return res.status(400).json({ message: validationResult.error.message });\n  }\n\n  const validatedUrl = validationResult.data;\n\n  try {\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n    if (!teamAccess) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    // Check if document exists\n    const document = await prisma.document.findUnique({\n      where: {\n        id: documentId,\n        teamId: teamId,\n      },\n      select: {\n        id: true,\n        file: true,\n        type: true,\n        versions: {\n          select: { id: true, file: true, type: true },\n          orderBy: { createdAt: \"desc\" },\n          take: 1,\n        },\n      },\n    });\n\n    if (!document) {\n      return res.status(404).json({ message: \"Document not found\" });\n    }\n\n    if (document.type !== \"link\") {\n      return res\n        .status(400)\n        .json({ message: \"Document is not a link document\" });\n    }\n\n    if (!document.versions[0]) {\n      return res.status(400).json({ message: \"Document has no versions\" });\n    }\n\n    // Check if URL contains blocked keywords (skip for trusted teams)\n    const trusted = await isTrustedTeam(teamId);\n    if (!trusted) {\n      const keywords = await get(\"keywords\");\n      if (Array.isArray(keywords) && keywords.length > 0) {\n        const matchedKeyword = keywords.find(\n          (keyword) =>\n            typeof keyword === \"string\" &&\n            validatedUrl.toLowerCase().includes(keyword.toLowerCase()),\n        );\n\n        if (matchedKeyword) {\n          log({\n            message: `Link URL update blocked: ${matchedKeyword} \\n\\n \\`Metadata: {teamId: ${teamId}, documentId: ${documentId}, url: ${validatedUrl}}\\``,\n            type: \"error\",\n            mention: true,\n          });\n          return res.status(400).json({\n            message: \"This URL is not allowed\",\n            matchedKeyword: matchedKeyword,\n          });\n        }\n      }\n    }\n\n    // Update document version\n    await prisma.document.update({\n      where: {\n        id: documentId,\n        teamId: teamId,\n      },\n      data: {\n        file: validatedUrl.toString(),\n        versions: {\n          update: {\n            where: { id: document.versions[0].id },\n            data: { file: validatedUrl.toString() },\n          },\n        },\n      },\n    });\n\n    return res.status(200).json({ message: \"Link URL updated successfully\" });\n  } catch (error) {\n    console.error(\"Error updating Link URL:\", error);\n    return res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/update-name.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { sanitizePlainText } from \"@/lib/utils/sanitize-html\";\n\nconst updateNameSchema = z.object({\n  name: z\n    .string()\n    .transform((value) => sanitizePlainText(value))\n    .pipe(\n      z\n        .string()\n        .min(1, \"Document name is required\")\n        .max(255, \"Document name too long\"),\n    ),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // GET /api/teams/:teamId/documents/:id/update-name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Validate input using Zod\n      const validationResult = updateNameSchema.safeParse(req.body);\n      if (!validationResult.success) {\n        return res.status(400).json({\n          error: \"Invalid input\",\n          details: validationResult.error.issues,\n        });\n      }\n\n      const { name } = validationResult.data;\n\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Atomic transaction to update document name\n      const result = await prisma.$transaction(async (tx) => {\n        // Perform atomic update with both teamId and id in the filter\n        const document = await tx.document.findUnique({\n          where: {\n            id: docId,\n            teamId: teamId, // Ensure document belongs to the team\n          },\n          select: { id: true },\n        });\n\n        if (!document) {\n          return res.status(404).json({ error: \"Document not found\" });\n        }\n\n        const updateResult = await tx.document.update({\n          where: { id: docId, teamId },\n          data: { name: name },\n        });\n\n        return updateResult;\n      });\n\n      // Check if any rows were affected\n      if (!result) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      return res.status(200).json({ message: \"Document name updated!\" });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/update-notion-url.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { notionUrlUpdateSchema } from \"@/lib/zod/url-validation\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"PATCH\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n  const { teamId, id: documentId } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const { notionUrl: url } = req.body as { notionUrl: string };\n\n  const validationResult = await notionUrlUpdateSchema.safeParseAsync(url);\n  if (!validationResult.success) {\n    return res.status(400).json({ message: validationResult.error.message });\n  }\n\n  const notionUrl = validationResult.data;\n\n  try {\n    // Check if user has access to the team\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const documentVersion = await prisma.documentVersion.findFirst({\n      where: {\n        documentId: documentId,\n        isPrimary: true,\n      },\n      select: {\n        id: true,\n        file: true,\n        type: true,\n      },\n    });\n\n    if (!documentVersion) {\n      return res.status(404).json({ message: \"Document version not found\" });\n    }\n\n    if (documentVersion.type !== \"notion\") {\n      return res\n        .status(400)\n        .json({ message: \"Document is not a Notion document\" });\n    }\n\n    // Preserve any existing query parameters from the old URL (like dark mode)\n    const oldUrl = new URL(documentVersion.file);\n    const newUrl = new URL(notionUrl);\n\n    // Copy over the mode parameter if it exists\n    const mode = oldUrl.searchParams.get(\"mode\");\n    if (mode) {\n      newUrl.searchParams.set(\"mode\", mode);\n    }\n\n    // Update document version\n    await prisma.documentVersion.updateMany({\n      where: {\n        documentId: documentId,\n        isPrimary: true,\n      },\n      data: {\n        file: newUrl.toString(),\n      },\n    });\n\n    return res.status(200).json({\n      message: \"Notion URL updated successfully\",\n      newUrl: newUrl.toString(),\n    });\n  } catch (error) {\n    console.error(\"Error updating Notion URL:\", error);\n    return res.status(500).json({ message: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/versions/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport { copyFileToBucketServer } from \"@/lib/files/copy-file-to-bucket-server\";\nimport prisma from \"@/lib/prisma\";\nimport { convertFilesToPdfTask } from \"@/lib/trigger/convert-files\";\nimport { processVideo } from \"@/lib/trigger/optimize-video-files\";\nimport { convertPdfToImageRoute } from \"@/lib/trigger/pdf-to-image-route\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\nimport { conversionQueue } from \"@/lib/utils/trigger-utils\";\nimport { documentUploadSchema } from \"@/lib/zod/url-validation\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents/:id/versions\n    const { teamId, id: documentId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    // Check for API token first, then fall back to session auth\n    const authHeader = req.headers.authorization;\n    let userId: string;\n\n    if (authHeader?.startsWith(\"Bearer \")) {\n      const token = authHeader.replace(\"Bearer \", \"\");\n      const hashedToken = hashToken(token);\n\n      const restrictedToken = await prisma.restrictedToken.findUnique({\n        where: { hashedKey: hashedToken },\n        select: { userId: true, teamId: true },\n      });\n\n      if (!restrictedToken) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      if (restrictedToken.teamId !== teamId) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      userId = restrictedToken.userId;\n    } else {\n      const session = await getServerSession(req, res, authOptions);\n      if (!session) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n      userId = (session.user as CustomUser).id;\n    }\n\n    // Validate request body using Zod schema for security\n    const validationResult = await documentUploadSchema.safeParseAsync({\n      ...req.body,\n      name: `Version ${new Date().toISOString()}`, // Dummy name for validation\n    });\n\n    if (!validationResult.success) {\n      log({\n        message: `Document version validation failed for documentId: ${documentId}, teamId: ${teamId}. Errors: ${JSON.stringify(validationResult.error.errors)}`,\n        type: \"error\",\n      });\n      return res.status(400).json({\n        error: \"Invalid document version data\",\n        details: validationResult.error.errors,\n      });\n    }\n\n    const { url, type, numPages, storageType, contentType, fileSize } =\n      validationResult.data;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: {\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New document uploads are not available.\",\n        });\n      }\n\n      const document = await prisma.document.findUnique({\n        where: {\n          id: documentId,\n          teamId,\n        },\n        select: {\n          id: true,\n          advancedExcelEnabled: true,\n          versions: {\n            orderBy: { createdAt: \"desc\" },\n            take: 1,\n            select: { versionNumber: true },\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      // create a new document version\n      const currentVersionNumber = document?.versions\n        ? document.versions[0].versionNumber\n        : 1;\n      const version = await prisma.documentVersion.create({\n        data: {\n          documentId: documentId,\n          file: url,\n          originalFile: url,\n          type: type,\n          storageType,\n          numPages: document?.advancedExcelEnabled ? 1 : numPages,\n          isPrimary: true,\n          versionNumber: currentVersionNumber + 1,\n          contentType,\n          fileSize,\n        },\n      });\n\n      // turn off isPrimary flag for all other versions\n      await prisma.documentVersion.updateMany({\n        where: {\n          documentId: documentId,\n          id: { not: version.id },\n        },\n        data: {\n          isPrimary: false,\n        },\n      });\n\n      if (type === \"docs\" || type === \"slides\") {\n        await convertFilesToPdfTask.trigger(\n          {\n            documentVersionId: version.id,\n            teamId,\n            documentId,\n          },\n          {\n            idempotencyKey: `${teamId}-${version.id}-docs`,\n            tags: [\n              `team_${teamId}`,\n              `document_${documentId}`,\n              `version:${version.id}`,\n            ],\n            queue: conversionQueue(team.plan),\n            concurrencyKey: teamId,\n          },\n        );\n      }\n\n      if (\n        type === \"video\" &&\n        contentType !== \"video/mp4\" &&\n        contentType?.startsWith(\"video/\")\n      ) {\n        await processVideo.trigger(\n          {\n            videoUrl: url,\n            teamId,\n            docId: url.split(\"/\")[1], // Extract doc_xxxx from teamId/doc_xxxx/filename\n            documentVersionId: version.id,\n            fileSize: fileSize || 0,\n          },\n          {\n            idempotencyKey: `${teamId}-${version.id}`,\n            tags: [\n              `team_${teamId}`,\n              `document_${documentId}`,\n              `version:${version.id}`,\n            ],\n            queue: conversionQueue(team.plan),\n            concurrencyKey: teamId,\n          },\n        );\n      }\n\n      // trigger document uploaded event to trigger convert-pdf-to-image job\n      if (type === \"pdf\") {\n        await convertPdfToImageRoute.trigger(\n          {\n            documentId: documentId,\n            documentVersionId: version.id,\n            teamId,\n            // docId: version.file.split(\"/\")[1], // Extract doc_xxxx from teamId/doc_xxxx/filename\n            versionNumber: version.versionNumber,\n          },\n          {\n            idempotencyKey: `${teamId}-${version.id}`,\n            tags: [\n              `team_${teamId}`,\n              `document_${documentId}`,\n              `version:${version.id}`,\n            ],\n            queue: conversionQueue(team.plan),\n            concurrencyKey: teamId,\n          },\n        );\n      }\n\n      if (type === \"sheet\" && document?.advancedExcelEnabled) {\n        console.log(\"copying file to bucket server\");\n        await copyFileToBucketServer({\n          filePath: version.file,\n          storageType: version.storageType,\n          teamId,\n        });\n      }\n\n      res.status(200).json({ id: documentId });\n    } catch (error) {\n      log({\n        message: `Failed to create new version for document: _${documentId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      return res.status(500).json({\n        message: \"Internal Server Error\",\n        error: (error as Error).message,\n      });\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/video-analytics.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { getVideoEventsByDocument } from \"@/lib/tinybird/pipes\";\nimport { CustomUser } from \"@/lib/types\";\n\ninterface AnalyticsResponse {\n  overall: {\n    unique_views: number;\n    total_views: number;\n    total_watch_time: number;\n    avg_view_duration: number;\n    last_viewed_at: string;\n    first_viewed_at: string;\n    view_distribution: Array<{\n      start_time: number;\n      unique_views: number;\n      total_views: number;\n    }>;\n  } | null;\n}\n\nfunction calculateAnalytics(\n  events: Array<{\n    timestamp: string;\n    view_id: string;\n    event_type: string;\n    start_time: number;\n    end_time: number;\n    playback_rate: number;\n    volume: number;\n    is_muted: number;\n    is_focused: number;\n    is_fullscreen: number;\n  }>,\n  videoLength: number,\n): AnalyticsResponse {\n  if (!events || events.length === 0) {\n    return {\n      overall: null,\n    };\n  }\n\n  try {\n    // Filter for valid events and ensure valid time ranges > 1 second\n    const validEvents = events.filter((event) => {\n      // Check if event has required properties\n      if (!event || typeof event.event_type !== \"string\" || !event.view_id) {\n        console.warn(\"Invalid event structure:\", event);\n        return false;\n      }\n\n      // Check if event has valid time properties\n      if (\n        typeof event.start_time !== \"number\" ||\n        typeof event.end_time !== \"number\"\n      ) {\n        console.warn(\"Invalid time properties:\", event);\n        return false;\n      }\n\n      return (\n        (event.event_type === \"played\" ||\n          event.event_type === \"muted\" ||\n          event.event_type === \"unmuted\" ||\n          event.event_type === \"rate_changed\") &&\n        event.end_time > event.start_time &&\n        event.end_time - event.start_time >= 1 &&\n        event.start_time >= 0 &&\n        event.end_time <= videoLength + 10\n      ); // Allow some buffer\n    });\n\n    // Get all unique view_ids from any event type\n    const uniqueViewIds = new Set(events.map((e) => e.view_id));\n\n    // Calculate total watch time\n    let totalWatchTime = 0;\n    validEvents.forEach((event) => {\n      const duration = event.end_time - event.start_time;\n      totalWatchTime += duration;\n    });\n\n    // Create a baseline array with zeros for every second\n    const viewDistributionMap = new Map<\n      number,\n      { uniqueViewers: Set<string>; viewDurations: Map<string, number> }\n    >();\n    for (let t = 0; t <= videoLength; t++) {\n      viewDistributionMap.set(t, {\n        uniqueViewers: new Set(),\n        viewDurations: new Map(), // Map of view_id to number of times this second was viewed\n      });\n    }\n\n    // Fill in the actual playback periods\n    validEvents.forEach((event) => {\n      // For each second in the duration, track the view\n      for (\n        let t = Math.floor(event.start_time);\n        t < Math.ceil(event.end_time);\n        t++\n      ) {\n        const stats = viewDistributionMap.get(t);\n        if (!stats) {\n          console.warn(`No stats found for time ${t}, skipping`);\n          continue;\n        }\n        stats.uniqueViewers.add(event.view_id);\n\n        // Increment the count for this view_id at this second\n        const currentCount = stats.viewDurations.get(event.view_id) || 0;\n        stats.viewDurations.set(event.view_id, currentCount + 1);\n      }\n    });\n\n    // Sort events by timestamp to find first and last view\n    const sortedEvents = [...validEvents].sort(\n      (a, b) =>\n        new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),\n    );\n\n    // Convert view distribution to sorted array with both metrics\n    const distributionArray = Array.from(viewDistributionMap.entries())\n      .map(([start_time, stats]) => {\n        // Sum up all view durations for this second\n        let totalViews = 0;\n        stats.viewDurations.forEach((count) => {\n          totalViews += count;\n        });\n\n        return {\n          start_time,\n          unique_views: stats.uniqueViewers.size,\n          total_views: totalViews,\n        };\n      })\n      .sort((a, b) => a.start_time - b.start_time);\n\n    return {\n      overall: {\n        unique_views: uniqueViewIds.size,\n        total_views: uniqueViewIds.size,\n        total_watch_time: totalWatchTime,\n        avg_view_duration:\n          uniqueViewIds.size > 0 ? totalWatchTime / uniqueViewIds.size : 0,\n        first_viewed_at:\n          sortedEvents.length > 0 ? sortedEvents[0].timestamp : \"\",\n        last_viewed_at:\n          sortedEvents.length > 0\n            ? sortedEvents[sortedEvents.length - 1].timestamp\n            : \"\",\n        view_distribution: distributionArray,\n      },\n    };\n  } catch (error) {\n    console.error(\"Error calculating analytics:\", error);\n    console.error(\"Events data:\", JSON.stringify(events, null, 2));\n    throw error;\n  }\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  try {\n    const session = await getServerSession(req, res, authOptions);\n    const user = session?.user as CustomUser;\n\n    if (!user?.id) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const { teamId, id: documentId } = req.query as {\n      teamId: string;\n      id: string;\n    };\n\n    // Check if user has access to this document and team\n    const document = await prisma.document.findFirst({\n      where: {\n        id: documentId,\n        teamId,\n        team: {\n          users: {\n            some: {\n              userId: user.id,\n            },\n          },\n        },\n      },\n      include: {\n        versions: {\n          where: {\n            isPrimary: true,\n          },\n          select: {\n            length: true,\n          },\n        },\n      },\n    });\n\n    if (!document) {\n      return res.status(404).json({ message: \"Document not found\" });\n    }\n\n    const videoLength = document.versions[0]?.length || 51;\n    if (!videoLength) {\n      return res.status(400).json({ message: \"Video length not found\" });\n    }\n\n    try {\n      // Fetch video events from Tinybird\n      const response = await getVideoEventsByDocument({\n        document_id: documentId,\n      });\n\n      if (!response || !response.data) {\n        console.error(\"Invalid response from Tinybird:\", response);\n        return res\n          .status(500)\n          .json({ message: \"Invalid response from analytics service\" });\n      }\n\n      // Validate that response.data is an array\n      if (!Array.isArray(response.data)) {\n        console.error(\"Response data is not an array:\", response.data);\n        return res\n          .status(500)\n          .json({ message: \"Invalid data format from analytics service\" });\n      }\n\n      const analytics = calculateAnalytics(response.data, videoLength);\n      return res.status(200).json(analytics);\n    } catch (error) {\n      console.error(\"Tinybird error details:\", {\n        error,\n        message: error instanceof Error ? error.message : \"Unknown error\",\n        stack: error instanceof Error ? error.stack : undefined,\n      });\n      return res.status(500).json({\n        message: \"Error fetching video analytics\",\n        error: error instanceof Error ? error.message : \"Unknown error\",\n      });\n    }\n  } catch (error) {\n    console.error(\n      \"Error in /api/teams/[teamId]/documents/[id]/video-analytics:\",\n      {\n        error,\n        message: error instanceof Error ? error.message : \"Unknown error\",\n        stack: error instanceof Error ? error.stack : undefined,\n      },\n    );\n    return res.status(500).json({\n      message: \"Internal Server Error\",\n      error: error instanceof Error ? error.message : \"Unknown error\",\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/views/[viewId]/click-events.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { getClickEventsByView } from \"@/lib/tinybird/pipes\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id, viewId } = req.query as {\n    teamId: string;\n    id: string;\n    viewId: string;\n  };\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n        users: {\n          some: {\n            userId: userId,\n          },\n        },\n      },\n      select: {\n        id: true,\n        plan: true,\n      },\n    });\n\n    if (!team) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    if (team.plan.includes(\"free\")) {\n      return res.status(403).end(\"Forbidden\");\n    }\n\n    const data = await getClickEventsByView({\n      document_id: id,\n      view_id: viewId,\n    });\n\n    return res.status(200).json(data);\n  } catch (error) {\n    log({\n      message: `Failed to get click events for document ${id} and view ${viewId}. \\n\\n ${error}`,\n      type: \"error\",\n      mention: true,\n    });\n    return res.status(500).json({ error: \"Failed to get click events\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/views/[viewId]/custom-fields.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id/views/:viewId/custom-fields\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: docId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (team.plan.includes(\"free\")) {\n        return res.status(403).end(\"Forbidden\");\n      }\n\n      const customFields = await prisma.customFieldResponse.findFirst({\n        where: {\n          viewId: viewId,\n          view: {\n            documentId: docId,\n          },\n        },\n        select: {\n          data: true,\n        },\n      });\n\n      const data = customFields?.data;\n\n      return res.status(200).json(data);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/views/[viewId]/stats.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { getViewPageDuration } from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id/views/:viewId/stats\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: docId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const document = await prisma.document.findUnique({\n        where: {\n          id: docId,\n          teamId,\n        },\n        select: { id: true },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      const duration = await getViewPageDuration({\n        documentId: docId,\n        viewId: viewId,\n        since: 0,\n      });\n\n      const total_duration = duration.data.reduce(\n        (totalDuration, data) => totalDuration + data.sum_duration,\n        0,\n      );\n\n      const stats = { duration, total_duration };\n\n      return res.status(200).json(stats);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/views/[viewId]/user-agent.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { getViewUserAgent, getViewUserAgent_v2 } from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/:id/views/:viewId/user-agent\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const {\n      teamId,\n      id: docId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (team.plan.includes(\"free\")) {\n        return res.status(403).end(\"Forbidden\");\n      }\n\n      let userAgent: {\n        rows?: number | undefined;\n        data: {\n          country: string;\n          city: string;\n          browser: string;\n          os: string;\n          device: string;\n        }[];\n      };\n\n      userAgent = await getViewUserAgent({\n        viewId: viewId,\n      });\n\n      if (!userAgent || userAgent.rows === 0) {\n        userAgent = await getViewUserAgent_v2({\n          documentId: docId,\n          viewId: viewId,\n          since: 0,\n        });\n      }\n\n      const userAgentData = userAgent.data[0];\n      // Include country and city for business and datarooms plans\n      if (team.plan.includes(\"business\") || team.plan.includes(\"datarooms\")) {\n        return res.status(200).json(userAgentData);\n      } else {\n        // For other plans, exclude country and city\n        const { country, city, ...remainingResponse } = userAgentData;\n        return res.status(200).json(remainingResponse);\n      }\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/views/[viewId]/video-stats.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { getVideoEventsByView } from \"@/lib/tinybird/pipes\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  try {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const {\n      teamId,\n      id: documentId,\n      viewId,\n    } = req.query as {\n      teamId: string;\n      id: string;\n      viewId: string;\n    };\n    const userId = (session.user as CustomUser).id;\n\n    // Check document access\n    const doc = await prisma.document.findFirst({\n      where: {\n        id: documentId,\n        teamId,\n        team: {\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n      },\n      include: {\n        versions: {\n          where: {\n            isPrimary: true,\n          },\n          select: {\n            length: true,\n          },\n        },\n      },\n    });\n\n    if (!doc) {\n      return res.status(404).json({ error: \"Document not found\" });\n    }\n\n    const videoLength = doc.versions[0]?.length;\n    if (!videoLength) {\n      return res.status(400).json({ error: \"Video length not found\" });\n    }\n\n    // Fetch video events from Tinybird\n    const response = await getVideoEventsByView({\n      view_id: viewId,\n      document_id: documentId,\n    });\n\n    if (!response?.data) {\n      return res.status(200).json({ data: [] });\n    }\n\n    // Filter for valid events and ensure valid time ranges > 1 second\n    const validEvents = response.data.filter(\n      (event) =>\n        (event.event_type === \"played\" ||\n          event.event_type === \"muted\" ||\n          event.event_type === \"unmuted\" ||\n          event.event_type === \"rate_changed\") &&\n        event.end_time > event.start_time &&\n        event.end_time - event.start_time >= 1,\n    );\n\n    // Create a baseline array with zeros for every second\n    const viewDistributionMap = new Map<number, number>();\n    for (let t = 0; t <= videoLength; t++) {\n      viewDistributionMap.set(t, 0);\n    }\n\n    // Fill in the actual playback periods\n    validEvents.forEach((event) => {\n      // For each second in the duration, increment the view count\n      for (let t = event.start_time; t < event.end_time; t++) {\n        viewDistributionMap.set(t, (viewDistributionMap.get(t) || 0) + 1);\n      }\n    });\n\n    // Convert to sorted array\n    const distributionArray = Array.from(viewDistributionMap.entries())\n      .map(([start_time, views]) => ({\n        start_time,\n        views,\n      }))\n      .sort((a, b) => a.start_time - b.start_time);\n\n    return res.status(200).json({\n      data: distributionArray,\n    });\n  } catch (error) {\n    console.error(\"Error fetching video stats:\", error);\n    return res.status(500).json({ error: \"Internal Server Error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/views/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPaused } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { View } from \"@prisma/client\";\nimport { JsonValue } from \"@prisma/client/runtime/library\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { LIMITS } from \"@/lib/constants\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { getViewPageDuration } from \"@/lib/tinybird\";\nimport { getVideoEventsByDocument } from \"@/lib/tinybird/pipes\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\ntype DocumentVersion = {\n  versionNumber: number;\n  createdAt: Date;\n  numPages: number | null;\n  type: string | null;\n  length: number | null;\n};\n\ntype Document = {\n  id: string;\n  versions: DocumentVersion[];\n  numPages: number | null;\n  type: string | null;\n  ownerId: string | null;\n  _count: {\n    views: number;\n  };\n};\n\ntype VideoEvent = {\n  view_id: string;\n  start_time: number;\n  end_time: number;\n  event_type: string;\n};\n\ntype ViewWithExtras = View & {\n  link: { name: string | null };\n  feedbackResponse: {\n    id: string;\n    data: JsonValue;\n  } | null;\n  agreementResponse: {\n    id: string;\n    agreementId: string;\n    agreement: { name: string };\n  } | null;\n};\n\nasync function getVideoViews(\n  views: ViewWithExtras[],\n  document: Document,\n  videoEvents: { data: VideoEvent[] },\n) {\n  const durationsPromises = views.map((view) => {\n    const viewEvents =\n      videoEvents?.data.filter(\n        (event) =>\n          event.view_id === view.id &&\n          [\"played\", \"muted\", \"unmuted\", \"rate_changed\"].includes(\n            event.event_type,\n          ) &&\n          event.end_time > event.start_time &&\n          event.end_time - event.start_time >= 1,\n      ) || [];\n\n    // Track timestamps and their frequency for total watch time\n    const timestampCounts = new Map<number, number>();\n    // Track unique timestamps for completion calculation\n    const uniqueTimestamps = new Set<number>();\n\n    // Calculate total watch time\n    // let totalWatchTime = 0;\n    viewEvents.forEach((event) => {\n      for (let t = event.start_time; t < event.end_time; t++) {\n        const timestamp = Math.floor(t);\n        // Count total occurrences including replays\n        timestampCounts.set(\n          timestamp,\n          (timestampCounts.get(timestamp) || 0) + 1,\n        );\n        // Track unique timestamps\n        uniqueTimestamps.add(timestamp);\n      }\n    });\n\n    // Sum up all timestamps including duplicates for total watch time\n    let totalWatchTime = 0;\n    timestampCounts.forEach((count) => {\n      totalWatchTime += count;\n    });\n\n    // Get the number of unique timestamps watched\n    const uniqueWatchTime = uniqueTimestamps.size;\n\n    return {\n      data: [],\n      totalWatchTime,\n      uniqueWatchTime,\n      videoLength: document.versions[0]?.length || 0,\n    };\n  });\n\n  const durations = await Promise.all(durationsPromises);\n\n  return views.map((view, index) => {\n    const relevantDocumentVersion = document.versions.find(\n      (version) => version.createdAt <= view.viewedAt,\n    );\n\n    const duration = durations[index];\n    const completionRate =\n      duration.videoLength > 0\n        ? Math.min(100, (duration.uniqueWatchTime / duration.videoLength) * 100)\n        : 0;\n\n    return {\n      ...view,\n      duration: durations[index],\n      totalDuration: duration.totalWatchTime * 1000, // convert to milliseconds\n      completionRate: completionRate.toFixed(),\n      versionNumber: relevantDocumentVersion?.versionNumber || 1,\n      versionNumPages: 0,\n    };\n  });\n}\n\nasync function getDocumentViews(views: ViewWithExtras[], document: Document) {\n  const durationsPromises = views.map((view) => {\n    return getViewPageDuration({\n      documentId: document.id,\n      viewId: view.id,\n      since: 0,\n    });\n  });\n\n  const durations = await Promise.all(durationsPromises);\n\n  return views.map((view, index) => {\n    const relevantDocumentVersion = document.versions.find(\n      (version) => version.createdAt <= view.viewedAt,\n    );\n\n    const numPages =\n      relevantDocumentVersion?.numPages || document.numPages || 0;\n    const completionRate = numPages\n      ? (durations[index].data.length / numPages) * 100\n      : 0;\n\n    return {\n      ...view,\n      duration: durations[index],\n      totalDuration: durations[index].data.reduce(\n        (total: number, data: { sum_duration: number }) =>\n          total + data.sum_duration,\n        0,\n      ),\n      completionRate: completionRate.toFixed(),\n      versionNumber: relevantDocumentVersion?.versionNumber || 1,\n      versionNumPages: numPages,\n    };\n  });\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: docId } = req.query as { teamId: string; id: string };\n\n    // Parse and validate pagination parameters\n    const rawPage = Number.parseInt((req.query.page as string) || \"1\", 10);\n    const rawLimit = Number.parseInt((req.query.limit as string) || \"10\", 10);\n\n    // Apply defaults for invalid values and enforce constraints\n    const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;\n    const limit =\n      Number.isNaN(rawLimit) || rawLimit < 1\n        ? 10\n        : Math.min(Math.max(rawLimit, 1), 100); // Min 1, Max 100\n    const offset = (page - 1) * limit;\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: {\n          plan: true,\n          pausedAt: true,\n          pauseStartsAt: true,\n          pauseEndsAt: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(404).end(\"Team not found\");\n      }\n\n      const document = await prisma.document.findUnique({\n        where: { id: docId, teamId: teamId },\n        select: {\n          id: true,\n          ownerId: true,\n          numPages: true,\n          type: true,\n          versions: {\n            orderBy: { createdAt: \"desc\" },\n            select: {\n              versionNumber: true,\n              createdAt: true,\n              numPages: true,\n              type: true,\n              length: true,\n            },\n          },\n          _count: {\n            select: {\n              views: true,\n            },\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).end(\"Document not found\");\n      }\n\n      const pauseStartedAt = team.pauseStartsAt;\n\n      // Build where clause for views - if team is paused, only show views before pause date\n      const viewsWhereClause = {\n        documentId: docId,\n        isArchived: false,\n        ...(pauseStartedAt && {\n          viewedAt: {\n            lt: pauseStartedAt,\n          },\n        }),\n      };\n\n      // Check if document has any views first to avoid expensive query\n      const viewCount = await prisma.view.count({\n        where: viewsWhereClause,\n      });\n\n      if (viewCount === 0) {\n        return res.status(200).json({\n          viewsWithDuration: [],\n          hiddenViewCount: 0,\n          totalViews: 0,\n        });\n      }\n\n      const views = await prisma.view.findMany({\n        skip: offset,\n        take: limit,\n        where: {\n          documentId: docId,\n          ...(pauseStartedAt && {\n            viewedAt: {\n              lt: pauseStartedAt,\n            },\n          }),\n        },\n        orderBy: {\n          viewedAt: \"desc\",\n        },\n        include: {\n          link: {\n            select: {\n              name: true,\n            },\n          },\n          feedbackResponse: {\n            select: {\n              id: true,\n              data: true,\n            },\n          },\n          agreementResponse: {\n            select: {\n              id: true,\n              agreementId: true,\n              agreement: {\n                select: {\n                  name: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!views) {\n        return res.status(404).end(\"Document has no views\");\n      }\n\n      const users = await prisma.user.findMany({\n        where: {\n          teams: {\n            some: {\n              teamId: teamId,\n            },\n          },\n        },\n        select: {\n          email: true,\n        },\n      });\n\n      // Get total view count (including views after pause date for accurate count)\n      const totalViewCount = await prisma.view.count({\n        where: {\n          documentId: docId,\n          isArchived: false,\n        },\n      });\n\n      // Calculate hidden views due to pause (views after pause date)\n      const hiddenViewsFromPause = pauseStartedAt\n        ? await prisma.view.count({\n            where: {\n              documentId: docId,\n              isArchived: false,\n              viewedAt: {\n                gte: pauseStartedAt,\n              },\n            },\n          })\n        : 0;\n\n      // filter the last 20 views for free plan\n      const limitedViews =\n        team.plan === \"free\" && offset >= LIMITS.views ? [] : views;\n\n      let viewsWithDuration;\n      if (document.type === \"video\") {\n        const videoEvents = await getVideoEventsByDocument({\n          document_id: docId,\n        });\n        viewsWithDuration = await getVideoViews(\n          limitedViews,\n          document,\n          videoEvents,\n        );\n      } else {\n        viewsWithDuration = await getDocumentViews(limitedViews, document);\n      }\n\n      // Add internal flag to all views\n      viewsWithDuration = viewsWithDuration.map((view) => ({\n        ...view,\n        internal: users.some((user) => user.email === view.viewerEmail),\n      }));\n\n      // Calculate total hidden views (free plan limits + paused team filtering)\n      const hiddenFromFreePlan = views.length - limitedViews.length;\n      const totalHiddenViews = hiddenFromFreePlan + hiddenViewsFromPause;\n\n      return res.status(200).json({\n        viewsWithDuration,\n        hiddenViewCount: totalHiddenViews,\n        totalViews: totalViewCount,\n        hiddenFromPause: hiddenViewsFromPause, // Optional: to show specific pause-related hidden count\n      });\n    } catch (error) {\n      log({\n        message: `Failed to get views for document: _${docId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/[id]/views-count.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id } = req.query as {\n    teamId: string;\n    id: string;\n  };\n  const userId = (session.user as CustomUser).id;\n\n  if (req.method === \"GET\") {\n    try {\n      // Verify user has access to the document\n      const document = await prisma.document.findFirst({\n        where: {\n          id: id,\n          teamId: teamId,\n          team: {\n            users: {\n              some: {\n                userId: userId,\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          _count: {\n            select: {\n              views: true,\n            },\n          },\n        },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      return res.status(200).json({ count: document._count.views });\n    } catch (error) {\n      console.error(\"Error fetching view count:\", error);\n      return res.status(500).json({ error: \"Failed to fetch view count\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"GET\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/agreement.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { convertFilesToPdfTask } from \"@/lib/trigger/convert-files\";\nimport { convertPdfToImageRoute } from \"@/lib/trigger/pdf-to-image-route\";\nimport { CustomUser } from \"@/lib/types\";\nimport { getExtension, log, serializeFileSize } from \"@/lib/utils\";\nimport { conversionQueue } from \"@/lib/utils/trigger-utils\";\nimport { documentUploadSchema } from \"@/lib/zod/url-validation\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents/agreement\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    // Validate request body using Zod schema for security\n    const validationResult = await documentUploadSchema.safeParseAsync({\n      ...req.body,\n      // Ensure type field is provided for validation\n      type: req.body.type || getExtension(req.body.name),\n    });\n\n    if (!validationResult.success) {\n      log({\n        message: `Agreement document validation failed for teamId: ${teamId}. Errors: ${JSON.stringify(validationResult.error.errors)}`,\n        type: \"error\",\n      });\n      return res.status(400).json({\n        error: \"Invalid agreement document data\",\n        details: validationResult.error.errors,\n      });\n    }\n\n    const {\n      name,\n      url: fileUrl,\n      storageType,\n      numPages,\n      type,\n      folderPathName,\n      fileSize,\n      contentType,\n    } = validationResult.data;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: { plan: true },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const folder = await prisma.folder.findUnique({\n        where: {\n          teamId_path: {\n            teamId,\n            path: \"/\" + folderPathName,\n          },\n        },\n        select: {\n          id: true,\n        },\n      });\n\n      // Save data to the database\n      const document = await prisma.document.create({\n        data: {\n          name: name,\n          numPages: numPages,\n          file: fileUrl,\n          originalFile: fileUrl,\n          contentType,\n          type,\n          storageType,\n          ownerId: (session.user as CustomUser).id,\n          teamId: teamId,\n          links: {\n            // create link for agreement without any protection\n            create: {\n              name: \"Generated Agreement Link\",\n              emailProtected: false,\n              enableFeedback: false,\n              enableNotification: false,\n              teamId,\n            },\n          },\n          versions: {\n            create: {\n              file: fileUrl,\n              type,\n              storageType,\n              originalFile: fileUrl,\n              contentType,\n              numPages: numPages,\n              fileSize: fileSize,\n              isPrimary: true,\n              versionNumber: 1,\n            },\n          },\n          folderId: folder?.id ? folder.id : null,\n        },\n        include: {\n          links: true,\n          versions: true,\n        },\n      });\n\n      if (type === \"docs\") {\n        await convertFilesToPdfTask.trigger(\n          {\n            documentId: document.id,\n            documentVersionId: document.versions[0].id,\n            teamId,\n          },\n          {\n            idempotencyKey: `${teamId}-${document.versions[0].id}-docs`,\n            tags: [\n              `team_${teamId}`,\n              `document_${document.id}`,\n              `version:${document.versions[0].id}`,\n            ],\n            queue: conversionQueue(team.plan),\n            concurrencyKey: teamId,\n          },\n        );\n      }\n\n      if (type === \"pdf\") {\n        await convertPdfToImageRoute.trigger(\n          {\n            documentId: document.id,\n            documentVersionId: document.versions[0].id,\n            teamId,\n            // docId: fileUrl.split(\"/\")[1],\n          },\n          {\n            idempotencyKey: `${teamId}-${document.versions[0].id}`,\n            tags: [\n              `team_${teamId}`,\n              `document_${document.id}`,\n              `version:${document.versions[0].id}`,\n            ],\n            queue: conversionQueue(team.plan),\n            concurrencyKey: teamId,\n          },\n        );\n      }\n\n      return res.status(201).json(serializeFileSize(document));\n    } catch (error) {\n      log({\n        message: `Failed to create document. \\n\\n*teamId*: _${teamId}_, \\n\\n*file*: ${fileUrl} \\n\\n ${error}`,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/document-processing-status.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const { documentVersionId } = req.query as { documentVersionId: string };\n\n  const documentVersion = await prisma.documentVersion.findUnique({\n    where: { id: documentVersionId },\n    select: {\n      numPages: true,\n      hasPages: true,\n      _count: { select: { pages: true } },\n    },\n  });\n\n  if (!documentVersion) {\n    return res.status(404).end();\n  }\n\n  const status = {\n    currentPageCount: documentVersion._count.pages,\n    totalPages: documentVersion.numPages,\n    hasPages: documentVersion.hasPages,\n  };\n\n  res.status(200).json(status);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/hidden/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents/hidden\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Fetch root-level hidden folders (folders whose parent is not hidden)\n      // This prevents showing nested hidden folders whose parents are already shown\n      const hiddenFolders = await prisma.folder.findMany({\n        where: {\n          teamId: teamId,\n          hiddenInAllDocuments: true,\n          // Only show root-level hidden folders (parent is null or parent is not hidden)\n          OR: [\n            { parentId: null },\n            {\n              parentFolder: {\n                hiddenInAllDocuments: false,\n              },\n            },\n          ],\n        },\n        include: {\n          _count: {\n            select: {\n              documents: true,\n              childFolders: true,\n            },\n          },\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      // Fetch root-level hidden documents (documents not in a hidden folder)\n      // Only show documents that are directly hidden, not documents in hidden folders\n      const hiddenDocuments = await prisma.document.findMany({\n        where: {\n          teamId: teamId,\n          hiddenInAllDocuments: true,\n          // Only show documents that are either:\n          // 1. In no folder (root level)\n          // 2. In a folder that is NOT hidden (document was individually hidden)\n          OR: [\n            { folderId: null },\n            {\n              folder: {\n                hiddenInAllDocuments: false,\n              },\n            },\n          ],\n        },\n        include: {\n          folder: {\n            select: {\n              name: true,\n              path: true,\n            },\n          },\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      // Get counts for documents\n      const documentIds = hiddenDocuments.map((d) => d.id);\n\n      const [linkCounts, viewCounts, versionCounts, dataroomCounts] =\n        await Promise.all([\n          prisma.link.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n              deletedAt: null,\n            },\n            _count: { id: true },\n          }),\n          prisma.view.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n          prisma.documentVersion.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n          prisma.dataroomDocument.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n        ]);\n\n      // Create lookup maps for counts\n      const linkCountMap = new Map(\n        linkCounts.map((lc) => [lc.documentId, lc._count.id]),\n      );\n      const viewCountMap = new Map(\n        viewCounts.map((vc) => [vc.documentId, vc._count.id]),\n      );\n      const versionCountMap = new Map(\n        versionCounts.map((vsc) => [vsc.documentId, vsc._count.id]),\n      );\n      const dataroomCountMap = new Map(\n        dataroomCounts.map((dc) => [dc.documentId, dc._count.id]),\n      );\n\n      // Combine documents with their counts\n      const documentsWithCounts = hiddenDocuments.map((document) => ({\n        ...document,\n        _count: {\n          links: linkCountMap.get(document.id) || 0,\n          views: viewCountMap.get(document.id) || 0,\n          versions: versionCountMap.get(document.id) || 0,\n          datarooms: dataroomCountMap.get(document.id) || 0,\n        },\n      }));\n\n      return res.status(200).json({\n        folders: hiddenFolders,\n        documents: documentsWithCounts,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/hide.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents/hide\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    const { documentIds, hidden } = req.body as {\n      documentIds: string[];\n      hidden: boolean;\n    };\n\n    if (!documentIds || !Array.isArray(documentIds) || documentIds.length === 0) {\n      return res.status(400).json({ error: \"Document IDs are required\" });\n    }\n\n    if (typeof hidden !== \"boolean\") {\n      return res.status(400).json({ error: \"Hidden flag is required\" });\n    }\n\n    try {\n      // Check team access\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Update the documents\n      const result = await prisma.document.updateMany({\n        where: {\n          id: { in: documentIds },\n          teamId,\n        },\n        data: {\n          hiddenInAllDocuments: hidden,\n        },\n      });\n\n      return res.status(200).json({\n        message: `${result.count} document(s) ${hidden ? \"hidden\" : \"unhidden\"} successfully`,\n        count: result.count,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport { processDocument } from \"@/lib/api/documents/process-document\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log, serializeFileSize } from \"@/lib/utils\";\nimport { supportsAdvancedExcelMode } from \"@/lib/utils/get-content-type\";\nimport { documentUploadSchema } from \"@/lib/zod/url-validation\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/documents\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const { query, sort } = req.query as { query?: string; sort?: string };\n    const userId = (session.user as CustomUser).id;\n\n    const usePagination = !!(query || sort);\n    const page = usePagination ? Number(req.query.page) || 1 : undefined;\n    const limit = usePagination ? Number(req.query.limit) || 10 : undefined;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      let orderBy: Prisma.DocumentOrderByWithRelationInput;\n\n      if (query || sort) {\n        switch (sort) {\n          case \"createdAt\":\n            orderBy = { createdAt: \"desc\" };\n            break;\n          case \"views\":\n            orderBy = { views: { _count: \"desc\" } };\n            break;\n          case \"name\":\n            orderBy = { name: \"asc\" };\n            break;\n          case \"links\":\n            orderBy = { links: { _count: \"desc\" } };\n            break;\n          default:\n            orderBy = { createdAt: \"desc\" };\n        }\n      } else {\n        orderBy = { createdAt: \"desc\" };\n      }\n\n      // Build the base where clause for All Documents view\n      // Documents should be excluded if:\n      // 1. They are directly hidden (hiddenInAllDocuments: true)\n      // 2. They are in a folder that is hidden (folder.hiddenInAllDocuments: true)\n      const baseWhere = {\n        teamId: teamId,\n        hiddenInAllDocuments: false, // Exclude directly hidden documents\n        ...(query && {\n          name: {\n            contains: query,\n            mode: \"insensitive\" as const,\n          },\n        }),\n        // For root view (no search/sort), only show root-level documents\n        ...(!(query || sort) && {\n          folderId: null,\n        }),\n        // For search/sort view, also exclude documents in hidden folders\n        ...((query || sort) && {\n          OR: [\n            { folderId: null },\n            {\n              folder: {\n                hiddenInAllDocuments: false,\n              },\n            },\n          ],\n        }),\n      };\n\n      const totalDocuments = usePagination\n        ? await prisma.document.count({\n            where: baseWhere,\n          })\n        : undefined;\n\n      // First, get documents without expensive counts\n      const documents = await prisma.document.findMany({\n        where: baseWhere,\n        orderBy,\n        ...(usePagination && {\n          skip: ((page as number) - 1) * (limit as number),\n          take: limit,\n        }),\n        include: {\n          folder: {\n            select: {\n              name: true,\n              path: true,\n            },\n          },\n          ...(sort === \"lastViewed\" && {\n            views: {\n              select: { viewedAt: true },\n              orderBy: { viewedAt: \"desc\" },\n              take: 1,\n            },\n          }),\n        },\n      });\n\n      // Then, get counts efficiently with separate GROUP BY queries\n      const documentIds = documents.map((d) => d.id);\n\n      const [linkCounts, viewCounts, versionCounts, dataroomCounts] =\n        await Promise.all([\n          prisma.link.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n              deletedAt: null,\n            },\n            _count: { id: true },\n          }),\n          prisma.view.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n          prisma.documentVersion.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n          prisma.dataroomDocument.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n        ]);\n\n      // Create lookup maps for counts\n      const linkCountMap = new Map(\n        linkCounts.map((lc) => [lc.documentId, lc._count.id]),\n      );\n      const viewCountMap = new Map(\n        viewCounts.map((vc) => [vc.documentId, vc._count.id]),\n      );\n      const versionCountMap = new Map(\n        versionCounts.map((vsc) => [vsc.documentId, vsc._count.id]),\n      );\n      const dataroomCountMap = new Map(\n        dataroomCounts.map((dc) => [dc.documentId, dc._count.id]),\n      );\n\n      // Combine documents with their counts\n      const documentsWithCounts = documents.map((document) => ({\n        ...document,\n        _count: {\n          links: linkCountMap.get(document.id) || 0,\n          views: viewCountMap.get(document.id) || 0,\n          versions: versionCountMap.get(document.id) || 0,\n          datarooms: dataroomCountMap.get(document.id) || 0,\n        },\n      }));\n\n      let documentsWithFolderList = documentsWithCounts;\n\n      if (query || sort) {\n        documentsWithFolderList = await Promise.all(\n          documentsWithCounts.map(async (doc) => {\n            const folderNames = [];\n            const pathSegments = doc.folder?.path?.split(\"/\") || [];\n\n            if (pathSegments.length > 0) {\n              const folders = await prisma.folder.findMany({\n                where: {\n                  teamId,\n                  path: {\n                    in: pathSegments.map((_, index) =>\n                      pathSegments.slice(0, index + 1).join(\"/\"),\n                    ),\n                  },\n                },\n                select: {\n                  path: true,\n                  name: true,\n                },\n                orderBy: {\n                  path: \"asc\",\n                },\n              });\n              folderNames.push(...folders.map((f) => f.name));\n            }\n            return { ...doc, folderList: folderNames };\n          }),\n        );\n      }\n\n      if ((query || sort) && sort === \"lastViewed\") {\n        documentsWithFolderList = documentsWithFolderList.sort((a, b) => {\n          const aLastView = a.views[0]?.viewedAt;\n          const bLastView = b.views[0]?.viewedAt;\n\n          if (!aLastView) return 1;\n          if (!bLastView) return -1;\n\n          return bLastView.getTime() - aLastView.getTime();\n        });\n      }\n\n      if ((query || sort) && sort === \"name\") {\n        documentsWithFolderList = documentsWithFolderList.sort((a, b) =>\n          a.name.toLowerCase().localeCompare(b.name.toLowerCase()),\n        );\n      }\n\n      let matchingFolders: any[] = [];\n      if (query) {\n        const folders = await prisma.folder.findMany({\n          where: {\n            teamId,\n            hiddenInAllDocuments: false,\n            name: {\n              contains: query,\n              mode: \"insensitive\",\n            },\n            OR: [\n              { parentId: null },\n              {\n                parentFolder: {\n                  hiddenInAllDocuments: false,\n                },\n              },\n            ],\n          },\n          include: {\n            _count: {\n              select: {\n                documents: {\n                  where: {\n                    hiddenInAllDocuments: false,\n                  },\n                },\n                childFolders: {\n                  where: {\n                    hiddenInAllDocuments: false,\n                  },\n                },\n              },\n            },\n          },\n          orderBy: { name: \"asc\" },\n        });\n\n        const allParentPaths = new Set<string>();\n        for (const folder of folders) {\n          const parentPath = folder.path.substring(\n            0,\n            folder.path.lastIndexOf(\"/\"),\n          );\n          if (!parentPath) continue;\n\n          const pathSegments = parentPath.split(\"/\").filter(Boolean);\n          for (let index = 0; index < pathSegments.length; index++) {\n            allParentPaths.add(\n              `/${pathSegments.slice(0, index + 1).join(\"/\")}`,\n            );\n          }\n        }\n\n        const parentFolders = allParentPaths.size\n          ? await prisma.folder.findMany({\n              where: {\n                teamId,\n                path: { in: Array.from(allParentPaths) },\n              },\n              select: { path: true, name: true },\n            })\n          : [];\n\n        const parentFolderNameByPath = new Map(\n          parentFolders.map((folder) => [folder.path, folder.name]),\n        );\n\n        matchingFolders = folders.map((folder) => {\n          const folderNames: string[] = [];\n          const parentPath = folder.path.substring(\n            0,\n            folder.path.lastIndexOf(\"/\"),\n          );\n\n          if (parentPath) {\n            const pathSegments = parentPath.split(\"/\").filter(Boolean);\n            for (let index = 0; index < pathSegments.length; index++) {\n              const path = `/${pathSegments.slice(0, index + 1).join(\"/\")}`;\n              const parentFolderName = parentFolderNameByPath.get(path);\n\n              if (parentFolderName) {\n                folderNames.push(parentFolderName);\n              }\n            }\n          }\n\n          return { ...folder, folderList: folderNames };\n        });\n      }\n\n      return res.status(200).json({\n        documents: documentsWithFolderList,\n        ...(query && { folders: matchingFolders }),\n        ...(usePagination && {\n          pagination: {\n            total: totalDocuments,\n            pages: Math.ceil(totalDocuments! / limit!),\n            currentPage: page,\n            pageSize: limit,\n          },\n        }),\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents\n    const { teamId } = req.query as { teamId: string };\n\n    // Check for API token first\n    const authHeader = req.headers.authorization;\n    let userId: string;\n    let token: string | null = null;\n\n    if (authHeader?.startsWith(\"Bearer \")) {\n      token = authHeader.replace(\"Bearer \", \"\");\n      const hashedToken = hashToken(token);\n\n      // Look up token in database\n      const restrictedToken = await prisma.restrictedToken.findUnique({\n        where: { hashedKey: hashedToken },\n        select: { userId: true, teamId: true },\n      });\n\n      // Check if token exists\n      if (!restrictedToken) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      // Check if token is for the correct team\n      if (restrictedToken.teamId !== teamId) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      userId = restrictedToken.userId;\n    } else {\n      // Fall back to session auth\n      const session = await getServerSession(req, res, authOptions);\n      if (!session) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n      userId = (session.user as CustomUser).id;\n    }\n\n    // Validate request body using Zod schema for security\n    const validationResult = await documentUploadSchema.safeParseAsync(\n      req.body,\n    );\n\n    if (!validationResult.success) {\n      log({\n        message: `Document upload validation failed for teamId: ${teamId}. Errors: ${JSON.stringify(validationResult.error.errors)}`,\n        type: \"error\",\n      });\n      return res.status(400).json({\n        error: \"Invalid document upload data\",\n        details: validationResult.error.errors,\n      });\n    }\n\n    const {\n      name,\n      url: fileUrl,\n      storageType,\n      numPages,\n      type: fileType,\n      folderPathName,\n      contentType,\n      createLink,\n      fileSize,\n    } = validationResult.data;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: { plan: true, enableExcelAdvancedMode: true },\n      });\n\n      if (!team) {\n        return res.status(404).end(\"Team not found\");\n      }\n\n      // Check if team is paused\n      const teamIsPaused = await isTeamPausedById(teamId);\n      if (teamIsPaused) {\n        return res.status(403).json({\n          error:\n            \"Team is currently paused. New document uploads are not available.\",\n        });\n      }\n\n      // For link documents, storageType is optional but processDocument requires it\n      // Use VERCEL_BLOB as a placeholder (not actually used for links)\n      const finalStorageType =\n        storageType || (fileType === \"link\" ? \"VERCEL_BLOB\" : \"VERCEL_BLOB\");\n\n      const document = await processDocument({\n        documentData: {\n          name,\n          key: fileUrl,\n          storageType: finalStorageType,\n          numPages,\n          supportedFileType: fileType,\n          contentType: contentType || null,\n          fileSize,\n          enableExcelAdvancedMode:\n            fileType === \"sheet\" &&\n            team.enableExcelAdvancedMode &&\n            supportsAdvancedExcelMode(contentType),\n        },\n        teamId,\n        userId,\n        teamPlan: team.plan,\n        createLink,\n        folderPathName,\n      });\n\n      return res.status(201).json(serializeFileSize(document));\n    } catch (error) {\n      log({\n        message: `Failed to create document. \\n\\n*teamId*: _${teamId}_, \\n\\n*file*: ${fileUrl} \\n\\n ${error}`,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/move.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/documents/move\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { documentIds, folderId } = req.body as {\n      documentIds: string[];\n      folderId: string | null;\n    };\n\n    // Ensure the user is an admin of the team\n    const team = await prisma.team.findUnique({\n      where: { id: teamId },\n      include: {\n        users: {\n          where: {\n            userId: userId,\n          },\n        },\n      },\n    });\n\n    if (!team || team.users.length === 0) {\n      return res.status(403).end(\"Forbidden\");\n    }\n\n    // Update the folderId for the specified documents\n    const updatedDocuments = await prisma.document.updateMany({\n      where: {\n        id: { in: documentIds },\n        teamId: teamId,\n      },\n      data: {\n        folderId: folderId,\n      },\n    });\n\n    // Get new path for folder unless folderId is null\n    let folder: { path: string } | null = null;\n    if (folderId) {\n      folder = await prisma.folder.findUnique({\n        where: { id: folderId, teamId: teamId },\n        select: { path: true },\n      });\n    }\n\n    if (updatedDocuments.count === 0) {\n      return res.status(404).end(\"No documents were updated\");\n    }\n\n    return res.status(200).json({\n      message: \"Document moved successfully\",\n      updatedCount: updatedDocuments.count,\n      newPath: folder?.path,\n    });\n  } else {\n    // We only allow PATCH requests\n    res.setHeader(\"Allow\", [\"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/search.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, query } = req.query as { teamId: string; query?: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // First, get documents without expensive counts\n      const documents = await prisma.document.findMany({\n        where: {\n          teamId: teamId,\n          name: {\n            contains: query,\n            mode: \"insensitive\",\n          },\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      // Then, get counts efficiently with separate GROUP BY queries\n      const documentIds = documents.map((d) => d.id);\n\n      const [linkCounts, viewCounts, versionCounts] = await Promise.all([\n        prisma.link.groupBy({\n          by: [\"documentId\"],\n          where: {\n            documentId: { in: documentIds },\n            deletedAt: null,\n          },\n          _count: { id: true },\n        }),\n        prisma.view.groupBy({\n          by: [\"documentId\"],\n          where: {\n            documentId: { in: documentIds },\n          },\n          _count: { id: true },\n        }),\n        prisma.documentVersion.groupBy({\n          by: [\"documentId\"],\n          where: {\n            documentId: { in: documentIds },\n          },\n          _count: { id: true },\n        }),\n      ]);\n\n      // Create lookup maps for counts\n      const linkCountMap = new Map(\n        linkCounts.map((lc) => [lc.documentId, lc._count.id]),\n      );\n      const viewCountMap = new Map(\n        viewCounts.map((vc) => [vc.documentId, vc._count.id]),\n      );\n      const versionCountMap = new Map(\n        versionCounts.map((vsc) => [vsc.documentId, vsc._count.id]),\n      );\n\n      // Combine documents with their counts\n      const documentsWithCounts = documents.map((document) => ({\n        ...document,\n        _count: {\n          links: linkCountMap.get(document.id) || 0,\n          views: viewCountMap.get(document.id) || 0,\n          versions: versionCountMap.get(document.id) || 0,\n        },\n      }));\n\n      return res.status(200).json(documentsWithCounts);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/documents/update.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/documents/update\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    // Assuming data is an object with `name` and `description` properties\n    const { documentId, numPages } = req.body;\n\n    const { teamId } = req.query as { teamId: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const document = await prisma.document.findUnique({\n        where: {\n          id: documentId,\n          teamId,\n        },\n        select: { id: true },\n      });\n\n      if (!document) {\n        return res.status(404).json({ error: \"Document not found\" });\n      }\n\n      // Save data to the database\n      await prisma.document.update({\n        where: { id: documentId },\n        data: {\n          numPages: numPages,\n          // versions: {\n          //   update: {\n          //     where: { id: documentId },\n          //     data: { numPages: numPages },\n          //   },\n          // },\n        },\n      });\n\n      return res.status(201).json({ message: \"Document updated successfully\" });\n    } catch (error) {\n      log({\n        message: `Failed to update document: _${documentId}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/domains/[domain]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api//auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport {\n  deleteDomainRedirectUrl,\n  setDomainRedirectUrl,\n} from \"@/lib/api/domains/redis\";\nimport { validateRedirectUrl } from \"@/lib/api/domains/validate-redirect-url\";\nimport { getApexDomain, removeDomainFromVercel } from \"@/lib/domains\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nconst updateDomainSchema = z.object({\n  redirectUrl: z.string().nullable().optional(),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/domains/:domain\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // Assuming the domain slug is sent in the request body.\n    const { teamId, domain } = req.query as { teamId: string; domain: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    if (!domain) {\n      return res.status(400).json(\"Domain is required for deletion\");\n    }\n\n    try {\n      const hasTeamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n      if (!hasTeamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const domainToBeDeleted = await prisma.domain.findUnique({\n        where: {\n          slug: domain,\n          teamId,\n        },\n        select: {\n          id: true,\n        },\n      });\n      if (!domainToBeDeleted) {\n        return res.status(404).json(\"Domain not found\");\n      }\n\n      // calculate the domainCount\n      const apexDomain = getApexDomain(`https://${domain}`);\n      const domainCount = await prisma.domain.count({\n        where: {\n          OR: [\n            {\n              slug: apexDomain,\n            },\n            {\n              slug: {\n                endsWith: `.${apexDomain}`,\n              },\n            },\n          ],\n        },\n      });\n\n      await Promise.allSettled([\n        removeDomainFromVercel(domain, domainCount),\n        prisma.domain.delete({\n          where: {\n            id: domainToBeDeleted.id,\n          },\n        }),\n        deleteDomainRedirectUrl(domain),\n      ]);\n\n      return res.status(204).end();\n    } catch (error) {\n      log({\n        message: `Failed to delete domain: _${domain}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n        mention: true,\n      });\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/domains/:domain\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // Assuming the domain slug is sent in the request body.\n    const { teamId, domain } = req.query as { teamId: string; domain: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    if (!domain) {\n      return res.status(400).json(\"Domain is required for deletion\");\n    }\n\n    try {\n      const hasTeamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n      if (!hasTeamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const domainToBeUpdated = await prisma.domain.findUnique({\n        where: {\n          slug: domain,\n          teamId,\n        },\n        select: {\n          id: true,\n        },\n      });\n      if (!domainToBeUpdated) {\n        return res.status(404).json(\"Domain not found\");\n      }\n\n      const updateDefaultPromise = prisma.domain.update({\n        where: {\n          id: domainToBeUpdated.id,\n          teamId: teamId,\n        },\n        data: {\n          isDefault: true,\n        },\n      });\n\n      const updateNonDefaultPromise = prisma.domain.updateMany({\n        where: {\n          teamId,\n          slug: {\n            not: domain,\n          },\n        },\n        data: {\n          isDefault: false,\n        },\n      });\n\n      await Promise.all([updateDefaultPromise, updateNonDefaultPromise]);\n\n      return res.status(200).json({ message: \"Domain set to default\" }); // 204 No Content response for successful deletes\n    } catch (error) {\n      log({\n        message: `Failed to set domain: _${domain}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n        mention: true,\n      });\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/domains/:domain - update domain settings (e.g. redirectUrl)\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, domain } = req.query as { teamId: string; domain: string };\n    const userId = (session.user as CustomUser).id;\n\n    if (!domain) {\n      return res.status(400).json(\"Domain is required\");\n    }\n\n    try {\n      const hasTeamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n      if (!hasTeamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const domainToBeUpdated = await prisma.domain.findUnique({\n        where: {\n          slug: domain,\n          teamId,\n        },\n        select: {\n          id: true,\n        },\n      });\n      if (!domainToBeUpdated) {\n        return res.status(404).json(\"Domain not found\");\n      }\n\n      const parsed = updateDomainSchema.safeParse(req.body);\n      if (!parsed.success) {\n        return res\n          .status(422)\n          .json({ message: parsed.error.issues[0]?.message ?? \"Invalid body\" });\n      }\n\n      const hasRedirectUrl = \"redirectUrl\" in parsed.data;\n      const redirectUrl = parsed.data.redirectUrl;\n\n      let normalizedUrl: string | null | undefined;\n      if (hasRedirectUrl) {\n        if (redirectUrl) {\n          const result = await validateRedirectUrl(redirectUrl, teamId);\n          if (!result.valid) {\n            return res.status(422).json({ message: result.message });\n          }\n          normalizedUrl = result.url || null;\n        } else {\n          normalizedUrl = null;\n        }\n      }\n\n      const updatedDomain = await prisma.domain.update({\n        where: {\n          id: domainToBeUpdated.id,\n          teamId,\n        },\n        data: {\n          ...(normalizedUrl !== undefined && { redirectUrl: normalizedUrl }),\n        },\n      });\n\n      if (normalizedUrl !== undefined) {\n        await setDomainRedirectUrl(domain, normalizedUrl);\n      }\n\n      return res.status(200).json(updatedDomain);\n    } catch (error) {\n      log({\n        message: `Failed to update domain: _${domain}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n        mention: true,\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"DELETE\", \"PATCH\", \"PUT\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/domains/[domain]/validate.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport dns from \"dns/promises\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { validDomainRegex } from \"@/lib/domains\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../../../auth/[...nextauth]\";\n\ntype DomainValidationStatus = \"invalid\" | \"has site\" | \"available\";\n\nconst sanitizeDomain = (value: string) =>\n  value\n    .trim()\n    .toLowerCase()\n    .replace(/^(?:https?:\\/\\/)?(?:www\\.)?/i, \"\")\n    .split(\"/\")[0];\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"GET\") {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const rawDomain = Array.isArray(req.query.domain)\n    ? req.query.domain[0]\n    : req.query.domain;\n\n  if (!teamId || !rawDomain) {\n    return res.status(400).json({ status: \"invalid\" });\n  }\n\n  const userId = (session.user as CustomUser).id;\n\n  try {\n    const hasTeamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n    if (!hasTeamAccess) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const sanitizedDomain = sanitizeDomain(rawDomain);\n\n    if (\n      !sanitizedDomain ||\n      !validDomainRegex.test(sanitizedDomain) ||\n      sanitizedDomain.includes(\"papermark\")\n    ) {\n      return res.status(200).json({\n        status: \"invalid\" as DomainValidationStatus,\n      });\n    }\n\n    const existingDomain = await prisma.domain.findFirst({\n      where: {\n        slug: sanitizedDomain,\n      },\n      select: {\n        id: true,\n      },\n    });\n\n    if (existingDomain) {\n      return res.status(200).json({\n        status: \"has site\" as DomainValidationStatus,\n      });\n    }\n\n    const hasSite = await hasSiteConfigured(sanitizedDomain);\n    if (hasSite) {\n      return res.status(200).json({\n        status: \"has site\" as DomainValidationStatus,\n      });\n    }\n\n    return res.status(200).json({\n      status: \"available\" as DomainValidationStatus,\n    });\n  } catch (error) {\n    errorhandler(error, res);\n  }\n}\n\nasync function hasSiteConfigured(domain: string): Promise<boolean> {\n  try {\n    const urls = [`https://${domain}`, `http://${domain}`];\n\n    for (const url of urls) {\n      try {\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), 3000);\n        const response = await fetch(url, {\n          method: \"HEAD\",\n          signal: controller.signal,\n          redirect: \"manual\",\n        });\n        clearTimeout(timeoutId);\n        // Any response (including redirects) means a site is configured\n        if (response.status < 500) return true;\n      } catch {\n        // Timeout, network error, or certificate error – try next URL\n        continue;\n      }\n    }\n\n    const dnsPromise = dns.resolve(domain);\n    const timeoutPromise = new Promise((_, reject) =>\n      setTimeout(() => reject(new Error(\"DNS Timeout\")), 3000),\n    );\n\n    try {\n      const records = (await Promise.race([\n        dnsPromise,\n        timeoutPromise,\n      ])) as string[];\n      return records.length > 0;\n    } catch (error) {\n      return false;\n    }\n  } catch (error) {\n    return false;\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/domains/[domain]/verify.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { waitUntil } from \"@vercel/functions\";\n\nimport { trackAnalytics } from \"@/lib/analytics\";\nimport {\n  getConfigResponse,\n  getDomainResponse,\n  verifyDomain,\n} from \"@/lib/domains\";\nimport prisma from \"@/lib/prisma\";\nimport { DomainVerificationStatusProps } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  // GET /api/teams/:teamId/domains/[domain]/verify - get domain verification status\n  if (req.method === \"GET\") {\n    const { domain } = req.query as { domain: string };\n    let status: DomainVerificationStatusProps = \"Valid Configuration\";\n\n    const [domainJson, configJson] = await Promise.all([\n      getDomainResponse(domain),\n      getConfigResponse(domain),\n    ]);\n\n    if (domainJson?.error?.code === \"not_found\") {\n      // domain not found on Vercel project\n      status = \"Domain Not Found\";\n      return res.status(200).json({\n        status,\n        response: { domainJson, configJson },\n      });\n      // unknown error\n    } else if (domainJson.error) {\n      status = \"Unknown Error\";\n      return res.status(200).json({\n        status,\n        response: { domainJson, configJson },\n      });\n    }\n\n    /**\n     * Domain has DNS conflicts\n     */\n    if (configJson?.conflicts.length > 0) {\n      status = \"Conflicting DNS Records\";\n      return res.status(200).json({\n        status,\n        response: { domainJson, configJson },\n      });\n    }\n\n    /**\n     * If domain is not verified, we try to verify now\n     */\n    if (!domainJson.verified) {\n      status = \"Pending Verification\";\n      const verificationJson = await verifyDomain(domain);\n\n      // domain was just verified\n      if (verificationJson && verificationJson.verified) {\n        status = \"Valid Configuration\";\n      }\n\n      return res.status(200).json({\n        status,\n        response: { domainJson, configJson },\n      });\n    }\n\n    if (!configJson.misconfigured) {\n      status = \"Valid Configuration\";\n      const currentDomain = await prisma.domain.findUnique({\n        where: {\n          slug: domain,\n        },\n        select: {\n          verified: true,\n        },\n      });\n\n      const updatedDomain = await prisma.domain.update({\n        where: {\n          slug: domain,\n        },\n        data: {\n          verified: true,\n          lastChecked: new Date(),\n        },\n        select: {\n          userId: true,\n          verified: true,\n        },\n      });\n\n      if (!currentDomain!.verified && updatedDomain.verified) {\n        waitUntil(trackAnalytics({ event: \"Domain Verified\", slug: domain }));\n      }\n    } else {\n      status = \"Invalid Configuration\";\n      await prisma.domain.update({\n        where: {\n          slug: domain,\n        },\n        data: {\n          verified: false,\n          lastChecked: new Date(),\n        },\n      });\n    }\n\n    return res.status(200).json({\n      status,\n      response: { domainJson, configJson },\n    });\n  } else {\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/domains/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { setDomainRedirectUrl } from \"@/lib/api/domains/redis\";\nimport { validateRedirectUrl } from \"@/lib/api/domains/validate-redirect-url\";\nimport { addDomainToVercel, validDomainRegex } from \"@/lib/domains\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nimport { authOptions } from \"../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/domains\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const hasTeamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n      if (!hasTeamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const domains = await prisma.domain.findMany({\n        where: {\n          teamId,\n        },\n        select: {\n          slug: true,\n          verified: true,\n          isDefault: true,\n          redirectUrl: true,\n        },\n        orderBy: {\n          createdAt: \"asc\",\n        },\n      });\n\n      return res.status(200).json(domains);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/domains\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n\n    if (!teamId) {\n      return res.status(401).json(\"Unauthorized\");\n    }\n\n    try {\n      const hasTeamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n      if (!hasTeamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const { domain, redirectUrl } = req.body;\n\n      // Sanitize domain by removing whitespace, protocol, and paths\n      const sanitizedDomain = domain\n        .trim()\n        .toLowerCase()\n        .replace(/^(?:https?:\\/\\/)?(?:www\\.)?/i, \"\")\n        .split(\"/\")[0];\n\n      // Check if domain is valid\n      const validDomain = validDomainRegex.test(sanitizedDomain);\n      if (validDomain !== true) {\n        return res.status(422).json(\"Invalid domain\");\n      }\n\n      // Check if domain contains papermark\n      if (sanitizedDomain.toLowerCase().includes(\"papermark\")) {\n        return res\n          .status(400)\n          .json({ message: \"Domain cannot contain 'papermark'\" });\n      }\n\n      // Check if domain already exists\n      const existingDomain = await prisma.domain.findFirst({\n        where: {\n          slug: sanitizedDomain,\n        },\n      });\n\n      if (existingDomain) {\n        return res.status(400).json({\n          message: \"Unable to add this domain. Please try a different one.\",\n        });\n      }\n\n      let validatedRedirectUrl: string | undefined;\n      if (redirectUrl) {\n        const result = await validateRedirectUrl(redirectUrl, teamId);\n        if (!result.valid) {\n          return res.status(422).json({ message: result.message });\n        }\n        validatedRedirectUrl = result.url || undefined;\n      }\n\n      const response = await prisma.domain.create({\n        data: {\n          slug: sanitizedDomain,\n          userId,\n          teamId,\n          ...(validatedRedirectUrl && { redirectUrl: validatedRedirectUrl }),\n        },\n      });\n      await addDomainToVercel(sanitizedDomain);\n\n      if (validatedRedirectUrl) {\n        try {\n          await setDomainRedirectUrl(sanitizedDomain, validatedRedirectUrl);\n        } catch {\n          // Domain is functional but redirect failed to persist in Redis.\n          // Remove redirectUrl from DB so the two stores stay consistent.\n          await prisma.domain.update({\n            where: { id: response.id },\n            data: { redirectUrl: null },\n          });\n        }\n      }\n\n      return res.status(201).json(response);\n    } catch (error) {\n      log({\n        message: `Failed to add domain. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n        mention: true,\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/enable-advanced-mode.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { Session } from \"next-auth\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { copyFileToBucketServer } from \"@/lib/files/copy-file-to-bucket-server\";\nimport prisma from \"@/lib/prisma\";\nimport { supportsAdvancedExcelMode } from \"@/lib/utils/get-content-type\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\ninterface CustomSession extends Session {\n  user: {\n    id: string;\n    name?: string | null;\n    email?: string | null;\n    image?: string | null;\n  };\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"PATCH\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  try {\n    const session = (await getServerSession(\n      req,\n      res,\n      authOptions,\n    )) as CustomSession | null;\n    if (!session?.user?.id) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const { teamId } = req.query;\n    const { enableExcelAdvancedMode } = req.body as {\n      enableExcelAdvancedMode: boolean;\n    };\n\n    const team = await prisma.team.findFirst({\n      where: {\n        id: teamId as string,\n        users: {\n          some: {\n            userId: session.user.id,\n          },\n        },\n      },\n    });\n\n    if (!team) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    // Start a transaction to ensure all updates succeed or none do\n    const result = await prisma.$transaction(async (tx) => {\n      // Get all Excel documents in the team that need to be updated\n      const documents = await tx.document.findMany({\n        where: {\n          teamId: teamId as string,\n          type: \"sheet\",\n          advancedExcelEnabled: !enableExcelAdvancedMode,\n        },\n        include: {\n          versions: {\n            where: {\n              isPrimary: true,\n              type: \"sheet\",\n            },\n            select: {\n              id: true,\n              file: true,\n              storageType: true,\n              contentType: true,\n            },\n          },\n        },\n      });\n\n      const eligibleDocuments = documents.filter((doc) => {\n        const primaryVersion = doc.versions[0];\n        return (\n          primaryVersion &&\n          supportsAdvancedExcelMode(primaryVersion.contentType)\n        );\n      });\n\n      // Update all documents and their versions\n      const updatePromises = eligibleDocuments.map(async (doc) => {\n        const primaryVersion = doc.versions[0];\n        if (!primaryVersion) return;\n\n        if (enableExcelAdvancedMode) {\n          // Copy file to bucket if enabling advanced mode\n          await copyFileToBucketServer({\n            filePath: primaryVersion.file,\n            storageType: primaryVersion.storageType,\n            teamId: teamId as string,\n          });\n\n          // Update document and version when enabling\n          await Promise.all([\n            tx.document.update({\n              where: { id: doc.id },\n              data: { advancedExcelEnabled: true },\n            }),\n            tx.documentVersion.update({\n              where: { id: primaryVersion.id },\n              data: { numPages: 1 },\n            }),\n          ]);\n        } else {\n          await tx.document.update({\n            where: { id: doc.id },\n            data: { advancedExcelEnabled: false },\n          });\n        }\n\n        // Revalidate the document\n        await fetch(\n          `${process.env.NEXTAUTH_URL}/api/revalidate?secret=${process.env.REVALIDATE_TOKEN}&documentId=${doc.id}`,\n        );\n      });\n\n      await Promise.all(updatePromises);\n      const updatedTeam = await tx.team.update({\n        where: {\n          id: teamId as string,\n        },\n        data: {\n          enableExcelAdvancedMode,\n        },\n      });\n      return {\n        team: updatedTeam,\n        updatedDocumentsCount: eligibleDocuments.length,\n      };\n    });\n    return res.status(200).json({\n      ...result,\n      message: `Successfully ${enableExcelAdvancedMode ? \"enabled\" : \"disabled\"} advanced Excel mode for ${result.updatedDocumentsCount} documents`,\n    });\n  } catch (error) {\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/export-jobs/[exportId]/send-email.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { sendExportReadyEmail } from \"@/lib/emails/send-export-ready-email\";\nimport { jobStore } from \"@/lib/redis-job-store\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, exportId } = req.query as {\n    teamId: string;\n    exportId: string;\n  };\n  const userId = (session.user as CustomUser).id;\n\n  if (req.method === \"POST\") {\n    try {\n      // Get export job details\n      const exportJob = await jobStore.getJob(exportId);\n\n      if (\n        !exportJob ||\n        exportJob.teamId !== teamId ||\n        exportJob.userId !== userId\n      ) {\n        return res.status(404).json({ error: \"Export job not found\" });\n      }\n\n      // Check if user has email\n      if (!session.user?.email) {\n        return res.status(400).json({ error: \"User email not found\" });\n      }\n\n      // Store email notification flag\n      await jobStore.updateJob(exportId, {\n        emailNotification: true,\n        emailAddress: session.user.email,\n      });\n\n      // If job is already completed, send email immediately\n      if (exportJob.status === \"COMPLETED\" && exportJob.result) {\n        await sendExportReadyEmail({\n          to: session.user.email,\n          resourceName: exportJob.resourceName || \"Export\",\n          downloadUrl: `${process.env.NEXTAUTH_URL}/api/teams/${teamId}/export-jobs/${exportId}?download=true`,\n        });\n      }\n\n      return res.status(200).json({ success: true });\n    } catch (error) {\n      console.error(\"Error setting up email notification:\", error);\n      return res\n        .status(500)\n        .json({ error: \"Failed to setup email notification\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"POST\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/export-jobs/[exportId].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { runs } from \"@trigger.dev/sdk/v3\";\n\nimport { jobStore } from \"@/lib/redis-job-store\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, exportId } = req.query as {\n    teamId: string;\n    exportId: string;\n  };\n  const userId = (session.user as CustomUser).id;\n\n  if (req.method === \"GET\") {\n    try {\n      // Get export job details\n      const exportJob = await jobStore.getJob(exportId);\n\n      if (\n        !exportJob ||\n        exportJob.teamId !== teamId ||\n        exportJob.userId !== userId\n      ) {\n        return res.status(404).json({ error: \"Export job not found\" });\n      }\n\n      // Check if client wants to download the CSV\n      const { download } = req.query;\n      if (\n        download === \"true\" &&\n        exportJob.status === \"COMPLETED\" &&\n        exportJob.result\n      ) {\n        // Redirect directly to the blob URL\n        return res.redirect(302, exportJob.result);\n      }\n\n      // Return job status\n      return res.status(200).json({\n        id: exportJob.id,\n        type: exportJob.type,\n        status: exportJob.status,\n        resourceId: exportJob.resourceId,\n        resourceName: exportJob.resourceName,\n        groupId: exportJob.groupId,\n        error: exportJob.error,\n        createdAt: exportJob.createdAt,\n        updatedAt: exportJob.updatedAt,\n        completedAt: exportJob.completedAt,\n        isReady: exportJob.status === \"COMPLETED\" && !!exportJob.result,\n      });\n    } catch (error) {\n      console.error(\"Error fetching export job:\", error);\n      return res.status(500).json({ error: \"Failed to fetch export job\" });\n    }\n  }\n\n  if (req.method === \"PATCH\") {\n    try {\n      // Get the job first to verify ownership\n      const exportJob = await jobStore.getJob(exportId);\n\n      if (\n        !exportJob ||\n        exportJob.teamId !== teamId ||\n        exportJob.userId !== userId\n      ) {\n        return res.status(404).json({ error: \"Export job not found\" });\n      }\n\n      // Check if job can be cancelled\n      if (![\"PENDING\", \"PROCESSING\"].includes(exportJob.status)) {\n        return res.status(400).json({ \n          error: \"Export job cannot be cancelled in current state\" \n        });\n      }\n\n      // Cancel the trigger run if we have the run ID\n      if (exportJob.triggerRunId) {\n        try {\n          \n          await runs.cancel(exportJob.triggerRunId);\n        } catch (error) {\n          console.error(\"Failed to cancel trigger run:\", error);\n          // Continue with local cancellation even if trigger cancellation fails\n        }\n      }\n\n      // Update job status to cancelled\n      const updatedJob = await jobStore.updateJob(exportId, { \n        status: \"FAILED\",\n        error: \"Export cancelled by user\"\n      });\n\n      return res.status(200).json({\n        message: \"Export job cancelled successfully\",\n        job: updatedJob\n      });\n    } catch (error) {\n      console.error(\"Error cancelling export job:\", error);\n      return res.status(500).json({ error: \"Failed to cancel export job\" });\n    }\n  }\n\n  if (req.method === \"DELETE\") {\n    try {\n      // Get the job first to verify ownership\n      const exportJob = await jobStore.getJob(exportId);\n\n      if (\n        !exportJob ||\n        exportJob.teamId !== teamId ||\n        exportJob.userId !== userId\n      ) {\n        return res.status(404).json({ error: \"Export job not found\" });\n      }\n\n      // Delete export job\n      await jobStore.deleteJob(exportId);\n\n      return res\n        .status(200)\n        .json({ message: \"Export job deleted successfully\" });\n    } catch (error) {\n      console.error(\"Error deleting export job:\", error);\n      return res.status(500).json({ error: \"Failed to delete export job\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"GET\", \"PATCH\", \"DELETE\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/export-jobs.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { exportVisitsTask } from \"@/lib/trigger/export-visits\";\nimport { jobStore } from \"@/lib/redis-job-store\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const userId = (session.user as CustomUser).id;\n\n  if (req.method === \"POST\") {\n    // Trigger a new export job\n    const { type, resourceId, groupId } = req.body as {\n      type: \"document\" | \"dataroom\" | \"dataroom-group\";\n      resourceId: string;\n      groupId?: string;\n    };\n\n    if (!type || !resourceId) {\n      return res.status(400).json({ error: \"Missing required fields\" });\n    }\n\n    try {\n      // Verify team access\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n        select: { plan: true },\n      });\n\n      if (!team) {\n        return res.status(404).json({ error: \"Team not found\" });\n      }\n\n      if (team.plan === \"free\") {\n        return res.status(403).json({ \n          error: \"This feature is not available for your plan\" \n        });\n      }\n\n      // Create export job record\n      const exportJob = await jobStore.createJob({\n        type,\n        resourceId,\n        groupId,\n        userId,\n        teamId,\n        status: \"PENDING\",\n      });\n\n      // Trigger the background task\n      const handle = await exportVisitsTask.trigger(\n        {\n          type,\n          teamId,\n          resourceId,\n          groupId,\n          userId,\n          exportId: exportJob.id,\n        },\n        {\n          idempotencyKey: exportJob.id,\n          tags: [\n            `team_${teamId}`,\n            `user_${userId}`,\n            `export_${exportJob.id}`,\n          ],\n        },\n      );\n\n      // Update the job with the trigger run ID for cancellation\n      const updatedJob = await jobStore.updateJob(exportJob.id, {\n        triggerRunId: handle.id,\n      });\n\n      return res.status(200).json({\n        exportId: updatedJob?.id || exportJob.id,\n        status: updatedJob?.status || exportJob.status,\n        message: \"Export job created successfully\",\n      });\n    } catch (error) {\n      console.error(\"Error creating export job:\", error);\n      return res.status(500).json({ error: \"Failed to create export job\" });\n    }\n  }\n\n  if (req.method === \"GET\") {\n    // Get export jobs for the team\n    try {\n      const exportJobs = await jobStore.getUserTeamJobs(userId, teamId, 20);\n\n      return res.status(200).json(exportJobs);\n    } catch (error) {\n      console.error(\"Error fetching export jobs:\", error);\n      return res.status(500).json({ error: \"Failed to fetch export jobs\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"POST\", \"GET\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/[...name].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { folderPathSchema } from \"@/lib/zod/schemas/folders\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/folders/:name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, name } = req.query as { teamId: string; name: string[] };\n\n    // Validate that name is an array of strings using shared Zod schema\n    const nameValidation = folderPathSchema.safeParse(name);\n    if (!nameValidation.success) {\n      return res.status(400).json({\n        error: \"Invalid folder path format\",\n        details: nameValidation.error.issues.map((issue) => issue.message),\n      });\n    }\n\n    const validatedName = nameValidation.data;\n    const path = \"/\" + validatedName.join(\"/\"); // construct the materialized path\n\n    try {\n      // Check if the user is part of the team\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const parentFolder = await prisma.folder.findUnique({\n        where: {\n          teamId_path: {\n            teamId: teamId,\n            path: path,\n          },\n        },\n        select: {\n          id: true,\n          parentId: true,\n        },\n      });\n\n      if (!parentFolder) {\n        return res.status(404).end(\"Parent Folder not found\");\n      }\n\n      const folders = await prisma.folder.findMany({\n        where: {\n          teamId: teamId,\n          parentId: parentFolder.id,\n          hiddenInAllDocuments: false, // Exclude hidden folders from All Documents folder tree\n        },\n        orderBy: {\n          name: \"asc\",\n        },\n        include: {\n          _count: {\n            select: {\n              documents: {\n                where: { hiddenInAllDocuments: false },\n              },\n              childFolders: {\n                where: { hiddenInAllDocuments: false },\n              },\n            },\n          },\n        },\n      });\n\n      return res.status(200).json(folders);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching folders\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/documents/[...name].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { folderPathSchema } from \"@/lib/zod/schemas/folders\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/folders/documents/:name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, name } = req.query as { teamId: string; name: string[] };\n\n    // Validate that name is an array of strings using shared Zod schema\n    const nameValidation = folderPathSchema.safeParse(name);\n    if (!nameValidation.success) {\n      return res.status(400).json({\n        error: \"Invalid folder path format\",\n        details: nameValidation.error.issues.map((issue) => issue.message),\n      });\n    }\n\n    const validatedName = nameValidation.data;\n    const path = \"/\" + validatedName.join(\"/\"); // construct the materialized path\n\n    try {\n      // Check if the user is part of the team\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const folder = await prisma.folder.findUnique({\n        where: {\n          teamId_path: {\n            teamId: teamId,\n            path: path,\n          },\n        },\n        select: {\n          id: true,\n          parentId: true,\n        },\n      });\n\n      if (!folder) {\n        return res.status(404).end(\"Folder not found\");\n      }\n\n      // First, get documents without expensive counts\n      const documents = await prisma.document.findMany({\n        where: {\n          teamId: teamId,\n          folderId: folder.id,\n          hiddenInAllDocuments: false, // Exclude hidden documents from All Documents folder view\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      // Then, get counts efficiently with separate GROUP BY queries\n      const documentIds = documents.map((d) => d.id);\n\n      const [linkCounts, viewCounts, versionCounts, dataroomCounts] =\n        await Promise.all([\n          prisma.link.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n          prisma.view.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n          prisma.documentVersion.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n          prisma.dataroomDocument.groupBy({\n            by: [\"documentId\"],\n            where: {\n              documentId: { in: documentIds },\n            },\n            _count: { id: true },\n          }),\n        ]);\n\n      // Create lookup maps for counts\n      const linkCountMap = new Map(\n        linkCounts.map((lc) => [lc.documentId, lc._count.id]),\n      );\n      const viewCountMap = new Map(\n        viewCounts.map((vc) => [vc.documentId, vc._count.id]),\n      );\n      const versionCountMap = new Map(\n        versionCounts.map((vsc) => [vsc.documentId, vsc._count.id]),\n      );\n      const dataroomCountMap = new Map(\n        dataroomCounts.map((dc) => [dc.documentId, dc._count.id]),\n      );\n\n      // Combine documents with their counts\n      const documentsWithCounts = documents.map((document) => ({\n        ...document,\n        _count: {\n          links: linkCountMap.get(document.id) || 0,\n          views: viewCountMap.get(document.id) || 0,\n          versions: versionCountMap.get(document.id) || 0,\n          datarooms: dataroomCountMap.get(document.id) || 0,\n        },\n      }));\n\n      return res.status(200).json(documentsWithCounts);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching folders\" });\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/hide.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/folders/hide\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    const { folderIds, hidden } = req.body as {\n      folderIds: string[];\n      hidden: boolean;\n    };\n\n    if (!folderIds || !Array.isArray(folderIds) || folderIds.length === 0) {\n      return res.status(400).json({ error: \"Folder IDs are required\" });\n    }\n\n    if (typeof hidden !== \"boolean\") {\n      return res.status(400).json({ error: \"Hidden flag is required\" });\n    }\n\n    try {\n      // Check team access\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Get the folders to find their paths for cascading\n      const folders = await prisma.folder.findMany({\n        where: {\n          id: { in: folderIds },\n          teamId,\n        },\n        select: {\n          id: true,\n          path: true,\n        },\n      });\n\n      if (folders.length === 0) {\n        return res.status(404).json({ error: \"No folders found\" });\n      }\n\n      // Update the selected folders\n      const folderResult = await prisma.folder.updateMany({\n        where: {\n          id: { in: folderIds },\n          teamId,\n        },\n        data: {\n          hiddenInAllDocuments: hidden,\n        },\n      });\n\n      // Build path conditions for cascading to child folders\n      // Cascade: hide/unhide all subfolders and documents that have paths starting with the hidden folder paths\n      const pathConditions = folders.map((folder) => ({\n        path: {\n          startsWith: folder.path + \"/\",\n        },\n      }));\n\n      // Update child folders (cascade)\n      let childFolderCount = 0;\n      if (pathConditions.length > 0) {\n        const childFolderResult = await prisma.folder.updateMany({\n          where: {\n            teamId,\n            OR: pathConditions,\n          },\n          data: {\n            hiddenInAllDocuments: hidden,\n          },\n        });\n        childFolderCount = childFolderResult.count;\n      }\n\n      // Update documents in these folders and subfolders\n      // Get all folder IDs (selected + children)\n      const allFolderPaths = folders.map((f) => f.path);\n      const childFolders = await prisma.folder.findMany({\n        where: {\n          teamId,\n          OR: pathConditions.length > 0 ? pathConditions : [{ id: \"none\" }],\n        },\n        select: {\n          id: true,\n          path: true,\n        },\n      });\n\n      const allFolderIds = [\n        ...folderIds,\n        ...childFolders.map((f) => f.id),\n      ];\n\n      // Update documents in all affected folders\n      const documentResult = await prisma.document.updateMany({\n        where: {\n          teamId,\n          folderId: { in: allFolderIds },\n        },\n        data: {\n          hiddenInAllDocuments: hidden,\n        },\n      });\n\n      return res.status(200).json({\n        message: `${folderResult.count} folder(s) and ${documentResult.count} document(s) ${hidden ? \"hidden\" : \"unhidden\"} successfully`,\n        foldersCount: folderResult.count + childFolderCount,\n        documentsCount: documentResult.count,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/folders\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, root } = req.query as { teamId: string; root?: string };\n\n    try {\n      // Check if the user is part of the team\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      /** if root is present then only get root folders */\n      if (root === \"true\") {\n        const folders = await prisma.folder.findMany({\n          where: {\n            teamId: teamId,\n            parentId: null,\n            hiddenInAllDocuments: false, // Exclude hidden folders from All Documents view\n          },\n          orderBy: {\n            name: \"asc\",\n          },\n          include: {\n            _count: {\n              select: {\n                documents: {\n                  where: { hiddenInAllDocuments: false },\n                },\n                childFolders: {\n                  where: { hiddenInAllDocuments: false },\n                },\n              },\n            },\n          },\n        });\n\n        return res.status(200).json(folders);\n      }\n\n      const folders = await prisma.folder.findMany({\n        where: {\n          teamId: teamId,\n        },\n        orderBy: {\n          name: \"asc\",\n        },\n        include: {\n          documents: {\n            select: {\n              id: true,\n              name: true,\n              folderId: true,\n            },\n          },\n          childFolders: {\n            include: {\n              documents: {\n                select: {\n                  id: true,\n                  name: true,\n                  folderId: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      return res.status(200).json(folders);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching folders\" });\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/folders\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n\n    const { teamId } = req.query as { teamId: string };\n    const { name, path, icon, color } = req.body as {\n      name: string;\n      path: string;\n      icon?: string;\n      color?: string;\n    };\n\n    const parentFolderPath = path ? \"/\" + path : \"/\";\n\n    try {\n      // Check if the user is part of the team\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const parentFolder = await prisma.folder.findUnique({\n        where: {\n          teamId_path: {\n            teamId: teamId,\n            path: parentFolderPath,\n          },\n        },\n        select: {\n          id: true,\n          name: true,\n          path: true,\n        },\n      });\n\n      let folderName = name;\n      let counter = 1;\n      const MAX_RETRIES = 50;\n\n      let childFolderPath = path\n        ? \"/\" + path + \"/\" + safeSlugify(folderName)\n        : \"/\" + safeSlugify(folderName);\n\n      while (counter <= MAX_RETRIES) {\n        const existingFolder = await prisma.folder.findUnique({\n          where: {\n            teamId_path: {\n              teamId: teamId,\n              path: childFolderPath,\n            },\n          },\n        });\n\n        if (!existingFolder) break;\n\n        folderName = `${name} (${counter})`;\n        childFolderPath = path\n          ? \"/\" + path + \"/\" + safeSlugify(folderName)\n          : \"/\" + safeSlugify(folderName);\n        counter++;\n      }\n\n      if (counter > MAX_RETRIES) {\n        return res.status(400).json({\n          error: \"Failed to create folder\",\n          message: \"Too many folders with similar names\",\n        });\n      }\n\n      const folder = await prisma.folder.create({\n        data: {\n          name: folderName,\n          path: childFolderPath,\n          parentId: parentFolder?.id ?? null,\n          teamId: teamId,\n          icon: icon ?? null,\n          color: color ?? null,\n        },\n      });\n\n      const folderWithDocs = {\n        ...folder,\n        documents: [],\n        childFolders: [],\n        parentFolderPath: parentFolderPath,\n      };\n\n      res.status(201).json(folderWithDocs);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      res.status(500).json({ error: \"Error creating folder\" });\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/manage/[folderId]/add-to-dataroom.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\ninterface FolderWithContents {\n  id: string;\n  name: string;\n  documents: { id: string }[];\n  childFolders: FolderWithContents[];\n}\n\nasync function fetchFolderContents(\n  folderId: string,\n): Promise<FolderWithContents> {\n  const folder = await prisma.folder.findUnique({\n    where: { id: folderId },\n    include: {\n      documents: { select: { id: true } },\n      childFolders: true,\n    },\n  });\n\n  if (!folder) {\n    throw new Error(`Folder with id ${folderId} not found`);\n  }\n\n  const childFolders = await Promise.all(\n    folder.childFolders.map((childFolder) =>\n      fetchFolderContents(childFolder.id),\n    ),\n  );\n\n  return {\n    id: folder.id,\n    name: folder.name,\n    documents: folder.documents,\n    childFolders: childFolders,\n  };\n}\n\nasync function createDataroomStructure(\n  dataroomId: string,\n  folder: FolderWithContents,\n  parentPath: string = \"\",\n  parentFolderId?: string,\n): Promise<void> {\n  const currentPath = `${parentPath}/${safeSlugify(folder.name)}`;\n\n  const dataroomFolder = await prisma.dataroomFolder.create({\n    data: {\n      dataroomId,\n      path: currentPath,\n      name: folder.name,\n      parentId: parentFolderId,\n      documents: {\n        create: folder.documents.map((doc) => ({\n          documentId: doc.id,\n          dataroomId,\n        })),\n      },\n    },\n  });\n\n  await Promise.all(\n    folder.childFolders.map((childFolder) =>\n      createDataroomStructure(\n        dataroomId,\n        childFolder,\n        currentPath,\n        dataroomFolder.id,\n      ),\n    ),\n  );\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/folders/manage/:folderId/add-to-dataroom\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, folderId } = req.query as {\n      teamId: string;\n      folderId: string;\n    };\n    const { dataroomId } = req.body as { dataroomId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n          datarooms: {\n            some: {\n              id: dataroomId,\n            },\n          },\n          folders: {\n            some: {\n              id: {\n                equals: folderId,\n              },\n            },\n          },\n        },\n        select: {\n          id: true,\n          plan: true,\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      if (team.plan === \"free\" || team.plan === \"pro\") {\n        return res.status(403).json({\n          message: \"Upgrade your plan to use datarooms.\",\n        });\n      }\n\n      try {\n        const folderContents = await fetchFolderContents(folderId);\n        await createDataroomStructure(dataroomId, folderContents);\n\n        // const folderWithDocuments = await prisma.folder.findUnique({\n        //   where: {\n        //     id: folderId,\n        //   },\n        //   include: {\n        //     childFolders: true,\n        //     documents: { select: { id: true } },\n        //   },\n        // });\n\n        // if (!folderWithDocuments) {\n        //   return res.status(404).json({\n        //     message: \"Folder not found.\",\n        //   });\n        // }\n\n        // const parentPath = \"/\" + slugify(folderWithDocuments.name);\n        // await prisma.dataroomFolder.create({\n        //   data: {\n        //     dataroomId: dataroomId,\n        //     path: parentPath,\n        //     name: folderWithDocuments.name,\n        //     documents: {\n        //       create: folderWithDocuments.documents.map((document) => ({\n        //         documentId: document.id,\n        //         dataroomId: dataroomId,\n        //       })),\n        //     },\n        //     childFolders: {\n        //       create: folderWithDocuments.childFolders.map((childFolder) => ({\n        //         name: childFolder.name,\n        //         dataroomId: dataroomId,\n        //         path: parentPath + \"/\" + slugify(childFolder.name),\n        //         documents:...\n        //       })),\n        //     },\n        //   },\n        // });\n      } catch (error) {\n        return res.status(500).json({\n          message: \"Document already exists in dataroom!\",\n        });\n      }\n\n      return res.status(200).json({\n        message: \"Folder added to dataroom!\",\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow POST requests\n    res.setHeader(\"Allow\", [\"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/manage/[folderId]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { deleteFile } from \"@/lib/files/delete-file-server\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/folders/manage/:folderId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    const { teamId, folderId } = req.query as {\n      teamId: string;\n      folderId: string;\n    };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).json({ message: \"Unauthorized\" });\n      }\n\n      if (teamAccess.role !== \"ADMIN\" && teamAccess.role !== \"MANAGER\") {\n        return res.status(403).json({\n          message:\n            \"You are not permitted to perform this action. Only admin and managers can delete folders.\",\n        });\n      }\n\n      const folder = await prisma.folder.findUnique({\n        where: {\n          id: folderId,\n        },\n        select: {\n          _count: {\n            select: {\n              documents: true,\n              childFolders: true,\n            },\n          },\n        },\n      });\n\n      if (!folder) {\n        return res.status(404).json({\n          message: \"Folder not found\",\n        });\n      }\n\n      // Delete the folder and its contents\n      await deleteFolderAndContents(folderId, teamId);\n\n      return res.status(204).end(); // 204 No Content response for successful deletes\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow DELETE requests\n    res.setHeader(\"Allow\", [\"DELETE\"]);\n    return res\n      .status(405)\n      .json({ message: `Method ${req.method} Not Allowed` });\n  }\n}\n\nasync function deleteFolderAndContents(folderId: string, teamId: string) {\n  const childFoldersToDelete = await prisma.folder.findMany({\n    where: {\n      parentId: folderId,\n    },\n  });\n\n  for (const childFolder of childFoldersToDelete) {\n    await deleteFolderAndContents(childFolder.id, teamId);\n  }\n\n  // Delete all documents in the folder\n  const documents = await prisma.document.findMany({\n    where: {\n      folderId: folderId,\n      type: {\n        not: \"notion\",\n      },\n    },\n    include: {\n      versions: {\n        select: {\n          id: true,\n          file: true,\n          type: true,\n          storageType: true,\n        },\n      },\n    },\n  });\n\n  documents.map(async (documentVersions: { versions: any }) => {\n    for (const version of documentVersions.versions) {\n      await deleteFile({\n        type: version.storageType,\n        data: version.file,\n        teamId,\n      });\n    }\n  });\n\n  await prisma.document.deleteMany({\n    where: {\n      folderId: folderId,\n    },\n  });\n\n  // Delete the folder itself\n  await prisma.folder.delete({\n    where: {\n      id: folderId,\n    },\n  });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/manage/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport {\n  ALLOWED_FOLDER_COLORS,\n  ALLOWED_FOLDER_ICONS,\n} from \"@/lib/constants/folder-constants\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/folders/manage\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { folderId, name, icon, color } = req.body as {\n      folderId: string;\n      name: string;\n      icon?: string | null;\n      color?: string | null;\n    };\n\n    try {\n      // Validate icon if provided\n      if (icon !== undefined && icon !== null && !ALLOWED_FOLDER_ICONS.includes(icon as any)) {\n        return res.status(400).json({ message: \"Invalid folder icon\" });\n      }\n\n      // Validate color if provided\n      if (color !== undefined && color !== null && !ALLOWED_FOLDER_COLORS.includes(color as any)) {\n        return res.status(400).json({ message: \"Invalid folder color\" });\n      }\n\n      // Validate name\n      if (!name || name.trim().length === 0) {\n        return res.status(400).json({ message: \"Folder name is required\" });\n      }\n\n      if (name.trim().length > 256) {\n        return res.status(400).json({ message: \"Folder name must be 256 characters or less\" });\n      }\n\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: userId,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const folder = await prisma.folder.findUnique({\n        where: {\n          id: folderId,\n        },\n        select: {\n          name: true,\n          path: true,\n          icon: true,\n          color: true,\n        },\n      });\n\n      if (!folder) {\n        return res.status(404).json({ message: \"Folder not found\" });\n      }\n\n      // take the old path and replace the last part with the new name\n      const oldPath = folder.path;\n      const newPathParts = folder.path.split(\"/\");\n      newPathParts.pop();\n      newPathParts.push(safeSlugify(name.trim()));\n      const newPath = newPathParts.join(\"/\");\n\n      // Build update data object with only changed fields\n      const updateData: {\n        name: string;\n        path: string;\n        icon?: string | null;\n        color?: string | null;\n      } = {\n        name: name.trim(),\n        path: newPath,\n      };\n\n      // Only include icon and color in update if they were provided\n      if (icon !== undefined) {\n        updateData.icon = icon;\n      }\n      if (color !== undefined) {\n        updateData.color = color;\n      }\n\n      // Use a transaction to update descendant paths and the folder atomically\n      const updatedFolder = await prisma.$transaction(async (tx) => {\n        // If the path is changing, we need to update all descendant folder paths\n        if (oldPath !== newPath) {\n          // Fetch all descendant folders whose path starts with the old path\n          const descendantFolders = await tx.folder.findMany({\n            where: {\n              teamId: teamId,\n              path: { startsWith: `${oldPath}/` },\n            },\n            select: {\n              id: true,\n              path: true,\n            },\n          });\n\n          // Update all descendant paths by replacing the old prefix with the new one\n          // Update descendants first to avoid unique constraint violations\n          if (descendantFolders.length > 0) {\n            const descendantUpdates = descendantFolders.map((descendant) => {\n              // Replace the old path prefix with the new path\n              const relativePath = descendant.path.slice(oldPath.length);\n              const newDescendantPath = `${newPath}${relativePath}`;\n\n              return tx.folder.update({\n                where: { id: descendant.id },\n                data: { path: newDescendantPath },\n              });\n            });\n\n            await Promise.all(descendantUpdates);\n          }\n        }\n\n        // Now update the renamed folder itself\n        return tx.folder.update({\n          where: {\n            id: folderId,\n          },\n          data: updateData,\n        });\n      });\n\n      // Get parent folder path for cache invalidation\n      const parentFolderPath = folder.path.substring(\n        0,\n        folder.path.lastIndexOf(\"/\"),\n      );\n\n      return res.status(200).json({\n        message: \"Folder updated successfully\",\n        parentFolderPath: parentFolderPath || \"/\",\n        folder: updatedFolder,\n      });\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow PUT requests\n    res.setHeader(\"Allow\", [\"PUT\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/move.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { safeSlugify } from \"@/lib/utils\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/folders/move\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { folderIds, selectedFolder, selectedFolderPath } = req.body as {\n      folderIds: string[];\n      selectedFolder: string | null;\n      selectedFolderPath: string;\n    };\n\n    // Ensure the user is an admin of the team\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: userId,\n          teamId: teamId,\n        },\n      },\n    });\n\n    if (!teamAccess) {\n      return res.status(403).end(\"Forbidden\");\n    }\n\n    try {\n      let updatedFolders: any[] = [];\n      await prisma.$transaction(async (prisma) => {\n        const foldersToMove = await prisma.folder.findMany({\n          where: {\n            id: { in: folderIds },\n            teamId,\n          },\n        });\n\n        // Prevent moving a folder into itself or one of its descendants.\n        if (selectedFolder) {\n          const allFolders = await prisma.folder.findMany({\n            where: { teamId },\n            select: { id: true, parentId: true },\n          });\n          const parentMap = new Map(\n            allFolders.map((f) => [f.id, f.parentId]),\n          );\n          const folderIdSet = new Set(folderIds);\n          const visited = new Set<string>();\n          let currentId: string | null = selectedFolder;\n          while (currentId) {\n            if (folderIdSet.has(currentId)) {\n              throw new Error(\"MOVE_INVALID_PARENT\");\n            }\n            if (visited.has(currentId)) break;\n            visited.add(currentId);\n            currentId = parentMap.get(currentId) ?? null;\n          }\n        }\n\n        const existingFolders = await prisma.folder.findMany({\n          where: {\n            teamId,\n            parentId: selectedFolder, // Check only inside the target folder can be null\n          },\n          select: { name: true },\n        });\n        if (existingFolders.length > 0) {\n          const existingFolderNames = new Set(\n            existingFolders.map((f) => f.name),\n          );\n          const duplicateNames = foldersToMove\n            .map((folder) => folder.name)\n            .filter((name) => existingFolderNames.has(name));\n\n          if (duplicateNames.length > 0) {\n            throw new Error(\n              `MOVE_DUPLICATE_NAMES:${duplicateNames.join(\", \")}`,\n            );\n          }\n        }\n        // Fetch all nested subfolders of the selected folders (excluding the folders themselves)\n        const allSubfolders = await prisma.folder.findMany({\n          where: {\n            teamId,\n            OR: foldersToMove.map((folder) => ({\n              path: { startsWith: `${folder.path}/` },\n            })),\n          },\n        });\n        const folderPathUpdates = new Map();\n        // Generate new paths for the folders being moved\n        foldersToMove.forEach((folder) => {\n          const newPath =\n            selectedFolderPath !== \"/\"\n              ? `${selectedFolderPath}/${safeSlugify(folder.name)}`\n              : `/${safeSlugify(folder.name)}`;\n\n          folderPathUpdates.set(folder.id, newPath);\n        });\n\n        // Update all subfolder paths dynamically\n        const updates = allSubfolders.map((subfolder) => {\n          // Find the parent folder it belongs to\n          const parentFolder = foldersToMove.find((folder) =>\n            subfolder.path.startsWith(folder.path),\n          );\n\n          if (!parentFolder) return null;\n\n          // Get the new base path for the parent\n          const newParentPath = folderPathUpdates.get(parentFolder.id);\n\n          // Calculate the new subfolder path by replacing the old path with the new one\n          const relativePath = subfolder.path\n            .replace(parentFolder.path, \"\")\n            .trim();\n          const newSubfolderPath = `${newParentPath}${relativePath}`;\n\n          return prisma.folder.update({\n            where: { id: subfolder.id, teamId },\n            data: { path: newSubfolderPath },\n          });\n        });\n\n        // Update each folder individually with its new path\n        const updateMainFolders = folderIds.map((folderId) =>\n          prisma.folder.update({\n            where: {\n              id: folderId,\n              teamId,\n            },\n            data: {\n              parentId: selectedFolder,\n              path: folderPathUpdates.get(folderId),\n            },\n          }),\n        );\n\n        await Promise.all(updates);\n        updatedFolders = await Promise.all(updateMainFolders);\n\n        // Get new path for folder unless selectedFolder is null\n      });\n      if (updatedFolders.length === 0) {\n        return res.status(404).end(\"No Folder were updated\");\n      }\n\n      let folder: { path: string } | null = null;\n      if (selectedFolder) {\n        folder = await prisma.folder.findUnique({\n          where: { id: selectedFolder, teamId },\n          select: { path: true },\n        });\n      }\n\n      return res.status(200).json({\n        message: \"Folder moved successfully\",\n        updatedCount: updatedFolders.length,\n        newPath: folder?.path,\n      });\n    } catch (error) {\n      if (error instanceof Error) {\n        if (error.message === \"MOVE_INVALID_PARENT\") {\n          return res.status(400).json({\n            message:\n              \"Cannot move a folder into itself or one of its subfolders\",\n          });\n        }\n        if (error.message.startsWith(\"MOVE_DUPLICATE_NAMES:\")) {\n          const names = error.message.slice(\"MOVE_DUPLICATE_NAMES:\".length);\n          return res.status(409).json({\n            message: `Cannot move folders: Duplicate names found inside target folder - ${names}`,\n          });\n        }\n      }\n      console.error(error);\n      return res.status(500).end(\"Failed to move folder\");\n    }\n  } else {\n    // We only allow PATCH requests\n    res.setHeader(\"Allow\", [\"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/folders/parents/[...name].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { folderPathSchema } from \"@/lib/zod/schemas/folders\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/folders/parents/:name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, name } = req.query as { teamId: string; name: string[] };\n\n    // Validate that name is an array of strings using shared Zod schema\n    const nameValidation = folderPathSchema.safeParse(name);\n    if (!nameValidation.success) {\n      return res.status(400).json({\n        error: \"Invalid folder path format\",\n        details: nameValidation.error.issues.map((issue) => issue.message),\n      });\n    }\n\n    const validatedName = nameValidation.data;\n    let folderNames = [];\n\n    try {\n      // Check if the user is part of the team\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      for (let i = 0; i < validatedName.length; i++) {\n        const path = \"/\" + validatedName.slice(0, i + 1).join(\"/\"); // construct the materialized path\n\n        const folder = await prisma.folder.findUnique({\n          where: {\n            teamId_path: {\n              teamId: teamId,\n              path: path,\n            },\n          },\n          select: {\n            id: true,\n            parentId: true,\n            name: true,\n          },\n        });\n\n        if (!folder) {\n          return res.status(404).end(\"Parent Folder not found\");\n        }\n\n        folderNames.push({ name: folder.name, path: path });\n      }\n\n      return res.status(200).json(folderNames);\n    } catch (error) {\n      console.error(\"Request error\", error);\n      return res.status(500).json({ error: \"Error fetching folders\" });\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/global-block-list.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { sanitizeList } from \"@/lib/utils\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nasync function handler(req: NextApiRequest, res: NextApiResponse) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId } = req.query;\n\n  if (typeof teamId !== \"string\") {\n    return res.status(400).json({ error: \"Invalid teamId\" });\n  }\n\n  const team = await prisma.team.findFirst({\n    where: {\n      id: teamId,\n      users: {\n        some: {\n          userId: (session.user as CustomUser).id,\n        },\n      },\n    },\n    include: {\n      users: true,\n    },\n  });\n\n  if (!team) {\n    return res.status(404).json({ error: \"Team not found\" });\n  }\n\n  const isUserAdminOrManager = team.users.some(\n    (user) =>\n      (user.role === \"ADMIN\" || user.role === \"MANAGER\") && user.userId === (session.user as CustomUser).id,\n  );\n\n  if (!isUserAdminOrManager) {\n    return res.status(403).json({ error: \"Forbidden\" });\n  }\n\n  if (req.method === \"GET\") {\n    return res.status(200).json(team.globalBlockList || []);\n  }\n\n  if (req.method === \"PUT\") {\n    try {\n      const { blockList } = req.body;\n\n      if (!Array.isArray(blockList)) {\n        return res.status(400).json({ error: \"Invalid block list\" });\n      }\n\n      const uniqueBlockList = sanitizeList(blockList.join(\"\\n\"), \"both\");\n\n      await prisma.team.update({\n        where: {\n          id: teamId,\n        },\n        data: {\n          globalBlockList: uniqueBlockList,\n        },\n      });\n\n      return res.status(200).json({ message: \"Global block list updated\" });\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"GET\", \"PUT\"]);\n  return res.status(405).json({ error: \"Method not allowed\" });\n}\n\nexport default handler;\n"
  },
  {
    "path": "pages/api/teams/[teamId]/ignored-domains.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { sanitizeList } from \"@/lib/utils\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nasync function handler(req: NextApiRequest, res: NextApiResponse) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId } = req.query;\n\n  if (typeof teamId !== \"string\") {\n    return res.status(400).json({ error: \"Invalid teamId\" });\n  }\n\n  const team = await prisma.team.findFirst({\n    where: {\n      id: teamId,\n      users: {\n        some: {\n          userId: (session.user as CustomUser).id,\n        },\n      },\n    },\n    include: {\n      users: true,\n    },\n  });\n\n  if (!team) {\n    return res.status(404).json({ error: \"Team not found\" });\n  }\n\n  const isUserAdminOrManager = team.users.some(\n    (user) =>\n      (user.role === \"ADMIN\" || user.role === \"MANAGER\") && user.userId === (session.user as CustomUser).id,\n  );\n\n  if (!isUserAdminOrManager) {\n    return res.status(403).json({ error: \"Forbidden\" });\n  }\n\n  if (req.method === \"GET\") {\n    return res.status(200).json(team.ignoredDomains || []);\n  }\n\n  if (req.method === \"PUT\") {\n    try {\n      const { domains } = req.body;\n\n      if (!Array.isArray(domains)) {\n        return res.status(400).json({ error: \"Invalid domains list\" });\n      }\n\n      const uniqueDomains = sanitizeList(domains.join(\"\\n\"), \"domain\");\n\n      await prisma.team.update({\n        where: {\n          id: teamId,\n        },\n        data: {\n          ignoredDomains: uniqueDomains,\n        },\n      });\n\n      return res.status(200).json({ message: \"Ignored domains updated\" });\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"GET\", \"PUT\"]);\n  return res.status(405).json({ error: `Method ${req.method} not allowed` });\n}\n\nexport default handler;\n"
  },
  {
    "path": "pages/api/teams/[teamId]/incoming-webhooks/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport { generateWebhookId } from \"@/lib/incoming-webhooks\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const { teamId } = req.query as { teamId: string };\n\n  // Check feature flag\n  const features = await getFeatureFlags({ teamId });\n  if (!features.incomingWebhooks) {\n    return res\n      .status(403)\n      .json({ error: \"This feature is not available for your team\" });\n  }\n\n  if (req.method === \"GET\") {\n    try {\n      const session = await getServerSession(req, res, authOptions);\n      if (!session) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      const userId = (session.user as CustomUser).id;\n\n      // Check if user is in team\n      const { role } = await prisma.userTeam.findUniqueOrThrow({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      if (!role) {\n        return res.status(403).json({ error: \"Unauthorized\" });\n      }\n\n      // Fetch webhooks\n      const webhooks = await prisma.incomingWebhook.findMany({\n        where: {\n          teamId,\n        },\n        select: {\n          id: true,\n          name: true,\n          externalId: true,\n          createdAt: true,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      // Transform the response to match the interface\n      const transformedWebhooks = webhooks.map((webhook) => ({\n        id: webhook.id,\n        name: webhook.name,\n        webhookId: webhook.externalId,\n        createdAt: webhook.createdAt,\n      }));\n\n      return res.status(200).json(transformedWebhooks);\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Error fetching webhooks\" });\n    }\n  }\n\n  if (req.method === \"POST\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Check if user has access to team\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      const { name } = req.body;\n      if (!name) {\n        return res.status(400).json({ error: \"Name is required\" });\n      }\n\n      // Generate webhook ID and secret\n      const webhookId = generateWebhookId(teamId);\n\n      // Create incoming webhook\n      const incomingWebhook = await prisma.incomingWebhook.create({\n        data: {\n          name: \"New Incoming Webhook\",\n          externalId: webhookId,\n          teamId,\n        },\n      });\n\n      return res.status(200).json({\n        name: incomingWebhook.name,\n        webhookId: incomingWebhook.externalId,\n      });\n    } catch (error) {\n      console.error(\"Error creating webhook:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  if (req.method === \"DELETE\") {\n    try {\n      const session = await getServerSession(req, res, authOptions);\n      if (!session) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      const { teamId } = req.query as { teamId: string };\n      const { webhookId } = req.body;\n      const userId = (session.user as CustomUser).id;\n\n      // Check if user is in team and has admin role\n      const { role } = await prisma.userTeam.findUniqueOrThrow({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      // Only admins can delete webhooks\n      if (role !== \"ADMIN\") {\n        return res\n          .status(403)\n          .json({ error: \"Forbidden: Admin access required\" });\n      }\n\n      // Delete the webhook\n      await prisma.incomingWebhook.delete({\n        where: {\n          id: webhookId,\n          teamId, // Ensure webhook belongs to the team\n        },\n      });\n\n      return res.status(200).json({ message: \"Webhook deleted successfully\" });\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Error deleting webhook\" });\n    }\n  }\n\n  return res.status(405).json({ error: \"Method not allowed\" });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { cancelSubscription } from \"@/ee/stripe\";\nimport { isOldAccount } from \"@/ee/stripe/utils\";\nimport { DocumentStorageType } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth\";\n\nimport { deleteDomainRedirectUrl } from \"@/lib/api/domains/redis\";\nimport { removeDomainFromVercelProject } from \"@/lib/domains\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { deleteFiles } from \"@/lib/files/delete-team-files-server\";\nimport prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\n\nimport { CustomUser } from \"@/lib/types\";\nimport { unsubscribe } from \"@/lib/resend\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n        },\n        select: {\n          id: true,\n          name: true,\n          users: {\n            select: {\n              role: true,\n              teamId: true,\n              userId: true,\n              status: true,\n              user: {\n                select: {\n                  email: true,\n                  name: true,\n                },\n              },\n            },\n          },\n          documents: {\n            select: {\n              owner: {\n                select: {\n                  name: true,\n                  id: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      // check that the user is member of the team, otherwise return 403\n      const teamUsers = team?.users;\n      const isUserPartOfTeam = teamUsers?.some(\n        (user) => user.userId === (session.user as CustomUser).id,\n      );\n      if (!isUserPartOfTeam) {\n        return res.status(403).end(\"Unauthorized to access this team\");\n      }\n\n      return res.status(200).json(team);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauthorized\");\n      return;\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      // check if the team exists\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n        },\n        include: {\n          users: true,\n          domains: true,\n        },\n      });\n      if (!team) {\n        return res.status(400).json(\"Team doesn't exists\");\n      }\n\n      // check if current user is admin of the team\n      const isUserAdmin = team.users.some(\n        (user) =>\n          user.role === \"ADMIN\" &&\n          user.userId === (session.user as CustomUser).id,\n      );\n      if (!isUserAdmin) {\n        return res\n          .status(403)\n          .json({ message: \"You are not permitted to perform this action\" });\n      }\n\n      // get all documents using Vercel Blob storage\n      const documentsUsingBlob = await prisma.document.findMany({\n        where: {\n          teamId: teamId,\n          storageType: DocumentStorageType.VERCEL_BLOB,\n        },\n        select: {\n          file: true,\n          versions: {\n            select: {\n              file: true,\n              pages: {\n                select: {\n                  file: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      // get all branding files\n      const brandingFiles = await prisma.brand.findMany({\n        where: {\n          teamId,\n        },\n        select: {\n          logo: true,\n        },\n      });\n\n      // get all dataroom branding files\n      const dataroomBrandingFiles = await prisma.dataroomBrand.findMany({\n        where: {\n          dataroom: {\n            teamId: teamId,\n          },\n        },\n        select: {\n          logo: true,\n          banner: true,\n        },\n      });\n\n      let files: string[] = [];\n      let hasBlobDocuments = false;\n\n      if (documentsUsingBlob) {\n        hasBlobDocuments = true;\n        files = documentsUsingBlob.flatMap((doc) => [\n          doc.file,\n          ...doc.versions.flatMap((version) => [\n            version.file,\n            ...version.pages.map((page) => page.file),\n          ]),\n        ]);\n      }\n\n      if (brandingFiles) {\n        files = [\n          ...files,\n          ...brandingFiles\n            .map((brand) => brand.logo)\n            .filter((logo): logo is string => logo !== null),\n        ];\n      }\n\n      if (dataroomBrandingFiles) {\n        files = [\n          ...files,\n          ...dataroomBrandingFiles\n            .flatMap((brand) => [brand.logo, brand.banner])\n            .filter((item): item is string => item !== null),\n        ];\n      }\n\n      // delete all files from storage\n      await deleteFiles({ teamId, data: hasBlobDocuments ? files : undefined });\n\n      // if user doesn't have other teams, delete the user\n      const userTeams = await prisma.team.findMany({\n        where: {\n          users: {\n            some: {\n              userId: (session.user as CustomUser).id,\n            },\n          },\n        },\n      });\n\n      // prepare a list of promises to delete domains and their Redis redirect entries\n      let domainPromises: Promise<unknown>[] = [];\n      if (team.domains) {\n        domainPromises = team.domains.flatMap((domain) => [\n          removeDomainFromVercelProject(domain.slug),\n          deleteDomainRedirectUrl(domain.slug),\n        ]);\n      }\n\n      await Promise.all([\n        // delete domains, if exists on team\n        team.domains && domainPromises,\n        // delete subscription, if exists on team\n        team.stripeId &&\n        cancelSubscription(team.stripeId, isOldAccount(team.plan)),\n        // delete user from contact book\n        unsubscribe((session.user as CustomUser).email ?? \"\"),\n        // delete user, if no other teams\n        userTeams.length === 1 &&\n        prisma.user.delete({\n          where: {\n            id: (session.user as CustomUser).id,\n          },\n        }),\n        // delete team branding from redis\n        redis.del(`brand:logo:${teamId}`),\n\n        // delete team\n        prisma.team.delete({\n          where: {\n            id: teamId,\n          },\n        }),\n      ]);\n\n      return res.status(204).end();\n    } catch (error) {\n      return res.status(500).json((error as Error).message);\n    }\n  } else {\n    // We only allow GET and DELETE requests\n    res.setHeader(\"Allow\", [\"GET\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/integrations/slack/channels.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { getSlackClient } from \"@/lib/integrations/slack/client\";\nimport { getSlackEnv } from \"@/lib/integrations/slack/env\";\nimport { SlackCredential } from \"@/lib/integrations/slack/types\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport const config = {\n  maxDuration: 300,\n};\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const userId = (session.user as CustomUser).id;\n\n  const userTeam = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId,\n        teamId,\n      },\n    },\n  });\n\n  if (!userTeam) {\n    return res.status(403).json({ error: \"Access denied\" });\n  }\n\n  const env = getSlackEnv();\n\n  if (req.method === \"GET\") {\n    try {\n      const integration = await prisma.installedIntegration.findUnique({\n        where: {\n          teamId_integrationId: {\n            teamId,\n            integrationId: env.SLACK_INTEGRATION_ID,\n          },\n        },\n        select: {\n          credentials: true,\n        },\n      });\n\n      if (!integration) {\n        return res.status(404).json({ error: \"Slack integration not found\" });\n      }\n\n      try {\n        const slackClient = getSlackClient();\n        const channels = await slackClient.getChannels(\n          (integration.credentials as SlackCredential).accessToken,\n        );\n\n        const availableChannels = channels\n          .filter((channel) => !channel.is_archived)\n          .map((channel) => ({\n            id: channel.id,\n            name: channel.name,\n            is_private: channel.is_private,\n            is_member: channel.is_member || false,\n          }))\n          .sort((a, b) => a.name.localeCompare(b.name));\n\n        return res.status(200).json({ channels: availableChannels });\n      } catch (slackError) {\n        if (\n          slackError instanceof Error &&\n          slackError.message.includes(\"missing_scope\")\n        ) {\n          return res.status(403).json({\n            error:\n              \"Insufficient permissions. The Slack app needs channels:read permission.\",\n          });\n        }\n\n        if (\n          slackError instanceof Error &&\n          slackError.message.includes(\"invalid_auth\")\n        ) {\n          return res.status(401).json({\n            error:\n              \"Invalid Slack access token. Please reconnect your Slack integration.\",\n          });\n        }\n        console.error(\"Unexpected Slack error:\", slackError);\n        return res\n          .status(502)\n          .json({ error: \"Failed to fetch Slack channels\" });\n      }\n    } catch (error) {\n      console.error(\"Error fetching Slack channels:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  if (req.method === \"PUT\") {\n    try {\n      const updateChannelsSchema = z.object({\n        enabledChannels: z.record(\n          z.string(),\n          z.object({\n            id: z.string(),\n            name: z.string(),\n            enabled: z.boolean(),\n            notificationTypes: z.array(z.string()),\n          }),\n        ),\n      });\n      const parsed = updateChannelsSchema.safeParse(req.body);\n      if (!parsed.success) {\n        return res\n          .status(400)\n          .json({ error: \"Invalid enabledChannels format\" });\n      }\n      const { enabledChannels } = parsed.data;\n\n      await prisma.installedIntegration.update({\n        where: {\n          teamId_integrationId: {\n            teamId,\n            integrationId: env.SLACK_INTEGRATION_ID,\n          },\n        },\n        data: { configuration: { enabledChannels } },\n      });\n\n      return res.status(200).json({\n        success: true,\n        enabledChannels,\n        updatedAt: new Date().toISOString(),\n      });\n    } catch (error) {\n      console.error(\"Error updating Slack channels:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  return res.status(405).json({ error: \"Method not allowed\" });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/integrations/slack/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { getSlackEnv } from \"@/lib/integrations/slack/env\";\nimport {\n  SlackCredential,\n  SlackCredentialPublic,\n} from \"@/lib/integrations/slack/types\";\nimport { uninstallSlackIntegration } from \"@/lib/integrations/slack/uninstall\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nconst channelConfigSchema = z.object({\n  enabled: z.boolean(),\n  notificationTypes: z\n    .array(z.enum([\"document_view\", \"document_download\", \"dataroom_access\"]))\n    .default([]),\n  name: z.string().optional(),\n  id: z.string().optional(),\n});\nconst slackIntegrationUpdateSchema = z.object({\n  enabled: z.boolean().optional(),\n  enabledChannels: z.record(z.string(), channelConfigSchema).optional(),\n});\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const userId = (session.user as CustomUser).id;\n\n  const userTeam = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId,\n        teamId,\n      },\n    },\n  });\n\n  if (!userTeam) {\n    return res.status(403).json({ error: \"Access denied\" });\n  }\n\n  switch (req.method) {\n    case \"GET\":\n      return handleGet(req, res, teamId);\n    case \"PUT\":\n      return handleUpdate(req, res, teamId);\n    case \"DELETE\":\n      return handleDelete(req, res, teamId);\n    default:\n      return res.status(405).json({ error: \"Method not allowed\" });\n  }\n}\n\nasync function handleGet(\n  req: NextApiRequest,\n  res: NextApiResponse,\n  teamId: string,\n) {\n  const env = getSlackEnv();\n\n  try {\n    const integrationFullData = await prisma.installedIntegration.findUnique({\n      where: {\n        teamId_integrationId: {\n          teamId,\n          integrationId: env.SLACK_INTEGRATION_ID,\n        },\n      },\n      select: {\n        id: true,\n        credentials: true,\n        configuration: true,\n        enabled: true,\n        createdAt: true,\n        updatedAt: true,\n      },\n    });\n\n    if (!integrationFullData) {\n      return res.status(404).json({ error: \"Slack integration not found\" });\n    }\n\n    const integration = {\n      ...integrationFullData,\n      credentials: {\n        team: (integrationFullData.credentials as SlackCredential)?.team,\n      },\n    };\n\n    return res.status(200).json(integration);\n  } catch (error) {\n    console.error(\"Error fetching Slack integration:\", error);\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n\nasync function handleUpdate(\n  req: NextApiRequest,\n  res: NextApiResponse,\n  teamId: string,\n) {\n  const env = getSlackEnv();\n  try {\n    const validationResult = slackIntegrationUpdateSchema.safeParse(req.body);\n\n    if (!validationResult.success) {\n      return res.status(400).json({\n        error: \"Invalid request payload\",\n        details: validationResult.error.errors,\n      });\n    }\n\n    const { enabled, enabledChannels } = validationResult.data;\n\n    if (enabledChannels && Object.keys(validationResult.data).length === 1) {\n      await prisma.installedIntegration.update({\n        where: {\n          teamId_integrationId: {\n            teamId,\n            integrationId: env.SLACK_INTEGRATION_ID,\n          },\n        },\n        data: { configuration: { enabledChannels } },\n      });\n\n      return res.status(200).json({\n        success: true,\n        enabledChannels,\n        updatedAt: new Date().toISOString(),\n      });\n    }\n\n    const updateData: any = {};\n    if (enabled !== undefined) updateData.enabled = enabled;\n    if (enabledChannels) updateData.configuration = { enabledChannels };\n\n    if (Object.keys(updateData).length === 0) {\n      return res.status(400).json({ error: \"No fields to update\" });\n    }\n\n    const updatedIntegrationData = await prisma.installedIntegration.update({\n      where: {\n        teamId_integrationId: {\n          teamId,\n          integrationId: env.SLACK_INTEGRATION_ID,\n        },\n      },\n      data: updateData,\n      select: {\n        id: true,\n        credentials: true,\n        configuration: true,\n        enabled: true,\n        createdAt: true,\n        updatedAt: true,\n      },\n    });\n\n    const updatedIntegration = {\n      ...updatedIntegrationData,\n      credentials: {\n        team: (updatedIntegrationData.credentials as SlackCredential)?.team,\n      },\n    };\n\n    return res.status(200).json(updatedIntegration);\n  } catch (error) {\n    console.error(\"Error updating Slack integration:\", error);\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n\nasync function handleDelete(\n  req: NextApiRequest,\n  res: NextApiResponse,\n  teamId: string,\n) {\n  const env = getSlackEnv();\n  try {\n    const integration = await prisma.installedIntegration.findUnique({\n      where: {\n        teamId_integrationId: {\n          teamId,\n          integrationId: env.SLACK_INTEGRATION_ID,\n        },\n      },\n    });\n\n    if (!integration) {\n      return res.status(404).json({ error: \"Slack integration not found\" });\n    }\n\n    // Uninstall the Slack integration from the Slack workspace\n    await uninstallSlackIntegration({ installation: integration });\n\n    // Delete the Slack integration from the database\n    await prisma.installedIntegration.delete({\n      where: {\n        teamId_integrationId: {\n          teamId,\n          integrationId: env.SLACK_INTEGRATION_ID,\n        },\n      },\n    });\n\n    return res\n      .status(200)\n      .json({ message: \"Slack integration deleted successfully\" });\n  } catch (error) {\n    console.error(\"Error deleting Slack integration:\", error);\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/invitations/accept.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { identifyUser, trackAnalytics } from \"@/lib/analytics\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/invitations/accept?token=...&email=...\n    const session = await getServerSession(req, res, authOptions);\n\n    const { teamId, token, email } = req.query as {\n      teamId: string;\n      token?: string;\n      email?: string;\n    };\n\n    // Check if user is authenticated\n    if (!session) {\n      // Store the invitation details in the redirect URL\n      const redirectUrl = `/login?next=/api/teams/${teamId}/invitations/accept`;\n      const params = new URLSearchParams();\n\n      if (token) params.append(\"token\", token);\n      if (email) params.append(\"email\", email);\n\n      const finalRedirectUrl = params.toString()\n        ? `${redirectUrl}&${params.toString()}`\n        : redirectUrl;\n\n      res.redirect(finalRedirectUrl);\n      return;\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const userEmail = (session.user as CustomUser).email;\n\n    try {\n      // Check if user is already in the team\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (userTeam) {\n        return res.redirect(`/documents?invitation=teamMember`);\n      }\n\n      // Find the invitation\n      let invitation;\n      if (token && email) {\n        // First try to find by token and email\n        invitation = await prisma.invitation.findFirst({\n          where: {\n            token,\n            email,\n            teamId,\n          },\n        });\n\n        if (!invitation) {\n          console.log(\"Invitation not found with token and email\");\n        }\n      }\n\n      // If not found by token/email or if token/email not provided, try by user email\n      if (!invitation && userEmail) {\n        invitation = await prisma.invitation.findUnique({\n          where: {\n            email_teamId: {\n              email: userEmail,\n              teamId,\n            },\n          },\n        });\n\n        if (!invitation) {\n          console.log(\"Invitation not found with user email\");\n        }\n      }\n\n      if (!invitation) {\n        return res.status(404).json(\"Invalid invitation\");\n      }\n\n      if (invitation.email !== (session?.user as CustomUser).email) {\n        return res.status(403).json(\"You are not invited to this team\");\n      }\n\n      const currentTime = new Date();\n      if (currentTime > invitation.expires) {\n        return res.status(410).json(\"Invitation link has expired\");\n      }\n\n      await prisma.team.update({\n        where: {\n          id: teamId,\n        },\n        data: {\n          users: {\n            create: {\n              userId,\n            },\n          },\n        },\n      });\n\n      await identifyUser(invitation.email);\n      await trackAnalytics({\n        event: \"Team Member Invitation Accepted\",\n        teamId: teamId,\n      });\n\n      // delete the invitation record after user is successfully added to the team\n      await prisma.invitation.delete({\n        where: {\n          token: invitation.token,\n        },\n      });\n\n      return res.redirect(`/documents?invitation=accepted`);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/invitations/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/invitations\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      // check if currentUser is part of the team with the teamId\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: (session.user as CustomUser).id,\n            teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return res.status(403).json(\"You are not part of this team\");\n      }\n\n      // get current invitations for the team\n      const invitations = await prisma.invitation.findMany({\n        where: {\n          teamId: teamId,\n        },\n        select: {\n          email: true,\n          expires: true,\n        },\n      });\n\n      if (!invitations) {\n        return res.status(404).json(\"No invitations found for this team\");\n      }\n\n      res.status(200).json(invitations);\n      return;\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/invitations\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    const { email } = req.body as { email: string };\n\n    try {\n      // check if currentUser is part of the team with the teamId\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: (session.user as CustomUser).id,\n            teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return res.status(403).json(\"You are not part of this team\");\n      }\n\n      // delete invitation\n      await prisma.invitation.delete({\n        where: {\n          email_teamId: {\n            teamId: teamId,\n            email: email,\n          },\n        },\n      });\n\n      res.status(204).end();\n      return;\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/invitations/resend.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport { sendTeammateInviteEmail } from \"@/lib/emails/send-teammate-invite\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { generateChecksum } from \"@/lib/utils/generate-checksum\";\nimport { generateJWT } from \"@/lib/utils/generate-jwt\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/invitations/resend\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      res.status(401).end(\"Unauhorized\");\n      return;\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    const { email } = req.body as { email: string };\n\n    try {\n      // check if currentUser is part of the team with the teamId\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: (session.user as CustomUser).id,\n            teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        res.status(403).json(\"You are not part of this team\");\n        return;\n      }\n\n      const isUserAdmin = userTeam.role === \"ADMIN\";\n      if (!isUserAdmin) {\n        res.status(403).json(\"Only admins can resend the invitation!\");\n        return;\n      }\n\n      // get current team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n        },\n        select: {\n          name: true,\n        },\n      });\n\n      const expiresAt = new Date();\n      expiresAt.setHours(expiresAt.getHours() + 168); // invitation expires in 7 days\n\n      // update invitation\n      const invitation = await prisma.invitation.update({\n        where: {\n          email_teamId: {\n            email: email,\n            teamId: teamId,\n          },\n        },\n        data: {\n          expires: expiresAt,\n        },\n        select: {\n          token: true,\n        },\n      });\n\n      const verificationTokenRecord = await prisma.verificationToken.findUnique(\n        {\n          where: { token: hashToken(invitation.token) },\n        },\n      );\n\n      if (!verificationTokenRecord) {\n        await prisma.verificationToken.create({\n          data: {\n            expires: expiresAt,\n            token: hashToken(invitation.token),\n            identifier: email,\n          },\n        });\n      } else {\n        await prisma.verificationToken.update({\n          where: { token: hashToken(invitation.token) },\n          data: {\n            expires: expiresAt,\n          },\n        });\n      }\n\n      // send invite email\n      const sender = session.user as CustomUser;\n\n      // invitation acceptance URL\n      const invitationUrl = `/api/teams/${teamId}/invitations/accept?token=${invitation.token}&email=${email}`;\n      const fullInvitationUrl = `${process.env.NEXT_PUBLIC_BASE_URL}${invitationUrl}`;\n\n      // magic link\n      const magicLinkParams = new URLSearchParams({\n        email,\n        token: invitation.token,\n        callbackUrl: fullInvitationUrl,\n      });\n\n      const magicLink = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback/email?${magicLinkParams.toString()}`;\n\n      const verifyParams = new URLSearchParams({\n        verification_url: magicLink,\n        email,\n        token: invitation.token,\n        teamId,\n        type: \"invitation\",\n        expiresAt: expiresAt.toISOString(),\n      });\n\n      const verifyParamsObject = Object.fromEntries(verifyParams.entries());\n\n      const jwtToken = generateJWT(verifyParamsObject);\n\n      const verifyUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/verify/invitation?token=${jwtToken}`;\n\n      sendTeammateInviteEmail({\n        senderName: sender.name || \"\",\n        senderEmail: sender.email || \"\",\n        teamName: team?.name || \"\",\n        to: email,\n        url: verifyUrl,\n      });\n\n      res.status(200).json(\"Invitation sent again!\");\n      return;\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/invite.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getLimits } from \"@/ee/limits/server\";\nimport { getServerSession } from \"next-auth\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport { sendTeammateInviteEmail } from \"@/lib/emails/send-teammate-invite\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { generateChecksum } from \"@/lib/utils/generate-checksum\";\nimport { generateJWT } from \"@/lib/utils/generate-jwt\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/invite\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauhorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    const { email } = req.body;\n\n    if (!email) {\n      return res.status(400).json(\"Email is missing in request body\");\n    }\n\n    try {\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      if (!userTeam) {\n        res.status(403).json(\"You are not part of this team\");\n        return;\n      }\n\n      if (userTeam.role !== \"ADMIN\") {\n        res.status(403).json(\"Only admins can send the invitation!\");\n        return;\n      }\n\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n        },\n        include: {\n          users: {\n            select: {\n              userId: true,\n              role: true,\n              user: {\n                select: {\n                  email: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        res.status(404).json(\"Team not found\");\n        return;\n      }\n\n      const teamUsers = team.users;\n\n      // Check if the user has reached the limit of users in the team\n      const limits = await getLimits({\n        teamId,\n        userId,\n      });\n\n      if (limits && teamUsers.length >= limits.users) {\n        res\n          .status(403)\n          .json(\"You have reached the limit of users in your team\");\n        return;\n      }\n\n      // check if user is already in the team\n      const isExistingMember = teamUsers?.some(\n        (user) => user.user.email === email,\n      );\n\n      if (isExistingMember) {\n        res.status(400).json(\"User is already a member of this team\");\n        return;\n      }\n\n      // check if invitation already exists\n      const invitationExists = await prisma.invitation.findUnique({\n        where: {\n          email_teamId: {\n            teamId,\n            email,\n          },\n        },\n      });\n\n      if (invitationExists) {\n        res.status(400).json(\"Invitation already sent to this email\");\n        return;\n      }\n\n      const token = newId(\"inv\");\n      const expiresAt = new Date();\n      expiresAt.setHours(expiresAt.getHours() + 168); // 7 days // invitation expires in 24 hour\n\n      // create invitation\n      await prisma.invitation.create({\n        data: {\n          email,\n          token,\n          expires: expiresAt,\n          teamId,\n        },\n      });\n\n      await prisma.verificationToken.create({\n        data: {\n          token: hashToken(token),\n          identifier: email,\n          expires: expiresAt,\n        },\n      });\n\n      // send invite email\n      const sender = session.user as CustomUser;\n\n      // invitation acceptance URL\n      const invitationUrl = `/api/teams/${teamId}/invitations/accept?token=${token}&email=${email}`;\n      const fullInvitationUrl = `${process.env.NEXT_PUBLIC_BASE_URL}${invitationUrl}`;\n\n      // magic link\n      const magicLinkParams = new URLSearchParams({\n        email,\n        token,\n        callbackUrl: fullInvitationUrl,\n      });\n\n      const magicLink = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback/email?${magicLinkParams.toString()}`;\n\n      const verifyParams = new URLSearchParams({\n        verification_url: magicLink,\n        email,\n        token,\n        teamId,\n        type: \"invitation\",\n        expiresAt: expiresAt.toISOString(),\n      });\n\n      const verifyParamsObject = Object.fromEntries(verifyParams.entries());\n\n      const jwtToken = generateJWT(verifyParamsObject, 60 * 60 * 24 * 7); // 7 days\n\n      const verifyUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/verify/invitation?token=${jwtToken}`;\n\n      sendTeammateInviteEmail({\n        senderName: sender.name || \"\",\n        senderEmail: sender.email || \"\",\n        teamName: team?.name || \"\",\n        to: email,\n        url: verifyUrl,\n      });\n\n      return res.status(200).json(\"Invitation sent!\");\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/limits.ts",
    "content": "import limitsHandler from \"@/ee/limits/handler\";\n\nexport default limitsHandler;\n"
  },
  {
    "path": "pages/api/teams/[teamId]/links/[id]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { customAlphabet } from \"nanoid\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/links/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id: linkId } = req.query as { teamId: string; id: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // First verify user has access to the team and check plan\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n          role: {\n            in: [\"ADMIN\", \"MANAGER\"],\n          },\n          status: \"ACTIVE\",\n        },\n        select: {\n          teamId: true,\n          role: true,\n          team: {\n            select: {\n              plan: true,\n            },\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Check if team is on free plan\n      if (teamAccess.team.plan === \"free\") {\n        return res.status(403).json({\n          error:\n            \"Link deletion is not available on the free plan. Please upgrade to delete links.\",\n        });\n      }\n\n      // Find the link and verify it belongs to this team\n      const linkToDelete = await prisma.link.findUnique({\n        where: {\n          id: linkId,\n          teamId: teamId,\n          deletedAt: null, // Only allow deleting links that aren't already deleted\n        },\n        select: {\n          id: true,\n          slug: true,\n        },\n      });\n\n      if (!linkToDelete) {\n        return res.status(404).json({ error: \"Link not found\" });\n      }\n\n      // Generate a random suffix for the deleted slug to free up the original slug\n      const generateDeletedSuffix = customAlphabet(\n        \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\",\n        6,\n      );\n\n      // Soft delete the link by setting deletedAt and isArchived,\n      // and rename the slug so the original can be reused\n      await prisma.link.update({\n        where: {\n          id: linkId,\n        },\n        data: {\n          deletedAt: new Date(),\n          isArchived: true,\n          ...(linkToDelete.slug && {\n            slug: `${linkToDelete.slug}-DELETED-${generateDeletedSuffix()}`,\n          }),\n        },\n      });\n\n      log({\n        message: `Link deleted: ${linkId} by user ${userId} in team ${teamId}.`,\n        type: \"info\",\n      });\n\n      return res.status(204).end(); // 204 No Content response for successful deletes\n    } catch (error) {\n      log({\n        message: `Failed to delete link: ${linkId} in team ${teamId}. \\n\\n ${error} \\n\\n*Metadata*: \\`{teamId: ${teamId}, userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow DELETE requests for now\n    res.setHeader(\"Allow\", [\"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/links/[id]/visits.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport { LIMITS } from \"@/lib/constants\";\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { getDocumentWithTeamAndUser } from \"@/lib/team/helper\";\nimport { getViewPageDuration } from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nimport { authOptions } from \"../../../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/links/:id/visits\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    // get link id from query params\n    const { teamId, id } = req.query as { teamId: string; id: string };\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // get the numPages from document\n      const result = await prisma.link.findUnique({\n        where: {\n          id: id,\n        },\n        select: {\n          deletedAt: true,\n          document: {\n            select: {\n              id: true,\n              numPages: true,\n              versions: {\n                where: { isPrimary: true },\n                orderBy: { createdAt: \"desc\" },\n                take: 1,\n                select: { numPages: true },\n              },\n              team: {\n                select: {\n                  id: true,\n                  plan: true,\n                  pauseStartsAt: true,\n                  pauseEndsAt: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      // If link doesn't exist (deleted), return empty response\n      if (!result || !result.document || result.deletedAt) {\n        return res.status(200).json({ views: [], hiddenFromPause: 0 });\n      }\n\n      const docId = result.document.id;\n\n      // check if the the team that own the document has the current user\n      await getDocumentWithTeamAndUser({\n        docId,\n        userId,\n        options: {\n          team: {\n            select: {\n              users: {\n                select: {\n                  userId: true,\n                },\n              },\n            },\n          },\n        },\n      });\n\n      const numPages =\n        result?.document?.versions[0]?.numPages ||\n        result?.document?.numPages ||\n        0;\n\n      const pauseStartsAt = result?.document?.team?.pauseStartsAt;\n      const pauseEndsAt = result?.document?.team?.pauseEndsAt;\n\n      const allViews = await prisma.view.findMany({\n        where: {\n          linkId: id,\n          teamId: teamId,\n        },\n        orderBy: {\n          viewedAt: \"desc\",\n        },\n      });\n\n      // Filter out views that occurred during the pause period and count hidden views\n      let hiddenFromPause = 0;\n      const views =\n        pauseStartsAt && pauseEndsAt\n          ? allViews.filter((view) => {\n              const viewedAt = new Date(view.viewedAt);\n              const isDuringPause =\n                viewedAt >= pauseStartsAt && viewedAt <= pauseEndsAt;\n              if (isDuringPause) {\n                hiddenFromPause++;\n              }\n              return !isDuringPause;\n            })\n          : allViews;\n\n      // limit the number of views to 20 on free plan\n      const limitedViews =\n        result?.document?.team?.plan === \"free\"\n          ? views.slice(0, LIMITS.views)\n          : views;\n\n      const durationsPromises = limitedViews.map((view) => {\n        return getViewPageDuration({\n          documentId: view.documentId!,\n          viewId: view.id,\n          since: 0,\n        });\n      });\n\n      const durations = await Promise.all(durationsPromises);\n\n      // Sum up durations for each view\n      const summedDurations = durations.map((duration) => {\n        return duration.data.reduce(\n          (totalDuration, data) => totalDuration + data.sum_duration,\n          0,\n        );\n      });\n\n      // Construct the response combining views and their respective durations\n      const viewsWithDuration = limitedViews.map((view, index) => {\n        // calculate the completion rate\n        const completionRate = numPages\n          ? (durations[index].data.length / numPages) * 100\n          : 0;\n\n        return {\n          ...view,\n          duration: durations[index],\n          totalDuration: summedDurations[index],\n          completionRate: completionRate.toFixed(),\n        };\n      });\n\n      // TODO: Check that the user is owner of the links, otherwise return 401\n\n      return res.status(200).json({\n        views: viewsWithDuration,\n        hiddenFromPause,\n      });\n    } catch (error) {\n      log({\n        message: `Failed to get views for link: _${id}_. \\n\\n ${error} \\n\\n*Metadata*: \\`{userId: ${userId}}\\``,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/presets/[id].ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth\";\nimport { ZodError } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { presetDataSchema } from \"@/lib/zod/schemas/presets\";\n\nexport const config = {\n  api: {\n    bodyParser: {\n      sizeLimit: \"4mb\",\n    },\n  },\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, id: presetId } = req.query as { teamId: string; id: string };\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n      },\n      select: {\n        id: true,\n        users: { select: { userId: true } },\n      },\n    });\n\n    // check that the user is member of the team, otherwise return 403\n    const teamUsers = team?.users;\n    const isUserPartOfTeam = teamUsers?.some(\n      (user) => user.userId === (session.user as CustomUser).id,\n    );\n    if (!isUserPartOfTeam) {\n      return res.status(403).end(\"Unauthorized to access this team\");\n    }\n  } catch (error) {\n    errorhandler(error, res);\n  }\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/presets/:id\n    try {\n      const preset = await prisma.linkPreset.findFirst({\n        where: {\n          id: presetId,\n          teamId: teamId,\n        },\n      });\n\n      if (!preset) {\n        return res.status(404).json({ message: \"Preset not found\" });\n      }\n\n      return res.status(200).json(preset);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/presets/:id\n    try {\n      // Validate request body with Zod schema\n      const validatedData = presetDataSchema.parse(req.body);\n\n      // Check if preset exists and belongs to the team\n      const existingPreset = await prisma.linkPreset.findUnique({\n        where: {\n          id: presetId,\n          teamId: teamId,\n        },\n      });\n\n      if (!existingPreset) {\n        return res.status(404).json({ message: \"Preset not found\" });\n      }\n\n      // Update the preset\n      const updatedPreset = await prisma.linkPreset.update({\n        where: {\n          id: presetId,\n        },\n        data: {\n          ...validatedData,\n          ...(!existingPreset.pId && { pId: newId(\"preset\") }),\n          // Convert objects to JSON strings for storage\n          watermarkConfig: validatedData.watermarkConfig\n            ? JSON.stringify(validatedData.watermarkConfig)\n            : Prisma.JsonNull,\n          customFields: validatedData.customFields\n            ? validatedData.customFields\n            : Prisma.JsonNull,\n        },\n      });\n\n      return res.status(200).json({\n        message: \"Preset updated successfully\",\n        preset: updatedPreset,\n      });\n    } catch (error) {\n      if (error instanceof ZodError) {\n        return res.status(400).json({\n          message: \"Invalid preset data\",\n          errors: error.errors,\n        });\n      }\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/presets/:id\n    try {\n      // Check if preset exists and belongs to the team\n      const existingPreset = await prisma.linkPreset.findFirst({\n        where: {\n          id: presetId,\n          teamId: teamId,\n        },\n      });\n\n      if (!existingPreset) {\n        return res.status(404).json({ message: \"Preset not found\" });\n      }\n\n      await prisma.linkPreset.delete({\n        where: {\n          id: presetId,\n        },\n      });\n\n      return res.status(204).end();\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET, PUT, and DELETE requests\n    res.setHeader(\"Allow\", [\"GET\", \"PUT\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/presets/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth\";\nimport { ZodError } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { presetDataSchema } from \"@/lib/zod/schemas/presets\";\n\nexport const config = {\n  api: {\n    bodyParser: {\n      sizeLimit: \"4mb\",\n    },\n  },\n};\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId } = req.query as { teamId: string };\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n      },\n      select: {\n        id: true,\n        users: { select: { userId: true } },\n      },\n    });\n\n    // check that the user is member of the team, otherwise return 403\n    const teamUsers = team?.users;\n    const isUserPartOfTeam = teamUsers?.some(\n      (user) => user.userId === (session.user as CustomUser).id,\n    );\n    if (!isUserPartOfTeam) {\n      return res.status(403).end(\"Unauthorized to access this team\");\n    }\n  } catch (error) {\n    errorhandler(error, res);\n  }\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/presets\n    try {\n      const presets = await prisma.linkPreset.findMany({\n        where: {\n          teamId: teamId,\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      return res.status(200).json(presets);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/presets\n    try {\n      // Validate request body with Zod schema\n      const validatedData = presetDataSchema.parse(req.body);\n\n      // Create a new preset\n      const preset = await prisma.linkPreset.create({\n        data: {\n          ...validatedData,\n          teamId,\n          pId: newId(\"preset\"),\n          watermarkConfig: validatedData.watermarkConfig\n            ? JSON.stringify(validatedData.watermarkConfig)\n            : Prisma.JsonNull,\n          customFields: validatedData.customFields\n            ? validatedData.customFields\n            : Prisma.JsonNull,\n        },\n      });\n      return res\n        .status(201)\n        .json({ message: \"Preset created successfully\", preset });\n    } catch (error) {\n      if (error instanceof ZodError) {\n        return res.status(400).json({\n          message: \"Invalid preset data\",\n          errors: error.errors,\n        });\n      }\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET and POST requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/remove-teammate.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/remove-teammate\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    const { userToBeDeleted } = req.body;\n\n    try {\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return res.status(401).json(\"Unauthorized\");\n      }\n\n      if (userTeam?.role === \"ADMIN\" && userTeam.userId === userToBeDeleted) {\n        return res.status(401).json(\"You can't remove the Admin\");\n      }\n\n      await Promise.all([\n        // update all documents owned by the user to be deleted to be owned by the team\n        prisma.document.updateMany({\n          where: {\n            teamId,\n            ownerId: userToBeDeleted,\n          },\n          data: {\n            ownerId: null,\n          },\n        }),\n        // update all links owned by the user to have no owner\n        prisma.link.updateMany({\n          where: {\n            teamId,\n            ownerId: userToBeDeleted,\n          },\n          data: {\n            ownerId: null,\n          },\n        }),\n        // delete the user from the team\n        prisma.userTeam.delete({\n          where: {\n            userId_teamId: {\n              userId: userToBeDeleted,\n              teamId,\n            },\n          },\n        }),\n      ]);\n\n      return res.status(204).end();\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/settings.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\n// Validate IANA timezone by attempting to use it with Intl.DateTimeFormat\nconst isValidIANATimezone = (tz: string): boolean => {\n  try {\n    Intl.DateTimeFormat(undefined, { timeZone: tz });\n    return true;\n  } catch {\n    return false;\n  }\n};\n\nconst updateSettingsSchema = z.object({\n  timezone: z\n    .string()\n    .refine(isValidIANATimezone, (tz) => ({\n      message: `Invalid timezone: \"${tz}\" is not a valid IANA timezone identifier. Examples of valid timezones: \"America/New_York\", \"Europe/London\", \"Asia/Tokyo\".`,\n    }))\n    .optional(),\n  replicateDataroomFolders: z.boolean().optional(),\n  enableExcelAdvancedMode: z.boolean().optional(),\n});\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const userId = (session.user as CustomUser).id;\n\n  // Verify user has access to the team\n  const teamAccess = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId: userId,\n        teamId: teamId,\n      },\n    },\n    select: { teamId: true, role: true },\n  });\n\n  if (!teamAccess) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/settings\n    try {\n      // Fetch only the settings fields\n      const teamSettings = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n        },\n        select: {\n          replicateDataroomFolders: true,\n          enableExcelAdvancedMode: true,\n          timezone: true,\n        },\n      });\n\n      if (!teamSettings) {\n        return res.status(404).json({ error: \"Team not found\" });\n      }\n\n      return res.status(200).json(teamSettings);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/settings\n    // Only admins and managers can update settings\n    if (teamAccess.role !== \"ADMIN\" && teamAccess.role !== \"MANAGER\") {\n      return res.status(403).json({ error: \"Insufficient permissions\" });\n    }\n\n    try {\n      const result = updateSettingsSchema.safeParse(req.body);\n      if (!result.success) {\n        return res\n          .status(400)\n          .json({ error: `Invalid body: ${result.error.message}` });\n      }\n\n      const { timezone, replicateDataroomFolders, enableExcelAdvancedMode } =\n        result.data;\n\n      // Build update object with only provided fields\n      const updateData: {\n        timezone?: string;\n        replicateDataroomFolders?: boolean;\n        enableExcelAdvancedMode?: boolean;\n      } = {};\n\n      if (timezone !== undefined) {\n        updateData.timezone = timezone;\n      }\n      if (replicateDataroomFolders !== undefined) {\n        updateData.replicateDataroomFolders = replicateDataroomFolders;\n      }\n      if (enableExcelAdvancedMode !== undefined) {\n        updateData.enableExcelAdvancedMode = enableExcelAdvancedMode;\n      }\n\n      const updatedTeam = await prisma.team.update({\n        where: {\n          id: teamId,\n        },\n        data: updateData,\n        select: {\n          replicateDataroomFolders: true,\n          enableExcelAdvancedMode: true,\n          timezone: true,\n        },\n      });\n\n      return res.status(200).json(updatedTeam);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\", \"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/survey.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\n// Zod schema for survey data - flexible for future options\nconst surveyDataSchema = z.object({\n  dealType: z\n    .enum([\n      \"startup-fundraising\",\n      \"fund-management\",\n      \"mergers-acquisitions\",\n      \"project-management\",\n      \"sales\",\n      \"financial-operations\",\n      \"real-estate\",\n      \"other\",\n    ])\n    .optional()\n    .nullable(),\n  dealTypeOther: z.string().max(200).optional().nullable(),\n  dealSize: z\n    .enum([\"0-500k\", \"500k-5m\", \"5m-10m\", \"10m-100m\", \"100m+\"])\n    .optional()\n    .nullable(),\n  dismissed: z.boolean().optional().nullable(),\n  dismissedAt: z.string().datetime().optional().nullable(),\n  dismissedBy: z.string().optional().nullable(),\n});\n\nexport type SurveyData = z.infer<typeof surveyDataSchema>;\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const userId = (session.user as CustomUser).id;\n\n  const teamAccess = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId,\n        teamId,\n      },\n    },\n  });\n\n  if (!teamAccess) {\n    return res.status(403).json({ error: \"Forbidden\" });\n  }\n\n  if (req.method === \"GET\") {\n    try {\n      const team = await prisma.team.findUnique({\n        where: { id: teamId },\n        select: { surveyData: true },\n      });\n\n      const surveyData = (team?.surveyData as SurveyData) || {};\n\n      return res.status(200).json({\n        dealType: surveyData.dealType || null,\n        dealTypeOther: surveyData.dealTypeOther || null,\n        dealSize: surveyData.dealSize || null,\n        dismissed: surveyData.dismissed || false,\n        dismissedAt: surveyData.dismissedAt || null,\n        dismissedBy: surveyData.dismissedBy || null,\n      });\n    } catch (error) {\n      return errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    try {\n      const validationResult = surveyDataSchema.safeParse(req.body);\n\n      if (!validationResult.success) {\n        return res.status(400).json({\n          error: \"Invalid survey data\",\n          details: validationResult.error.errors,\n        });\n      }\n\n      const { dealType, dealTypeOther, dealSize, dismissed, dismissedAt } =\n        validationResult.data;\n\n      // Get current survey data to merge with new data\n      const team = await prisma.team.findUnique({\n        where: { id: teamId },\n        select: { surveyData: true },\n      });\n\n      const currentSurveyData = (team?.surveyData as SurveyData) || {};\n\n      const isSurveyComplete =\n        (dealType ?? currentSurveyData.dealType) &&\n        ((dealType ?? currentSurveyData.dealType) === \"project-management\" ||\n          (dealSize ?? currentSurveyData.dealSize));\n\n      const updatedSurveyData: SurveyData = {\n        ...currentSurveyData,\n        ...(dealType !== undefined && { dealType }),\n        ...(dealTypeOther !== undefined && { dealTypeOther }),\n        ...(dealSize !== undefined && { dealSize }),\n        ...(dismissed !== undefined && { dismissed }),\n        ...(dismissedAt !== undefined && { dismissedAt }),\n        ...(dismissed && { dismissedBy: userId }),\n        ...(isSurveyComplete &&\n          currentSurveyData.dismissed && {\n            dismissed: null,\n            dismissedAt: null,\n            dismissedBy: null,\n          }),\n      };\n\n      await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          surveyData: updatedSurveyData,\n        },\n      });\n\n      return res.status(200).json({ success: true });\n    } catch (error) {\n      return errorhandler(error, res);\n    }\n  } else {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/tags/[id]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { createTagBodySchema } from \"..\";\n\nconst updateTagBodySchema = createTagBodySchema;\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId } = req.query as { teamId: string };\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n      },\n      select: {\n        id: true,\n        users: { select: { userId: true } },\n      },\n    });\n\n    // check that the user is member of the team, otherwise return 403\n    const teamUsers = team?.users;\n    const isUserPartOfTeam = teamUsers?.some(\n      (user) => user.userId === (session.user as CustomUser).id,\n    );\n    if (!isUserPartOfTeam) {\n      return res.status(403).end(\"Unauthorized to access this team\");\n    }\n  } catch (error) {\n    errorhandler(error, res);\n  }\n  if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/tags/[id]\n\n    const { teamId, id } = req.query as { teamId: string; id: string };\n    const {\n      name,\n      color,\n      description = \"\",\n    } = updateTagBodySchema.parse(req.body);\n    const tag = await prisma.tag.findUnique({\n      where: {\n        id: id,\n        teamId: teamId,\n      },\n    });\n\n    if (!tag) {\n      return res.status(404).json({ error: \"Tag not found.\" });\n    }\n    try {\n      const response = await prisma.tag.update({\n        where: {\n          id: id,\n        },\n        data: {\n          name,\n          color,\n          description,\n        },\n      });\n\n      return res.status(200).json(response);\n    } catch (error) {\n      return res.status(500).json({ error: (error as Error).message });\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/tags/[id]\n    const { teamId, id } = req.query as { teamId: string; id: string };\n    // First verify the tag belongs to the team\n    const tag = await prisma.tag.findUnique({\n      where: { id, teamId },\n    });\n\n    if (!tag) {\n      return res.status(404).json({ error: \"Tag not found\" });\n    }\n\n    // Then delete the tag\n    await prisma.tag.delete({\n      where: { id, teamId },\n      include: {\n        items: true,\n      },\n    });\n    return res.status(204).end();\n  } else {\n    // We only allow GET and DELETE requests\n    res.setHeader(\"Allow\", [\"PUT\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/tags/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\nimport { z } from \"zod\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser, tagColors } from \"@/lib/types\";\n\nimport {\n  COLORS_LIST,\n  randomBadgeColor,\n} from \"@/components/links/link-sheet/tags/tag-badge\";\n\nexport const searchParamsSchema = z.object({\n  sortBy: z\n    .enum([\"name\", \"createdAt\"])\n    .optional()\n    .default(\"name\")\n    .describe(\"The field to sort the tags by.\"),\n  sortOrder: z\n    .enum([\"asc\", \"desc\"])\n    .optional()\n    .default(\"asc\")\n    .describe(\"The order to sort the tags by.\"),\n  search: z\n    .string()\n    .optional()\n    .describe(\"The search term to filter the tags by.\"),\n  includeLinksCount: z\n    .preprocess((val) => val === \"true\", z.boolean())\n    .optional(),\n  page: z\n    .preprocess((val) => parseInt(val as string, 10), z.number().min(1))\n    .optional(),\n  pageSize: z\n    .preprocess(\n      (val) => parseInt(val as string, 10),\n      z.number().min(1).max(100),\n    )\n    .optional(),\n});\n\nexport const tagColorSchema = z\n  .enum(tagColors, {\n    errorMap: () => {\n      return {\n        message: `Invalid color. Must be one of: ${tagColors.join(\", \")}`,\n      };\n    },\n  })\n  .describe(\"The color of the tag\");\n\nexport const createTagBodySchema = z\n  .object({\n    name: z\n      .string()\n      .trim()\n      .min(3)\n      .max(50)\n      .describe(\"The name of the tag to create.\"),\n    color: tagColorSchema.describe(\n      `The color of the tag. If not provided, a random color will be used from the list: ${tagColors.join(\", \")}.`,\n    ),\n    description: z\n      .string()\n      .trim()\n      .max(120)\n      .nullish()\n      .describe(\"The description of the tag to create.\"),\n  })\n  .partial()\n  .superRefine((data, ctx) => {\n    if (!data.name) {\n      ctx.addIssue({\n        code: z.ZodIssueCode.custom,\n        path: [\"name\"],\n        message: \"Name is required.\",\n      });\n    }\n  });\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId } = req.query as { teamId: string };\n\n  try {\n    const team = await prisma.team.findUnique({\n      where: {\n        id: teamId,\n      },\n      select: {\n        id: true,\n        users: { select: { userId: true } },\n      },\n    });\n\n    // check that the user is member of the team, otherwise return 403\n    const teamUsers = team?.users;\n    const isUserPartOfTeam = teamUsers?.some(\n      (user) => user.userId === (session.user as CustomUser).id,\n    );\n    if (!isUserPartOfTeam) {\n      return res.status(403).end(\"Unauthorized to access this team\");\n    }\n  } catch (error) {\n    errorhandler(error, res);\n  }\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/tag\n    const { search, includeLinksCount, sortBy, sortOrder, pageSize, page } =\n      searchParamsSchema.parse(req.query);\n\n    const whereCondition = {\n      teamId: teamId,\n      ...(search && {\n        name: {\n          contains: search,\n        },\n      }),\n    };\n\n    const [tags, totalCount] = await prisma.$transaction([\n      prisma.tag.findMany({\n        where: whereCondition,\n        select: {\n          id: true,\n          name: true,\n          color: true,\n          description: true,\n          ...(includeLinksCount && {\n            _count: {\n              select: {\n                items: true,\n              },\n            },\n          }),\n        },\n        orderBy: {\n          [sortBy]: sortOrder,\n        },\n        ...(pageSize &&\n          page && {\n            take: pageSize,\n            skip: (page - 1) * pageSize,\n          }),\n      }),\n      prisma.tag.count({ where: whereCondition }),\n    ]);\n\n    if (!tags) {\n      return res.status(200).json({ tags: null, totalCount: 0 });\n    }\n\n    return res.status(200).json({ tags, totalCount });\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/tag\n\n    const { color, name, description } = createTagBodySchema.parse(req.body);\n\n    const existingTag = await prisma.tag.findFirst({\n      where: {\n        teamId: teamId,\n        name: name,\n      },\n    });\n    if (existingTag) {\n      return res.status(400).json({\n        error: \"A tag with that name already exists.\",\n      });\n    }\n    const response = await prisma.tag.create({\n      data: {\n        name: name!,\n        color:\n          color && COLORS_LIST.map(({ color }) => color).includes(color)\n            ? color\n            : randomBadgeColor(),\n        teamId: teamId,\n        description: description,\n      },\n    });\n    return res.status(200).json(response);\n  } else {\n    // We only allow GET and DELETE requests\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/tokens/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const { teamId } = req.query as { teamId: string };\n\n  const features = await getFeatureFlags({ teamId });\n  if (!features.tokens) {\n    return res\n      .status(403)\n      .json({ error: \"This feature is not available for your team\" });\n  }\n\n  if (req.method === \"GET\") {\n    try {\n      const session = await getServerSession(req, res, authOptions);\n      if (!session) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      const { teamId } = req.query as { teamId: string };\n      const userId = (session.user as CustomUser).id;\n\n      // Check if user is in team\n      const userTeam = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!userTeam) {\n        return res.status(403).json({ error: \"Unauthorized\" });\n      }\n\n      // Fetch tokens\n      const tokens = await prisma.restrictedToken.findMany({\n        where: {\n          teamId,\n        },\n        select: {\n          id: true,\n          name: true,\n          partialKey: true,\n          createdAt: true,\n          lastUsed: true,\n          user: {\n            select: {\n              name: true,\n              email: true,\n            },\n          },\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      return res.status(200).json(tokens);\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Error fetching tokens\" });\n    }\n  } else if (req.method === \"POST\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).json({ error: \"Unauthorized\" });\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n    const { name } = req.body;\n\n    try {\n      // Check if user is in team\n      const { role } = await prisma.userTeam.findUniqueOrThrow({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      // Only admins and managers can create tokens\n      if (role !== \"ADMIN\" && role !== \"MANAGER\") {\n        return res.status(403).json({\n          error:\n            \"You don't have the permissions to create a token. Please contact your administrator or manager.\",\n        });\n      }\n\n      // Generate token\n      const token = newId(\"token\"); // pmk_\n      const hashedToken = hashToken(token);\n      const partialKey = `${token.slice(0, 3)}...${token.slice(-4)}`;\n\n      // Create token in database\n      await prisma.restrictedToken.create({\n        data: {\n          name,\n          hashedKey: hashedToken,\n          partialKey,\n          teamId,\n          userId,\n        },\n      });\n\n      // Return token only once\n      return res.status(200).json({ token });\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Error creating token\" });\n    }\n  } else if (req.method === \"DELETE\") {\n    try {\n      const session = await getServerSession(req, res, authOptions);\n      if (!session) {\n        return res.status(401).json({ error: \"Unauthorized\" });\n      }\n\n      const { teamId } = req.query as { teamId: string };\n      const { tokenId } = req.body;\n      const userId = (session.user as CustomUser).id;\n\n      // Check if user is in team and has admin role\n      const { role } = await prisma.userTeam.findUniqueOrThrow({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n        select: {\n          role: true,\n        },\n      });\n\n      // Only admins can delete tokens\n      if (role !== \"ADMIN\") {\n        return res.status(403).json({\n          error:\n            \"You don't have the permissions to delete a token. Please contact your administrator.\",\n        });\n      }\n\n      // Delete the token\n      await prisma.restrictedToken.delete({\n        where: {\n          id: tokenId,\n          teamId, // Ensure token belongs to the team\n        },\n      });\n\n      return res.status(200).json({ message: \"Token deleted successfully\" });\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Error deleting token\" });\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\", \"POST\", \"DELETE\"]);\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/update-advanced-mode.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { Session } from \"next-auth\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\ninterface CustomSession extends Session {\n  user: {\n    id: string;\n    name?: string | null;\n  };\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/update-advanced-mode\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const { enableExcelAdvancedMode } = req.body;\n\n    try {\n      // Verify user is part of the team\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId: (session.user as CustomUser).id,\n            },\n          },\n        },\n      });\n\n      if (!team) {\n        return res.status(404).json({ error: \"Team not found.\" });\n      }\n\n      const isPlanRestricted = [\"free\", \"starter\", \"pro\"].includes(team.plan);\n      const isTrial = team.plan.includes(\"trial\");\n\n      if (isPlanRestricted && !isTrial) {\n        return res\n          .status(403)\n          .json({ error: \"Your current plan does not allow this feature.\" });\n      }\n\n      // Update team limits\n      await prisma.team.update({\n        where: {\n          id: teamId,\n        },\n        data: {\n          enableExcelAdvancedMode,\n        },\n      });\n\n      return res.status(200).json(\"Excel mode updated!\");\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow PATCH requests\n    res.setHeader(\"Allow\", \"[PATCH]\");\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/update-name.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { validateContent } from \"@/lib/utils/sanitize-html\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/update-name\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n\n    try {\n      // check if the team exists\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n        },\n        include: {\n          users: true,\n        },\n      });\n      if (!team) {\n        return res.status(400).json(\"Team doesn't exists\");\n      }\n\n      // check if current user is admin of the team\n      const isUserAdmin = team.users.some(\n        (user) =>\n          user.role === \"ADMIN\" &&\n          user.userId === (session.user as CustomUser).id,\n      );\n      if (!isUserAdmin) {\n        return res\n          .status(403)\n          .json(\"You are not permitted to perform this action\");\n      }\n\n      // Validate and sanitize the team name\n      let sanitizedName: string;\n      try {\n        sanitizedName = validateContent(req.body.name);\n      } catch (error) {\n        return res.status(400).json({\n          error: {\n            message: (error as Error).message || \"Invalid team name\",\n          },\n        });\n      }\n\n      // Check if name exceeds the maximum length (32 characters as per frontend)\n      if (sanitizedName.length > 32) {\n        return res.status(400).json({\n          error: {\n            message: \"Team name cannot exceed 32 characters\",\n          },\n        });\n      }\n\n      // update name\n      await prisma.team.update({\n        where: {\n          id: teamId,\n        },\n        data: {\n          name: sanitizedName,\n        },\n      });\n\n      return res.status(200).json(\"Team name updated!\");\n    } catch (error) {\n      return res.status(500).json((error as Error).message);\n    }\n  } else {\n    // We only allow PATCH requests\n    res.setHeader(\"Allow\", \"[PATCH]\");\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/update-replicate-folders.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PATCH\") {\n    // PATCH /api/teams/:teamId/update-replicate-folders\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId } = req.query as { teamId: string };\n    const { replicateDataroomFolders } = req.body as {\n      replicateDataroomFolders: boolean;\n    };\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      // Update the team's replicateDataroomFolders setting\n      const updatedTeam = await prisma.team.update({\n        where: { id: teamId },\n        data: {\n          replicateDataroomFolders,\n        },\n        select: {\n          id: true,\n          name: true,\n          replicateDataroomFolders: true,\n        },\n      });\n\n      return res.status(200).json(updatedTeam);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow PATCH requests\n    res.setHeader(\"Allow\", [\"PATCH\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/viewers/[id]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { redis } from \"@/lib/redis\";\nimport { getDocumentDurationPerViewer } from \"@/lib/tinybird\";\nimport { CustomUser } from \"@/lib/types\";\nimport { Prisma } from \"@prisma/client\";\n\nasync function fetchAndCacheDurations(\n  groupedViews: Array<{ documentId: string; viewIds: string[] }>,\n  teamId: string,\n  viewerId: string,\n  cacheKey: string\n): Promise<Record<string, number>> {\n  let durationsMap: Record<string, number> = {};\n  const cachedDurations = await redis.get(cacheKey);\n\n  if (cachedDurations) {\n    const parsedDurations = typeof cachedDurations === 'string' ? JSON.parse(cachedDurations) : cachedDurations;\n    durationsMap = parsedDurations;\n  } else {\n    const batchSize = 10; \n    for (let i = 0; i < groupedViews.length; i += batchSize) {\n      const batch = groupedViews.slice(i, i + batchSize);\n\n      const batchPromises = batch.map(async (view) => {\n        try {\n          const durationResult = await getDocumentDurationPerViewer({\n            documentId: view.documentId,\n            viewIds: view.viewIds.join(\",\"),\n          });\n          return {\n            documentId: view.documentId,\n            totalDuration: durationResult.data[0]?.sum_duration || 0,\n          };\n        } catch (error) {\n          console.error(`Error fetching duration for document ${view.documentId}:`, error);\n          return {\n            documentId: view.documentId,\n            totalDuration: 0,\n          };\n        }\n      });\n\n      const batchResults = await Promise.all(batchPromises);\n      batchResults.forEach(result => {\n        durationsMap[result.documentId] = result.totalDuration;\n      });\n    }\n\n    await redis.set(cacheKey, JSON.stringify(durationsMap), { ex: 600 });\n  }\n\n  return durationsMap;\n}\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/viewers/:id\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId, id, page, pageSize, sortBy, sortOrder, withDuration } = req.query as {\n      teamId: string;\n      id: string;\n      page?: string;\n      pageSize?: string;\n      sortBy?: string;\n      sortOrder?: string;\n      withDuration?: string;\n    };\n\n    // Parse pagination parameters\n    const currentPage = parseInt(page || \"1\", 10);\n    const limit = Math.min(parseInt(pageSize || \"10\", 10), 100); // Cap at 100 for performance\n    const offset = (currentPage - 1) * limit;\n\n    // Parse sorting parameters\n    const validSortFields = [\"lastViewed\", \"totalDuration\", \"viewCount\"];\n    const validSortOrders = [\"asc\", \"desc\"];\n    const sort = validSortFields.includes(sortBy || \"\") ? sortBy : \"lastViewed\";\n    const order = validSortOrders.includes(sortOrder || \"\") ? sortOrder : \"desc\";\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: { id: true, plan: true },\n      });\n\n      if (!team || team.plan === \"free\") {\n        return res.status(404).json({ message: \"Team not found\" });\n      }\n\n      const viewer = await prisma.viewer.findUnique({\n        where: { id, teamId },\n        select: {\n          id: true,\n          email: true,\n          createdAt: true,\n          updatedAt: true,\n        },\n      });\n\n      if (!viewer) {\n        return res.status(404).json({ message: \"Viewer not found\" });\n      }\n\n      // Enhanced cache key\n      const cacheKey = `viewer-details:${teamId}:${id}:${currentPage}:${limit}:${sort}:${order}:v2`;\n\n      let groupedViews: Array<{\n        documentId: string;\n        viewCount: number;\n        lastViewed: Date;\n        documentName: string | null;\n        documentType: string | null;\n        documentContentType: string | null;\n        viewIds: string[];\n        totalDuration?: number;\n      }>;\n\n      let orderByClause: Prisma.Sql;\n      if (sort === \"lastViewed\") {\n        orderByClause = order === \"desc\"\n          ? Prisma.sql`\"lastViewed\" DESC`\n          : Prisma.sql`\"lastViewed\" ASC`;\n      } else if (sort === \"viewCount\") {\n        orderByClause = order === \"desc\"\n          ? Prisma.sql`\"viewCount\" DESC, \"lastViewed\" DESC`\n          : Prisma.sql`\"viewCount\" ASC, \"lastViewed\" DESC`;\n      } else {\n        orderByClause = Prisma.sql`\"lastViewed\" DESC`;\n      }\n\n      if (sort === \"totalDuration\") {\n        const allDocuments = await prisma.$queryRaw`\n          WITH viewer_documents AS (\n            SELECT \n              v.\"documentId\",\n              COUNT(v.id)::int as \"viewCount\",\n              MAX(v.\"viewedAt\") as \"lastViewed\",\n              d.name as \"documentName\",\n              d.type as \"documentType\",\n              d.\"contentType\" as \"documentContentType\",\n              ARRAY_AGG(v.id ORDER BY v.\"viewedAt\" DESC) as \"viewIds\"\n            FROM \"View\" v\n            INNER JOIN \"Document\" d ON v.\"documentId\" = d.id\n            WHERE v.\"viewerId\" = ${id}\n              AND v.\"documentId\" IS NOT NULL\n            GROUP BY v.\"documentId\", d.name, d.type, d.\"contentType\"\n          )\n          SELECT * FROM viewer_documents\n          ORDER BY \"lastViewed\" DESC\n        ` as Array<{\n          documentId: string;\n          viewCount: number;\n          lastViewed: Date;\n          documentName: string | null;\n          documentType: string | null;\n          documentContentType: string | null;\n          viewIds: string[];\n        }>;\n\n        const durationCacheKey = `durations:${teamId}:${id}:all:duration-sort`;\n        const durationsMap = await fetchAndCacheDurations(allDocuments, teamId, id, durationCacheKey);\n\n        const documentsWithDurations = allDocuments.map(doc => ({\n          ...doc,\n          totalDuration: durationsMap[doc.documentId] || 0\n        }));\n\n        // Sort by duration efficiently\n        documentsWithDurations.sort((a, b) => {\n          return order === \"asc\"\n            ? a.totalDuration - b.totalDuration\n            : b.totalDuration - a.totalDuration;\n        });\n\n        // Apply pagination after sorting\n        groupedViews = documentsWithDurations.slice(offset, offset + limit);\n\n      } else {\n        // Optimized query leveraging our performance indexes\n        groupedViews = await prisma.$queryRaw`\n          WITH viewer_documents AS (\n            SELECT \n              v.\"documentId\",\n              COUNT(v.id)::int as \"viewCount\",\n              MAX(v.\"viewedAt\") as \"lastViewed\",\n              d.name as \"documentName\",\n              d.type as \"documentType\",\n              d.\"contentType\" as \"documentContentType\",\n              ARRAY_AGG(v.id ORDER BY v.\"viewedAt\" DESC) as \"viewIds\"\n            FROM \"View\" v\n            INNER JOIN \"Document\" d ON v.\"documentId\" = d.id\n            WHERE v.\"viewerId\" = ${id}\n              AND v.\"documentId\" IS NOT NULL\n            GROUP BY v.\"documentId\", d.name, d.type, d.\"contentType\"\n          )\n          SELECT * FROM viewer_documents\n          ORDER BY ${orderByClause}\n          LIMIT ${limit}\n          OFFSET ${offset}\n        ` as Array<{\n          documentId: string;\n          viewCount: number;\n          lastViewed: Date;\n          documentName: string | null;\n          documentType: string | null;\n          documentContentType: string | null;\n          viewIds: string[];\n        }>;\n      }\n\n      const totalCountResult = await prisma.$queryRaw`\n        SELECT COUNT(DISTINCT v.\"documentId\")::int as count\n        FROM \"View\" v\n        WHERE v.\"viewerId\" = ${id}\n          AND v.\"documentId\" IS NOT NULL\n      ` as Array<{ count: number }>;\n\n      const totalItems = totalCountResult[0]?.count || 0;\n      const totalPages = Math.ceil(totalItems / limit);\n\n      // If withDuration=true, return only duration data for faster response\n      if (withDuration === \"true\") {\n        try {\n          const durationCacheKey = `durations:${teamId}:${id}:${currentPage}:${limit}:${sort}:${order}`;\n          const durationsMap = await fetchAndCacheDurations(groupedViews, teamId, id, durationCacheKey);\n\n          // Add cache headers\n          res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');\n          return res.status(200).json({ durations: durationsMap });\n\n        } catch (error) {\n          console.error(\"Error processing duration batches:\", error);\n          // Return empty durations on error\n          const emptyDurationsMap = Object.fromEntries(\n            groupedViews.map(view => [view.documentId, 0])\n          );\n          return res.status(200).json({ durations: emptyDurationsMap });\n        }\n      }\n\n      // Standard response without durations\n      const formattedViews = groupedViews.map((view) => ({\n        documentId: view.documentId,\n        viewCount: view.viewCount,\n        lastViewed: view.lastViewed,\n        document: {\n          id: view.documentId,\n          name: view.documentName,\n          type: view.documentType,\n          contentType: view.documentContentType,\n        },\n        totalDuration: view.totalDuration || 0,\n      }));\n\n      const newViewer = {\n        ...viewer,\n        views: formattedViews,\n        pagination: {\n          currentPage,\n          pageSize: limit,\n          totalItems,\n          totalPages,\n          hasNext: currentPage < totalPages,\n          hasPrev: currentPage > 1,\n        },\n        sorting: {\n          sortBy: sort,\n          sortOrder: order,\n        },\n      };\n\n      if (withDuration !== \"true\") {\n        await redis.set(cacheKey, JSON.stringify(formattedViews), { ex: 600 }); // 10 min cache\n      }\n      res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=300');\n\n      return res.status(200).json(newViewer);\n    } catch (error) {\n      console.error('Viewer Details API Error:', error);\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/viewers/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { Prisma } from \"@prisma/client\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/viewers\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { query, page, pageSize, sortBy, sortOrder } = req.query as {\n      query?: string;\n      page?: string;\n      pageSize?: string;\n      sortBy?: string;\n      sortOrder?: string;\n    };\n\n    const { teamId } = req.query as { teamId: string };\n\n    const currentPage = parseInt(page || \"1\", 10);\n    const limit = Math.min(parseInt(pageSize || \"10\", 10), 100);\n    const offset = (currentPage - 1) * limit;\n\n    const validSortFields = [\"lastViewed\", \"totalVisits\"];\n    const validSortOrders = [\"asc\", \"desc\"];\n    const sort = validSortFields.includes(sortBy || \"\") ? sortBy : \"lastViewed\";\n    const order = validSortOrders.includes(sortOrder || \"\")\n      ? sortOrder\n      : \"desc\";\n\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: {\n            some: {\n              userId,\n            },\n          },\n        },\n        select: { id: true, plan: true },\n      });\n\n      if (!team || team.plan === \"free\") {\n        return res.status(404).json({ error: \"Team not found\" });\n      }\n\n      const searchCondition = query\n        ? Prisma.sql`AND LOWER(v.email) LIKE LOWER(${`${query}%`})`\n        : Prisma.empty;\n\n      let orderByClause: Prisma.Sql;\n      if (sort === \"totalVisits\") {\n        orderByClause =\n          order === \"desc\"\n            ? Prisma.sql`\"totalVisits\" DESC, \"createdAt\" DESC`\n            : Prisma.sql`\"totalVisits\" ASC, \"createdAt\" DESC`;\n      } else if (sort === \"lastViewed\") {\n        orderByClause =\n          order === \"desc\"\n            ? Prisma.sql`\"lastViewed\" DESC NULLS LAST, \"createdAt\" DESC`\n            : Prisma.sql`\"lastViewed\" ASC NULLS LAST, \"createdAt\" DESC`;\n      } else {\n        orderByClause =\n          order === \"desc\"\n            ? Prisma.sql`\"createdAt\" DESC`\n            : Prisma.sql`\"createdAt\" ASC`;\n      }\n\n      const viewersWithStats = (await prisma.$queryRaw`\n        WITH view_stats AS (\n          SELECT \n            vw.\"viewerId\",\n            COUNT(*)::int as total_visits,\n            MAX(vw.\"viewedAt\") as last_viewed\n          FROM \"View\" vw\n          JOIN \"Viewer\" vr ON vr.id = vw.\"viewerId\" AND vr.\"teamId\" = ${teamId}\n          WHERE vw.\"documentId\" IS NOT NULL\n          GROUP BY vw.\"viewerId\"\n        ),\n        latest_viewer_names AS (\n          SELECT DISTINCT ON (vw.\"viewerId\")\n            vw.\"viewerId\",\n            vw.\"viewerName\"\n          FROM \"View\" vw\n          JOIN \"Viewer\" vr ON vr.id = vw.\"viewerId\" AND vr.\"teamId\" = ${teamId}\n          WHERE vw.\"viewerName\" IS NOT NULL\n          ORDER BY vw.\"viewerId\", vw.\"viewedAt\" DESC\n        ),\n        viewer_stats AS (\n          SELECT \n            v.id,\n            v.email,\n            v.\"createdAt\",\n            v.\"updatedAt\",\n            COALESCE(vs.total_visits, 0)::int as \"totalVisits\",\n            vs.last_viewed as \"lastViewed\",\n            lvn.\"viewerName\" as \"viewerName\"\n          FROM \"Viewer\" v\n          LEFT JOIN view_stats vs ON v.id = vs.\"viewerId\"\n          LEFT JOIN latest_viewer_names lvn ON v.id = lvn.\"viewerId\"\n          WHERE v.\"teamId\" = ${teamId}\n            ${searchCondition}\n        )\n        SELECT * FROM viewer_stats\n        ORDER BY ${orderByClause}\n        LIMIT ${limit}\n        OFFSET ${offset}\n      `) as Array<{\n        id: string;\n        email: string;\n        createdAt: Date;\n        updatedAt: Date;\n        totalVisits: number;\n        lastViewed: Date | null;\n        viewerName: string | null;\n      }>;\n\n      const totalCountResult = (await prisma.$queryRaw`\n        SELECT COUNT(*)::int as count\n        FROM \"Viewer\" v\n        WHERE v.\"teamId\" = ${teamId}\n          ${searchCondition}\n      `) as Array<{ count: number }>;\n\n      const totalCount = totalCountResult[0]?.count || 0;\n      const totalPages = Math.ceil(totalCount / limit);\n\n      const formattedViewers = viewersWithStats.map((viewer) => ({\n        id: viewer.id,\n        email: viewer.email,\n        createdAt: viewer.createdAt,\n        updatedAt: viewer.updatedAt,\n        totalVisits: viewer.totalVisits,\n        lastViewed: viewer.lastViewed,\n        viewerName: viewer.viewerName,\n      }));\n\n      const response = {\n        viewers: formattedViewers,\n        pagination: {\n          currentPage,\n          pageSize: limit,\n          totalItems: totalCount,\n          totalPages,\n          hasNext: currentPage < totalPages,\n          hasPrev: currentPage > 1,\n        },\n        sorting: {\n          sortBy: sort,\n          sortOrder: order,\n        },\n      };\n\n      res.setHeader(\n        \"Cache-Control\",\n        \"public, s-maxage=30, stale-while-revalidate=300\",\n      );\n\n      return res.status(200).json(response);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    // We only allow GET requests\n    res.setHeader(\"Allow\", [\"GET\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/views/[id]/archive.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/views/:id/archive\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const userId = (session.user as CustomUser).id;\n    const { teamId, id } = req.query as { teamId: string; id: string };\n    const { isArchived } = req.body;\n\n    try {\n      const team = await prisma.team.findUnique({\n        where: {\n          id: teamId,\n          users: { some: { userId } },\n        },\n      });\n\n      if (!team) {\n        return res.status(403).json({ error: \"Unauthorized\" });\n      }\n\n      // Update the link in the database\n      const updatedView = await prisma.view.update({\n        where: { id, teamId },\n        data: {\n          isArchived: isArchived,\n        },\n      });\n\n      if (!updatedView) {\n        return res.status(404).json({ error: \"View not found\" });\n      }\n\n      return res.status(200).json(updatedView);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  }\n\n  // We only allow PUT requests\n  res.setHeader(\"Allow\", [\"PUT\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/visitor-groups/[groupId]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const { teamId, groupId } = req.query as {\n    teamId: string;\n    groupId: string;\n  };\n  const userId = (session.user as CustomUser).id;\n\n  // Verify team access\n  const teamAccess = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId,\n        teamId,\n      },\n    },\n  });\n\n  if (!teamAccess) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/visitor-groups/:groupId\n    try {\n      const visitorGroup = await prisma.visitorGroup.findUnique({\n        where: {\n          id: groupId,\n          teamId,\n        },\n        include: {\n          links: {\n            where: {\n              link: {\n                deletedAt: null,\n                isArchived: false,\n              },\n            },\n            include: {\n              link: {\n                select: {\n                  id: true,\n                  name: true,\n                  linkType: true,\n                  deletedAt: true,\n                  isArchived: true,\n                  documentId: true,\n                  dataroomId: true,\n                  document: {\n                    select: {\n                      id: true,\n                      name: true,\n                    },\n                  },\n                  dataroom: {\n                    select: {\n                      id: true,\n                      name: true,\n                    },\n                  },\n                },\n              },\n            },\n          },\n          _count: {\n            select: { links: true },\n          },\n        },\n      });\n\n      if (!visitorGroup) {\n        return res.status(404).json({ error: \"Visitor group not found.\" });\n      }\n\n      return res.status(200).json(visitorGroup);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"PUT\") {\n    // PUT /api/teams/:teamId/visitor-groups/:groupId\n    const { name, emails } = req.body as { name: string; emails: string[] };\n\n    if (!name || name.trim() === \"\") {\n      return res.status(400).json({ error: \"Group name is required.\" });\n    }\n\n    try {\n      const visitorGroup = await prisma.visitorGroup.update({\n        where: {\n          id: groupId,\n          teamId,\n        },\n        data: {\n          name: name.trim(),\n          emails: emails || [],\n        },\n        include: {\n          _count: {\n            select: { links: true },\n          },\n        },\n      });\n\n      return res.status(200).json(visitorGroup);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"DELETE\") {\n    // DELETE /api/teams/:teamId/visitor-groups/:groupId\n    try {\n      // Check if the group is attached to any active (non-deleted) links\n      const activeLinks = await prisma.linkVisitorGroup.findMany({\n        where: {\n          visitorGroupId: groupId,\n          link: {\n            deletedAt: null,\n          },\n        },\n        include: {\n          link: {\n            select: {\n              id: true,\n              name: true,\n              linkType: true,\n              documentId: true,\n              dataroomId: true,\n            },\n          },\n        },\n      });\n\n      if (activeLinks.length > 0) {\n        return res.status(400).json({\n          error:\n            \"Cannot delete this visitor group because it is used by active links. Remove the group from those links first.\",\n          activeLinks: activeLinks.map((al) => ({\n            linkId: al.link.id,\n            linkName: al.link.name,\n            linkType: al.link.linkType,\n          })),\n        });\n      }\n\n      await prisma.visitorGroup.delete({\n        where: {\n          id: groupId,\n          teamId,\n        },\n      });\n\n      return res.status(204).end();\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\", \"PUT\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/visitor-groups/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/visitor-groups\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const visitorGroups = await prisma.visitorGroup.findMany({\n        where: { teamId },\n        orderBy: { createdAt: \"desc\" },\n        include: {\n          _count: {\n            select: { links: true },\n          },\n        },\n      });\n\n      return res.status(200).json(visitorGroups);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/visitor-groups\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n    const { name, emails } = req.body as { name: string; emails: string[] };\n\n    if (!name || name.trim() === \"\") {\n      return res.status(400).json({ error: \"Group name is required.\" });\n    }\n\n    try {\n      const teamAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId,\n            teamId,\n          },\n        },\n      });\n\n      if (!teamAccess) {\n        return res.status(401).end(\"Unauthorized\");\n      }\n\n      const visitorGroup = await prisma.visitorGroup.create({\n        data: {\n          name: name.trim(),\n          emails: emails || [],\n          teamId,\n        },\n        include: {\n          _count: {\n            select: { links: true },\n          },\n        },\n      });\n\n      return res.status(201).json(visitorGroup);\n    } catch (error) {\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/webhooks/[id]/events.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { getWebhookEvents } from \"@/lib/tinybird/pipes\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId, id: webhookId } = req.query as { teamId: string; id: string };\n  const userId = (session.user as CustomUser).id;\n\n  const userTeam = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId,\n        teamId,\n      },\n    },\n  });\n\n  if (!userTeam) {\n    return res.status(403).json({ error: \"Forbidden\" });\n  }\n\n  if (req.method === \"GET\") {\n    try {\n      const { pId } = await prisma.webhook.findUniqueOrThrow({\n        where: {\n          id: webhookId,\n          teamId,\n        },\n        select: {\n          pId: true,\n        },\n      });\n\n      const events = await getWebhookEvents({\n        webhookId: pId,\n      });\n\n      const parsedEvents = events.data.map((event) => ({\n        ...event,\n        request_body: JSON.parse(event.request_body as string),\n      }));\n\n      return res.status(200).json(parsedEvents);\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  return res.status(405).json({ error: \"Method not allowed\" });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/webhooks/[id]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { updateWebhookSchema } from \"@/lib/zod/schemas/webhooks\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId, id } = req.query as { teamId: string; id: string };\n  const userId = (session.user as CustomUser).id;\n\n  const userTeam = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId,\n        teamId,\n      },\n    },\n  });\n\n  if (!userTeam) {\n    return res.status(403).json({ error: \"Forbidden\" });\n  }\n\n  if (req.method === \"GET\") {\n    try {\n      const webhook = await prisma.webhook.findFirst({\n        where: {\n          id,\n          teamId,\n        },\n      });\n\n      if (!webhook) {\n        return res.status(404).json({ error: \"Webhook not found\" });\n      }\n\n      return res.status(200).json(webhook);\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  if (req.method === \"PATCH\") {\n    try {\n      const { name, triggers } = updateWebhookSchema.parse(req.body);\n\n      const webhook = await prisma.webhook.update({\n        where: {\n          id,\n          teamId,\n        },\n        data: {\n          name,\n          triggers,\n        },\n      });\n\n      return res.status(200).json(webhook);\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  if (req.method === \"DELETE\") {\n    try {\n      await prisma.webhook.delete({\n        where: {\n          id,\n          teamId,\n        },\n      });\n\n      return res.status(204).end();\n    } catch (error) {\n      console.error(error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"GET\", \"PATCH\", \"DELETE\"]);\n  return res.status(405).json({ error: `Method ${req.method} Not Allowed` });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/webhooks/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth/next\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { createWebhookSchema } from \"@/lib/zod/schemas/webhooks\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const userId = (session.user as CustomUser).id;\n\n  const userTeam = await prisma.userTeam.findUnique({\n    where: {\n      userId_teamId: {\n        userId,\n        teamId,\n      },\n    },\n  });\n\n  if (!userTeam) {\n    return res.status(404).json({ error: \"Team not found\" });\n  }\n\n  if (req.method === \"GET\") {\n    // GET /api/teams/:teamId/webhooks\n    try {\n      const webhooks = await prisma.webhook.findMany({\n        where: {\n          teamId: teamId,\n        },\n      });\n\n      return res.status(200).json(webhooks);\n    } catch (error) {\n      console.error(\"Error fetching webhooks:\", error);\n      return res.status(500).json({ error: \"Failed to fetch webhooks\" });\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams/:teamId/webhooks\n    try {\n      const { name, url, secret, triggers } = createWebhookSchema.parse(\n        req.body,\n      );\n\n      const webhookId = newId(\"webhook\");\n\n      const webhook = await prisma.webhook.create({\n        data: {\n          pId: webhookId,\n          name: name,\n          url: url,\n          secret: secret,\n          triggers: triggers,\n          teamId: teamId,\n        },\n      });\n\n      return res.status(201).json(webhook);\n    } catch (error) {\n      console.error(\"Error creating webhook:\", error);\n      return res.status(500).json({ error: \"Failed to create webhook\" });\n    }\n  }\n\n  return res.status(405).json({ error: \"Method not allowed\" });\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/workflow-links.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { authOptions } from \"@/pages/api/auth/[...nextauth]\";\nimport { getServerSession } from \"next-auth\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { teamId } = req.query as { teamId: string };\n    const userId = (session.user as CustomUser).id;\n\n    try {\n      // Check if user is part of the team\n      const hasAccess = await prisma.userTeam.findUnique({\n        where: {\n          userId_teamId: {\n            userId: userId,\n            teamId: teamId,\n          },\n        },\n      });\n      if (!hasAccess) {\n        return res.status(403).json({ error: \"Unauthorized\" });\n      }\n\n      // Fetch all links with document/dataroom info\n      const links = await prisma.link.findMany({\n        where: {\n          teamId,\n          linkType: {\n            in: [\"DOCUMENT_LINK\", \"DATAROOM_LINK\"],\n          },\n          deletedAt: null,\n          isArchived: false,\n        },\n        select: {\n          id: true,\n          name: true,\n          slug: true,\n          domainSlug: true,\n          linkType: true,\n          documentId: true,\n          dataroomId: true,\n          allowList: true,\n          document: {\n            select: {\n              id: true,\n              name: true,\n            },\n          },\n          dataroom: {\n            select: {\n              id: true,\n              name: true,\n            },\n          },\n        },\n        orderBy: {\n          createdAt: \"desc\",\n        },\n      });\n\n      // Format response with better labels\n      const formattedLinks = links.map((link) => {\n        const resourceName =\n          link.linkType === \"DOCUMENT_LINK\"\n            ? link.document?.name\n            : link.dataroom?.name;\n\n        // Build display name priority: name > domain/slug > slug > resource name > id\n        let displayName = link.name;\n        if (!displayName && link.domainSlug && link.slug) {\n          displayName = `${link.domainSlug}/${link.slug}`;\n        } else if (!displayName && link.slug) {\n          displayName = link.slug;\n        } else if (!displayName && resourceName) {\n          displayName = resourceName;\n        } else if (!displayName) {\n          displayName = link.id.substring(0, 8);\n        }\n\n        return {\n          id: link.id,\n          name: link.name,\n          slug: link.slug,\n          domainSlug: link.domainSlug, // Will be null for papermark.com links\n          linkType: link.linkType,\n          documentId: link.documentId,\n          dataroomId: link.dataroomId,\n          allowList: link.allowList, // Pre-populate conditions from link\n          resourceName, // Document or Dataroom name\n          displayName, // Human-readable label\n        };\n      });\n\n      return res.status(200).json(formattedLinks);\n    } catch (error) {\n      console.error(\"Error fetching workflow links:\", error);\n      return res.status(500).json({ error: \"Internal server error\" });\n    }\n  }\n\n  res.setHeader(\"Allow\", [\"GET\"]);\n  return res.status(405).end(`Method ${req.method} Not Allowed`);\n}\n"
  },
  {
    "path": "pages/api/teams/[teamId]/yearly-recap.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth/next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { LOCALHOST_GEO_DATA, getGeoData } from \"@/lib/utils/geo\";\nimport { getYearInReviewStats } from \"@/lib/year-in-review/get-stats\";\n\nimport { authOptions } from \"../../auth/[...nextauth]\";\n\nexport default async function handler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).json({ message: \"Unauthorized\" });\n  }\n\n  if (req.method !== \"GET\") {\n    return res.status(405).json({ message: \"Method not allowed\" });\n  }\n\n  const { teamId } = req.query as { teamId: string };\n  const year = req.query.year ? parseInt(req.query.year as string) : undefined;\n\n  if (!teamId) {\n    return res.status(400).json({ message: \"Team ID is required\" });\n  }\n\n  try {\n    const userId = (session.user as CustomUser).id;\n\n    const teamAccess = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId,\n          teamId,\n        },\n      },\n    });\n    if (!teamAccess) {\n      return res.status(401).json({ message: \"Unauthorized\" });\n    }\n\n    // Get user's geo data from request IP for distance calculation\n    const geo = getGeoData(req.headers);\n    const userGeo = {\n      latitude: geo.latitude || LOCALHOST_GEO_DATA.latitude,\n      longitude: geo.longitude || LOCALHOST_GEO_DATA.longitude,\n    };\n\n    const stats = await getYearInReviewStats(teamId, year, userGeo);\n    return res.status(200).json(stats);\n  } catch (error) {\n    console.error(\"Error fetching yearly recap stats:\", error);\n    return res.status(500).json({\n      message: \"Internal server error\",\n    });\n  }\n}\n"
  },
  {
    "path": "pages/api/teams/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport prisma from \"@/lib/prisma\";\nimport { CustomUser } from \"@/lib/types\";\nimport { log } from \"@/lib/utils\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method === \"GET\") {\n    // GET /api/teams\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const user = session.user as CustomUser;\n\n    try {\n      const userTeams = await prisma.userTeam.findMany({\n        where: {\n          userId: user.id,\n        },\n        include: {\n          team: {\n            select: {\n              id: true,\n              name: true,\n              plan: true,\n              createdAt: true,\n              enableExcelAdvancedMode: true,\n              replicateDataroomFolders: true,\n            },\n          },\n        },\n        orderBy: {\n          team: {\n            createdAt: \"asc\",\n          },\n        },\n      });\n\n      const teams = userTeams.map((userTeam) => userTeam.team);\n\n      // if no teams then create a default one\n      if (teams.length === 0) {\n        const defaultTeamName = user.name\n          ? `${user.name}'s Team`\n          : \"Personal Team\";\n        const defaultTeam = await prisma.team.create({\n          data: {\n            name: defaultTeamName,\n            users: {\n              create: {\n                userId: user.id,\n                role: \"ADMIN\",\n              },\n            },\n          },\n          select: {\n            id: true,\n            name: true,\n            plan: true,\n            createdAt: true,\n            enableExcelAdvancedMode: true,\n            replicateDataroomFolders: true,\n          },\n        });\n        teams.push(defaultTeam);\n      }\n\n      return res.status(200).json(teams);\n    } catch (error) {\n      log({\n        message: `Failed to find team for user: _${user.id}_ \\n\\n ${error}`,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else if (req.method === \"POST\") {\n    // POST /api/teams\n    const session = await getServerSession(req, res, authOptions);\n    if (!session) {\n      return res.status(401).end(\"Unauthorized\");\n    }\n\n    const { team } = req.body;\n\n    const user = session.user as CustomUser;\n\n    try {\n      const newTeam = await prisma.team.create({\n        data: {\n          name: team,\n          users: {\n            create: {\n              userId: user.id,\n              role: \"ADMIN\",\n            },\n          },\n        },\n        include: {\n          users: true,\n        },\n      });\n\n      return res.status(201).json(newTeam);\n    } catch (error) {\n      log({\n        message: `Failed to create team \"${team}\" for user: _${user.id}_. \\n\\n*Error*: \\n\\n ${error}`,\n        type: \"error\",\n      });\n      errorhandler(error, res);\n    }\n  } else {\n    res.setHeader(\"Allow\", [\"GET\", \"POST\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  }\n}\n"
  },
  {
    "path": "pages/api/unsubscribe/dataroom/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { verifyUnsubscribeToken } from \"@/lib/utils/unsubscribe\";\nimport { ZViewerNotificationPreferencesSchema } from \"@/lib/zod/schemas/notifications\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\" && req.method !== \"GET\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  const { token } = req.query as { token: string };\n\n  try {\n    if (!token) {\n      res.status(400).json({ message: \"Token is required\" });\n      return;\n    }\n\n    if (req.method === \"GET\") {\n      return res.redirect(`/notification-preferences?token=${token}`);\n    }\n\n    // Rate limit the unsubscribe request\n    const ipAddress =\n      req.headers[\"x-forwarded-for\"] || req.socket.remoteAddress || \"127.0.0.1\";\n    const { success, limit, reset, remaining } = await ratelimit(\n      5,\n      \"1 m\",\n    ).limit(`unsubscribe_${ipAddress}`);\n\n    // Set rate limit headers\n    res.setHeader(\"Retry-After\", reset.toString());\n    res.setHeader(\"X-RateLimit-Limit\", limit.toString());\n    res.setHeader(\"X-RateLimit-Remaining\", remaining.toString());\n    res.setHeader(\"X-RateLimit-Reset\", reset.toString());\n\n    if (!success) {\n      return res.status(429).json({ error: \"Too many requests\" });\n    }\n\n    const payload = verifyUnsubscribeToken(token);\n\n    if (!payload) {\n      res.status(404).json({ message: \"Invalid token\" });\n      return;\n    }\n\n    if (payload.exp && payload.exp < new Date().getTime() / 1000) {\n      res.status(404).json({ message: \"Token expired\" });\n      return;\n    }\n\n    const { viewerId, dataroomId, teamId } = payload;\n\n    if (!dataroomId) {\n      res.status(400).json({ message: \"Dataroom ID is required\" });\n      return;\n    }\n\n    // Fetch the current notification preferences\n    const viewer = await prisma.viewer.findUnique({\n      where: { id: viewerId, teamId },\n      select: { notificationPreferences: true },\n    });\n\n    if (!viewer) {\n      res.status(404).json({ message: \"Viewer not found\" });\n      return;\n    }\n\n    // Parse the existing preferences or initialize an empty object\n    // Example preferences object:\n    // {\n    //   \"dataroom\": {\n    //     \"123\": { enabled: true, frequency: \"instant\" },\n    //     \"456\": { enabled: false, frequency: \"daily\" }\n    //   },\n    //   \"document\": {\n    //     \"789\": { enabled: true, frequency: \"weekly\" }\n    //   }\n    // }\n    let updatedPreferences;\n\n    if (viewer.notificationPreferences) {\n      // Parse the existing preferences\n      const defaultPreferences = ZViewerNotificationPreferencesSchema.safeParse(\n        viewer.notificationPreferences,\n      );\n\n      // Update the preferences for the specific dataroom\n      updatedPreferences = {\n        ...defaultPreferences.data,\n        dataroom: {\n          ...defaultPreferences.data?.dataroom,\n          [dataroomId]: { enabled: false },\n        },\n      };\n    } else {\n      // If no preferences exist, initialize with the dataroom preference\n      updatedPreferences = {\n        dataroom: { [dataroomId]: { enabled: false } },\n      };\n    }\n\n    // Update the viewer's notification preferences in the database\n    await prisma.viewer.update({\n      where: { id: viewerId, teamId },\n      data: { notificationPreferences: updatedPreferences },\n    });\n\n    res\n      .status(200)\n      .json({ message: \"Successfully unsubscribed from notifications.\" });\n  } catch (error) {\n    res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/unsubscribe/yir/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport { verifyUnsubscribeToken } from \"@/lib/utils/unsubscribe\";\nimport { ZUserNotificationPreferencesSchema } from \"@/lib/zod/schemas/notifications\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\" && req.method !== \"GET\") {\n    res.status(405).json({ message: \"Method Not Allowed\" });\n    return;\n  }\n\n  const { token } = req.query as { token: string };\n\n  try {\n    if (!token) {\n      res.status(400).json({ message: \"Token is required\" });\n      return;\n    }\n\n    if (req.method === \"GET\") {\n      // For GET requests, redirect to the unsubscribe page\n      return res.redirect(`/unsubscribe?type=yir&token=${token}`);\n    }\n\n    // Rate limit the unsubscribe request\n    const ipAddress =\n      req.headers[\"x-forwarded-for\"] || req.socket.remoteAddress || \"127.0.0.1\";\n    const { success, limit, reset, remaining } = await ratelimit(\n      5,\n      \"1 m\",\n    ).limit(`unsubscribe_${ipAddress}`);\n\n    // Set rate limit headers\n    res.setHeader(\"Retry-After\", reset.toString());\n    res.setHeader(\"X-RateLimit-Limit\", limit.toString());\n    res.setHeader(\"X-RateLimit-Remaining\", remaining.toString());\n    res.setHeader(\"X-RateLimit-Reset\", reset.toString());\n\n    if (!success) {\n      return res.status(429).json({ error: \"Too many requests\" });\n    }\n\n    const payload = verifyUnsubscribeToken(token);\n\n    if (!payload) {\n      res.status(404).json({ message: \"Invalid token\" });\n      return;\n    }\n\n    if (payload.exp && payload.exp < new Date().getTime() / 1000) {\n      res.status(404).json({ message: \"Token expired\" });\n      return;\n    }\n\n    const { viewerId, teamId } = payload;\n\n    // Fetch the current notification preferences\n    const userTeam = await prisma.userTeam.findUnique({\n      where: {\n        userId_teamId: {\n          userId: viewerId,\n          teamId,\n        },\n      },\n      select: { notificationPreferences: true },\n    });\n\n    if (!userTeam) {\n      res.status(404).json({ message: \"User not found\" });\n      return;\n    }\n\n    // Parse existing preferences or initialize empty object\n    let updatedPreferences;\n\n    if (userTeam.notificationPreferences) {\n      // Parse the existing preferences\n      const defaultPreferences = ZUserNotificationPreferencesSchema.safeParse(\n        userTeam.notificationPreferences,\n      );\n\n      // Update the preferences to opt out of year in review\n      updatedPreferences = {\n        ...defaultPreferences.data,\n        yearInReview: { enabled: false },\n      };\n    } else {\n      // If no preferences exist, initialize with year in review preference\n      updatedPreferences = {\n        yearInReview: { enabled: false },\n      };\n    }\n\n    // Update the user's notification preferences in the database\n    await prisma.userTeam.update({\n      where: {\n        userId_teamId: {\n          userId: viewerId,\n          teamId,\n        },\n      },\n      data: {\n        notificationPreferences: updatedPreferences,\n      },\n    });\n\n    res.status(200).json({\n      message: \"Successfully unsubscribed from Year in Review emails.\",\n    });\n  } catch (error) {\n    res.status(500).json({ message: (error as Error).message });\n  }\n}\n"
  },
  {
    "path": "pages/api/user/subscribe.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { getServerSession } from \"next-auth\";\n\nimport { errorhandler } from \"@/lib/errorHandler\";\nimport { resend, subscribe, unsubscribe } from \"@/lib/resend\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { authOptions } from \"../auth/[...nextauth]\";\n\nexport default async function handle(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  const session = await getServerSession(req, res, authOptions);\n  if (!session) {\n    return res.status(401).end(\"Unauthorized\");\n  }\n\n  const user = session.user as CustomUser;\n\n  if (!user.email) {\n    return res.status(400).json({ error: \"User email not found\" });\n  }\n\n  try {\n    if (req.method === \"GET\") {\n      // Get subscription status\n      if (!resend) {\n        // Resend unavailable, default to subscribed (opt-out model)\n        return res.status(200).json({ subscribed: true });\n      }\n\n      // Fetch contact from Resend to get actual subscription status\n      const { data: contact, error } = await resend.contacts.get({\n        email: user.email,\n      });\n\n      if (error || !contact?.unsubscribed) {\n        // Contact not found or not unsubscribed, default to subscribed\n        return res.status(200).json({ subscribed: true });\n      }\n\n      return res.status(200).json({ subscribed: !contact.unsubscribed });\n    }\n\n    if (req.method === \"POST\") {\n      await subscribe(user.email);\n\n      return res.status(200).json({ subscribed: true });\n    }\n\n    if (req.method === \"DELETE\") {\n      await unsubscribe(user.email);\n\n      return res.status(200).json({ subscribed: false });\n    }\n\n    res.setHeader(\"Allow\", [\"GET\", \"POST\", \"DELETE\"]);\n    return res.status(405).end(`Method ${req.method} Not Allowed`);\n  } catch (error) {\n    errorhandler(error, res);\n  }\n}\n"
  },
  {
    "path": "pages/api/webhooks/services/[...path]/index.ts",
    "content": "import { NextApiRequest, NextApiResponse } from \"next\";\n\nimport { isTeamPausedById } from \"@/ee/features/billing/cancellation/lib/is-team-paused\";\nimport { LinkPreset } from \"@prisma/client\";\nimport { put } from \"@vercel/blob\";\nimport { waitUntil } from \"@vercel/functions\";\nimport { z } from \"zod\";\n\nimport { hashToken } from \"@/lib/api/auth/token\";\nimport {\n  createDocument,\n  createNewDocumentVersion,\n} from \"@/lib/documents/create-document\";\nimport { putFileServer } from \"@/lib/files/put-file-server\";\nimport { newId } from \"@/lib/id-helper\";\nimport { extractTeamId, isValidWebhookId } from \"@/lib/incoming-webhooks\";\nimport prisma from \"@/lib/prisma\";\nimport { ratelimit } from \"@/lib/redis\";\nimport {\n  convertDataUrlToBuffer,\n  generateEncrpytedPassword,\n  isDataUrl,\n  safeSlugify,\n  uploadImage,\n} from \"@/lib/utils\";\nimport {\n  getExtensionFromContentType,\n  getSupportedContentType,\n} from \"@/lib/utils/get-content-type\";\nimport { sendLinkCreatedWebhook } from \"@/lib/webhook/triggers/link-created\";\nimport { webhookFileUrlSchema } from \"@/lib/zod/url-validation\";\n\nexport const config = {\n  // in order to enable `waitUntil` function\n  supportsResponseStreaming: true,\n  maxDuration: 120,\n};\n\n// Define a common link schema to reuse\nconst LinkSchema = z.object({\n  name: z.string().optional(),\n  domain: z.string().optional(),\n  slug: z.string().optional(),\n  password: z.string().optional(),\n  expiresAt: z.string().optional(), // ISO string date\n  emailProtected: z.boolean().optional(),\n  emailAuthenticated: z.boolean().optional(),\n  allowDownload: z.boolean().optional(),\n  enableNotification: z.boolean().optional(),\n  enableFeedback: z.boolean().optional(),\n  enableScreenshotProtection: z.boolean().optional(),\n  showBanner: z.boolean().optional(),\n  audienceType: z.enum([\"GENERAL\", \"GROUP\", \"TEAM\"]).optional(),\n  groupId: z.string().optional(),\n  allowList: z.array(z.string()).optional(),\n  denyList: z.array(z.string()).optional(),\n  presetId: z.string().optional(),\n});\n\n// Define validation schemas for different resource types\nconst BaseSchema = z.object({\n  resourceType: z.enum([\n    \"document.create\",\n    \"document.update\",\n    \"link.create\",\n    \"link.update\",\n    \"links.get\",\n    \"dataroom.create\",\n  ]),\n});\n\nconst DocumentCreateSchema = BaseSchema.extend({\n  resourceType: z.literal(\"document.create\"),\n  fileUrl: webhookFileUrlSchema,\n  name: z.string(),\n  contentType: z.string(),\n  dataroomId: z.string().optional(),\n  folderId: z.string().nullable().optional(),\n  dataroomFolderId: z.string().nullable().optional(),\n  createLink: z.boolean().optional().default(false),\n  link: LinkSchema.optional(),\n});\n\nconst DocumentUpdateSchema = BaseSchema.extend({\n  resourceType: z.literal(\"document.update\"),\n  documentId: z.string(),\n  fileUrl: webhookFileUrlSchema,\n  contentType: z.string(),\n});\n\nconst LinkCreateSchema = BaseSchema.extend({\n  resourceType: z.literal(\"link.create\"),\n  targetId: z.string(),\n  linkType: z.enum([\"DOCUMENT_LINK\", \"DATAROOM_LINK\"]),\n  link: LinkSchema,\n});\n\nconst LinkUpdateSchema = BaseSchema.extend({\n  resourceType: z.literal(\"link.update\"),\n  linkId: z.string(),\n  link: LinkSchema,\n});\n\nconst LinksGetSchema = BaseSchema.extend({\n  resourceType: z.literal(\"links.get\"),\n});\n\n// Schema for dataroom folder structure\nconst DataroomFolderSchema: z.ZodType<any> = z.lazy(() =>\n  z.object({\n    name: z.string(),\n    subfolders: z.array(DataroomFolderSchema).optional(),\n  }),\n);\n\nconst DataroomCreateSchema = BaseSchema.extend({\n  resourceType: z.literal(\"dataroom.create\"),\n  name: z.string(),\n  description: z.string().optional(),\n  folders: z.array(DataroomFolderSchema).optional(), // Create folders with hierarchy\n  createLink: z.boolean().optional().default(false),\n  link: LinkSchema.optional(),\n});\n\nconst RequestBodySchema = z.discriminatedUnion(\"resourceType\", [\n  DocumentCreateSchema,\n  DocumentUpdateSchema,\n  LinkCreateSchema,\n  LinkUpdateSchema,\n  LinksGetSchema,\n  DataroomCreateSchema,\n]);\n\nexport default async function incomingWebhookHandler(\n  req: NextApiRequest,\n  res: NextApiResponse,\n) {\n  if (req.method !== \"POST\") {\n    return res.status(405).json({ error: \"Method not allowed\" });\n  }\n\n  // Get the full webhook ID from the path\n  const { path } = req.query;\n  const webhookId = Array.isArray(path) ? path.join(\"/\") : path;\n\n  if (!webhookId || !isValidWebhookId(webhookId)) {\n    return res.status(400).json({ error: \"Invalid webhook format\" });\n  }\n\n  // Check for API token\n  const authHeader = req.headers.authorization;\n  if (!authHeader?.startsWith(\"Bearer \")) {\n    return res\n      .status(401)\n      .json({ error: \"Missing or invalid authorization header\" });\n  }\n\n  const token = authHeader.replace(\"Bearer \", \"\");\n  const hashedToken = hashToken(token);\n\n  // Look up token in database\n  const restrictedToken = await prisma.restrictedToken.findUnique({\n    where: { hashedKey: hashedToken },\n    select: { teamId: true, rateLimit: true },\n  });\n\n  if (!restrictedToken) {\n    return res.status(401).json({ error: \"Invalid token\" });\n  }\n\n  // Rate limit checks for API tokens\n  const rateLimit = restrictedToken.rateLimit || 60; // Default rate limit of 60 requests per minute\n\n  const { success, limit, reset, remaining } = await ratelimit(\n    rateLimit,\n    \"1 m\",\n  ).limit(hashedToken);\n\n  // Set rate limit headers\n  res.setHeader(\"Retry-After\", reset.toString());\n  res.setHeader(\"X-RateLimit-Limit\", limit.toString());\n  res.setHeader(\"X-RateLimit-Remaining\", remaining.toString());\n  res.setHeader(\"X-RateLimit-Reset\", reset.toString());\n\n  if (!success) {\n    return res.status(429).json({ error: \"Too many requests\" });\n  }\n\n  // Update last used timestamp for the token\n  waitUntil(\n    prisma.restrictedToken.update({\n      where: {\n        hashedKey: hashedToken,\n      },\n      data: {\n        lastUsed: new Date(),\n      },\n    }),\n  );\n\n  const teamId = extractTeamId(webhookId);\n  if (!teamId) {\n    return res.status(400).json({ error: \"Invalid team ID in webhook\" });\n  }\n\n  if (restrictedToken.teamId !== teamId) {\n    return res.status(401).json({ error: \"Unauthorized\" });\n  }\n\n  try {\n    // 1. Find the webhook integration\n    const incomingWebhook = await prisma.incomingWebhook.findUnique({\n      where: {\n        externalId: webhookId,\n        teamId: teamId,\n      },\n      include: { team: true },\n    });\n\n    if (!incomingWebhook) {\n      return res.status(404).json({ error: \"Webhook not found\" });\n    }\n\n    // Validate request body against the schema\n    const validationResult = RequestBodySchema.safeParse(req.body);\n    if (!validationResult.success) {\n      return res.status(400).json({\n        error: \"Invalid request body\",\n        details: validationResult.error.format(),\n      });\n    }\n\n    const validatedData = validationResult.data;\n\n    // Handle different resource types\n    if (validatedData.resourceType === \"document.create\") {\n      return await handleDocumentCreate(\n        validatedData,\n        incomingWebhook.teamId,\n        token,\n        res,\n      );\n    } else if (validatedData.resourceType === \"document.update\") {\n      return await handleDocumentUpdate(\n        validatedData,\n        incomingWebhook.teamId,\n        token,\n        res,\n      );\n    } else if (validatedData.resourceType === \"link.create\") {\n      return await handleLinkCreate(\n        validatedData,\n        incomingWebhook.teamId,\n        token,\n        res,\n      );\n    } else if (validatedData.resourceType === \"link.update\") {\n      return await handleLinkUpdate(\n        validatedData,\n        incomingWebhook.teamId,\n        token,\n        res,\n      );\n    } else if (validatedData.resourceType === \"links.get\") {\n      return await handleLinksGet(incomingWebhook.teamId, res);\n    } else if (validatedData.resourceType === \"dataroom.create\") {\n      return await handleDataroomCreate(\n        validatedData,\n        incomingWebhook.teamId,\n        token,\n        res,\n      );\n    }\n\n    // This shouldn't be reached due to the validation schema, but just in case\n    return res.status(400).json({ error: \"Invalid resource type\" });\n  } catch (error) {\n    console.error(\"Webhook error:\", error);\n    return res.status(500).json({ error: \"Internal server error\" });\n  }\n}\n\n/**\n * Handle links.get resource type – return all links for the team\n */\nasync function handleLinksGet(teamId: string, res: NextApiResponse) {\n  try {\n    const links = await prisma.link.findMany({\n      where: {\n        teamId,\n        deletedAt: null,\n      },\n      select: {\n        id: true,\n        name: true,\n        linkType: true,\n        documentId: true,\n        dataroomId: true,\n        slug: true,\n        domainSlug: true,\n        expiresAt: true,\n        isArchived: true,\n        createdAt: true,\n        updatedAt: true,\n      },\n      orderBy: {\n        createdAt: \"desc\",\n      },\n    });\n\n    const transformedLinks = links.map((link) => ({\n      linkId: link.id,\n      name: link.name,\n      linkType: link.linkType,\n      documentId: link.documentId ?? null,\n      dataroomId: link.dataroomId ?? null,\n      slug: link.slug,\n      domainSlug: link.domainSlug,\n      expiresAt: link.expiresAt,\n      isArchived: link.isArchived,\n      createdAt: link.createdAt,\n      updatedAt: link.updatedAt,\n      linkUrl:\n        link.domainSlug && link.slug\n          ? `https://${link.domainSlug}/${link.slug}`\n          : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${link.id}`,\n    }));\n\n    return res.status(200).json(transformedLinks);\n  } catch (error) {\n    console.error(\"Error fetching team links:\", error);\n    return res.status(500).json({ error: \"Failed to fetch team links\" });\n  }\n}\n\n/**\n * Handle document.create resource type\n */\nasync function handleDocumentCreate(\n  data: z.infer<typeof DocumentCreateSchema>,\n  teamId: string,\n  token: string,\n  res: NextApiResponse,\n) {\n  const {\n    fileUrl,\n    name,\n    contentType,\n    dataroomId,\n    createLink,\n    link,\n    folderId,\n    dataroomFolderId,\n  } = data;\n\n  // Check if team is paused\n  const teamIsPaused = await isTeamPausedById(teamId);\n  if (teamIsPaused) {\n    return res.status(403).json({\n      error:\n        \"Team is currently paused. New document uploads are not available.\",\n    });\n  }\n\n  // Check if the content type is supported\n  const supportedContentType = getSupportedContentType(contentType);\n  if (!supportedContentType) {\n    return res.status(400).json({ error: \"Unsupported content type\" });\n  }\n\n  if (dataroomId) {\n    // Verify dataroom exists and belongs to team\n    const dataroom = await prisma.dataroom.findUnique({\n      where: {\n        id: dataroomId,\n        teamId: teamId,\n      },\n    });\n\n    if (!dataroom) {\n      return res.status(400).json({ error: \"Invalid dataroom ID\" });\n    }\n  }\n\n  // If custom domain and slug are provided, validate them\n  if (createLink && link?.domain && link?.slug) {\n    // Check if domain exists\n    const domain = await prisma.domain.findUnique({\n      where: {\n        slug: link.domain,\n        teamId: teamId,\n      },\n    });\n\n    if (!domain) {\n      return res\n        .status(400)\n        .json({ error: \"Domain not found or not associated with this team\" });\n    }\n\n    // Check if the slug is already in use with this domain\n    const existingLink = await prisma.link.findUnique({\n      where: {\n        domainSlug_slug: {\n          slug: link.slug,\n          domainSlug: link.domain,\n        },\n      },\n    });\n\n    if (existingLink) {\n      return res\n        .status(400)\n        .json({ error: \"The link with this domain and slug already exists\" });\n    }\n  }\n\n  // 4. Fetch file from URL\n  const response = await fetch(fileUrl);\n  if (!response.ok) {\n    return res.status(400).json({ error: \"Failed to fetch file from URL\" });\n  }\n\n  // 5. Validate response content type matches expected\n  const responseContentType = response.headers.get(\"content-type\");\n  if (!responseContentType || responseContentType.startsWith(\"text/html\")) {\n    return res\n      .status(400)\n      .json({ error: \"Remote resource is not a supported file type\" });\n  }\n  if (!responseContentType.startsWith(contentType)) {\n    console.warn(\n      `Content type mismatch: expected ${contentType}, got ${responseContentType}`,\n    );\n    // Log but don't fail - some services return generic types\n  }\n\n  // 6. Convert to buffer\n  const fileBuffer = Buffer.from(await response.arrayBuffer());\n\n  // Ensure filename has proper extension, based on the actual response content-type when available\n  let fileName = name?.trim();\n  const actualContentType = (\n    responseContentType?.split(\";\")[0] ?? contentType\n  ).trim();\n  const expectedExtension = getExtensionFromContentType(actualContentType);\n  if (expectedExtension) {\n    const lower = fileName.toLowerCase();\n    const dotIdx = lower.lastIndexOf(\".\");\n    const currentExt = dotIdx !== -1 ? lower.slice(dotIdx + 1) : null;\n    // Minimal alias map to avoid double extensions (e.g., jpg vs jpeg)\n    const alias: Record<string, string[]> = {\n      jpeg: [\"jpeg\", \"jpg\"],\n      jpg: [\"jpg\", \"jpeg\"],\n      tiff: [\"tiff\", \"tif\"],\n    };\n    const matches =\n      !!currentExt &&\n      (currentExt === expectedExtension ||\n        (alias[expectedExtension]?.includes(currentExt) ?? false));\n    if (!matches) {\n      fileName = `${fileName}.${expectedExtension}`;\n    }\n  }\n\n  console.log(\"Uploading file to storage\", teamId, fileName, contentType);\n\n  // 7. Upload the file to storage\n  const { type: storageType, data: fileData } = await putFileServer({\n    file: {\n      name: fileName,\n      type: contentType,\n      buffer: fileBuffer,\n    },\n    teamId: teamId,\n    restricted: false, // allows all supported file types\n  });\n\n  if (!fileData || !storageType) {\n    return res.status(500).json({ error: \"Failed to save file to storage\" });\n  }\n\n  // 8. Create document using our service\n  // Note: The createDocument function doesn't accept linkData in its parameters\n  // so we will just pass createLink flag\n  const documentCreationResponse = await createDocument({\n    documentData: {\n      name: fileName,\n      key: fileData,\n      storageType: storageType,\n      contentType: contentType,\n      supportedFileType: supportedContentType,\n      fileSize: fileBuffer.byteLength,\n    },\n    teamId: teamId,\n    numPages: 1,\n    token: token,\n    createLink: createLink, // INFO: creatLink=true will not trigger a link.created webhook\n  });\n\n  if (!documentCreationResponse.ok) {\n    return res.status(500).json({ error: \"Failed to create document\" });\n  }\n\n  const document = await documentCreationResponse.json();\n  let newLink: any;\n\n  // If the document is added to a folder, update the folderId\n  if (folderId) {\n    const folder = await prisma.folder.findUnique({\n      where: { id: folderId, teamId: teamId },\n      select: {\n        id: true,\n      },\n    });\n\n    if (!folder) {\n      return res.status(400).json({ error: \"Invalid folder ID\" });\n    }\n\n    await prisma.document.update({\n      where: { id: document.id, teamId: teamId },\n      data: {\n        folderId: folder.id,\n      },\n    });\n  }\n\n  // If we need to customize the link, update it after creation\n  if (createLink && document.links && document.links.length > 0 && link) {\n    const linkId = document.links[0].id;\n\n    // If preset is provided, validate it\n    let preset: LinkPreset | null = null;\n    let metaImage: string | null = null;\n    let metaFavicon: string | null = null;\n    if (link?.presetId) {\n      preset = await prisma.linkPreset.findUnique({\n        where: { pId: link.presetId, teamId: teamId },\n      });\n\n      if (!preset) {\n        return res.status(400).json({\n          error: \"Link preset not found or not associated with this team\",\n        });\n      }\n\n      // Handle image files for custom meta tag (if enabled)\n      if (preset.enableCustomMetaTag) {\n        // Process meta image if present\n        if (preset.metaImage && isDataUrl(preset.metaImage)) {\n          const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n            preset.metaImage,\n          );\n          const blob = await put(filename, buffer, {\n            access: \"public\",\n            addRandomSuffix: true,\n          });\n          metaImage = blob.url;\n        }\n\n        // Process favicon if present\n        if (preset.metaFavicon && isDataUrl(preset.metaFavicon)) {\n          const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n            preset.metaFavicon,\n          );\n          const blob = await put(filename, buffer, {\n            access: \"public\",\n            addRandomSuffix: true,\n          });\n          metaFavicon = blob.url;\n        }\n      }\n    }\n\n    // Process fields for link update\n    const hashedPassword = link.password\n      ? await generateEncrpytedPassword(link.password)\n      : preset?.password\n        ? await generateEncrpytedPassword(preset.password)\n        : null;\n\n    const expiresAtDate = link.expiresAt\n      ? new Date(link.expiresAt)\n      : preset?.expiresAt\n        ? new Date(preset.expiresAt)\n        : null;\n\n    const isGroupAudience = link.audienceType === \"GROUP\";\n\n    let domainId = null;\n    if (link.domain) {\n      const domain = await prisma.domain.findUnique({\n        where: {\n          slug: link.domain,\n          teamId: teamId,\n        },\n        select: { id: true },\n      });\n      domainId = domain?.id || null;\n    }\n\n    // Update the link with custom settings\n    newLink = await prisma.link.update({\n      where: { id: linkId, teamId: teamId },\n      data: {\n        name: link.name,\n        password: hashedPassword,\n        expiresAt: expiresAtDate,\n        domainId: domainId,\n        domainSlug: link.domain || null,\n        slug: link.slug || null,\n        emailProtected: link.emailProtected ?? preset?.emailProtected ?? false,\n        emailAuthenticated:\n          link.emailAuthenticated ?? preset?.emailAuthenticated ?? false,\n        allowDownload: link.allowDownload ?? preset?.allowDownload,\n        enableNotification:\n          link.enableNotification ?? preset?.enableNotification ?? false,\n        enableFeedback: link.enableFeedback,\n        enableScreenshotProtection: link.enableScreenshotProtection,\n        showBanner: link.showBanner ?? preset?.showBanner ?? false,\n        audienceType: link.audienceType,\n        groupId: isGroupAudience ? link.groupId : null,\n        // For group links, ignore allow/deny lists from presets as access is controlled by group membership\n        allowList: isGroupAudience\n          ? link.allowList\n          : (link.allowList ?? preset?.allowList),\n        denyList: isGroupAudience\n          ? link.denyList\n          : (link.denyList ?? preset?.denyList),\n        ...(preset?.enableCustomMetaTag && {\n          enableCustomMetatag: preset?.enableCustomMetaTag,\n          metaTitle: preset?.metaTitle,\n          metaDescription: preset?.metaDescription,\n          metaImage: metaImage,\n          metaFavicon: metaFavicon,\n        }),\n      },\n    });\n\n    waitUntil(\n      sendLinkCreatedWebhook({\n        teamId,\n        data: {\n          document_id: document.id,\n          link_id: newLink.id,\n        },\n      }),\n    );\n  }\n\n  // If dataroomId was provided, create the relationship\n  if (dataroomId) {\n    // If dataroomFolderId is provided, validate it belongs to the dataroom\n    if (dataroomFolderId) {\n      const dataroomFolder = await prisma.dataroomFolder.findUnique({\n        where: {\n          id: dataroomFolderId,\n          dataroomId: dataroomId,\n        },\n      });\n\n      if (!dataroomFolder) {\n        return res.status(400).json({\n          error:\n            \"Invalid dataroom folder ID or folder does not belong to the specified dataroom\",\n        });\n      }\n    }\n\n    await prisma.dataroomDocument.create({\n      data: {\n        dataroomId,\n        documentId: document.id,\n        folderId: dataroomFolderId || null,\n      },\n    });\n  }\n\n  return res.status(200).json({\n    message: `Document created successfully${\n      dataroomId ? ` and added to dataroom` : \"\"\n    }`,\n    documentId: document.id,\n    dataroomId: dataroomId ?? undefined,\n    linkId: newLink?.id ?? undefined,\n    linkUrl: createLink\n      ? newLink?.domainSlug && newLink?.slug\n        ? `https://${newLink.domainSlug}/${newLink.slug}`\n        : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${newLink?.id}`\n      : undefined,\n  });\n}\n\n/**\n * Handle document.update resource type – creates a new version for an existing document.\n * Delegates version creation and document processing to the versions API endpoint\n * via createNewDocumentVersion.\n */\nasync function handleDocumentUpdate(\n  data: z.infer<typeof DocumentUpdateSchema>,\n  teamId: string,\n  token: string,\n  res: NextApiResponse,\n) {\n  const { documentId, fileUrl, contentType } = data;\n\n  // Check if the content type is supported\n  const supportedContentType = getSupportedContentType(contentType);\n  if (!supportedContentType) {\n    return res.status(400).json({ error: \"Unsupported content type\" });\n  }\n\n  // Verify document exists and belongs to team\n  const document = await prisma.document.findUnique({\n    where: {\n      id: documentId,\n      teamId: teamId,\n    },\n    select: { id: true, name: true },\n  });\n\n  if (!document) {\n    return res\n      .status(404)\n      .json({ error: \"Document not found or not associated with this team\" });\n  }\n\n  // Fetch file from URL\n  const response = await fetch(fileUrl);\n  if (!response.ok) {\n    return res.status(400).json({ error: \"Failed to fetch file from URL\" });\n  }\n\n  // Validate response content type\n  const responseContentType = response.headers.get(\"content-type\");\n  if (!responseContentType || responseContentType.startsWith(\"text/html\")) {\n    return res\n      .status(400)\n      .json({ error: \"Remote resource is not a supported file type\" });\n  }\n  if (!responseContentType.startsWith(contentType)) {\n    console.warn(\n      `Content type mismatch: expected ${contentType}, got ${responseContentType}`,\n    );\n  }\n\n  // Convert to buffer\n  const fileBuffer = Buffer.from(await response.arrayBuffer());\n\n  // Ensure filename has proper extension\n  let fileName = document.name?.trim() ?? \"document\";\n  const actualContentType = (\n    responseContentType?.split(\";\")[0] ?? contentType\n  ).trim();\n  const expectedExtension = getExtensionFromContentType(actualContentType);\n  if (expectedExtension) {\n    const lower = fileName.toLowerCase();\n    const dotIdx = lower.lastIndexOf(\".\");\n    const currentExt = dotIdx !== -1 ? lower.slice(dotIdx + 1) : null;\n    const alias: Record<string, string[]> = {\n      jpeg: [\"jpeg\", \"jpg\"],\n      jpg: [\"jpg\", \"jpeg\"],\n      tiff: [\"tiff\", \"tif\"],\n    };\n    const matches =\n      !!currentExt &&\n      (currentExt === expectedExtension ||\n        (alias[expectedExtension]?.includes(currentExt) ?? false));\n    if (!matches) {\n      fileName = `${fileName}.${expectedExtension}`;\n    }\n  }\n\n  // Upload the file to storage\n  const { type: storageType, data: fileData } = await putFileServer({\n    file: {\n      name: fileName,\n      type: contentType,\n      buffer: fileBuffer,\n    },\n    teamId: teamId,\n    restricted: false,\n  });\n\n  if (!fileData || !storageType) {\n    return res.status(500).json({ error: \"Failed to save file to storage\" });\n  }\n\n  // Create a new document version via the shared helper.\n  // This handles version creation, primary flag management, and triggers\n  // all document processing (pdf-to-image, docs/slides conversion, video, etc.)\n  try {\n    const versionResponse = await createNewDocumentVersion({\n      documentData: {\n        name: fileName,\n        key: fileData,\n        storageType: storageType,\n        contentType: contentType,\n        supportedFileType: supportedContentType,\n        fileSize: fileBuffer.byteLength,\n      },\n      documentId: documentId,\n      teamId: teamId,\n      numPages: 1,\n      token: token,\n    });\n\n    if (!versionResponse.ok) {\n      const errorBody = await versionResponse.json().catch(() => ({}));\n      return res.status(versionResponse.status).json({\n        error: \"Failed to create document version\",\n        details: errorBody,\n      });\n    }\n\n    return res.status(200).json({\n      message: \"Document version created successfully\",\n      documentId: document.id,\n    });\n  } catch (error) {\n    console.error(\"Document update error:\", error);\n    return res\n      .status(500)\n      .json({ error: \"Failed to create document version\" });\n  }\n}\n\n/**\n * Handle link.create resource type\n */\nasync function handleLinkCreate(\n  data: z.infer<typeof LinkCreateSchema>,\n  teamId: string,\n  token: string,\n  res: NextApiResponse,\n) {\n  const { targetId, linkType, link } = data;\n\n  // Check if team is paused\n  const teamIsPaused = await isTeamPausedById(teamId);\n  if (teamIsPaused) {\n    return res.status(403).json({\n      error: \"Team is currently paused. New link creation is not available.\",\n    });\n  }\n\n  // Validate target exists and belongs to the team\n  if (linkType === \"DOCUMENT_LINK\") {\n    const document = await prisma.document.findUnique({\n      where: {\n        id: targetId,\n        teamId: teamId,\n      },\n    });\n\n    if (!document) {\n      return res\n        .status(400)\n        .json({ error: \"Document not found or not associated with this team\" });\n    }\n  } else if (linkType === \"DATAROOM_LINK\") {\n    const dataroom = await prisma.dataroom.findUnique({\n      where: {\n        id: targetId,\n        teamId: teamId,\n      },\n    });\n\n    if (!dataroom) {\n      return res\n        .status(400)\n        .json({ error: \"Dataroom not found or not associated with this team\" });\n    }\n  }\n\n  // If domain and slug are provided, validate them\n  let domainId = null;\n  if (link.domain && link.slug) {\n    // Check if domain exists\n    const domain = await prisma.domain.findUnique({\n      where: {\n        slug: link.domain,\n        teamId: teamId,\n      },\n      select: { id: true },\n    });\n\n    if (!domain) {\n      return res\n        .status(400)\n        .json({ error: \"Domain not found or not associated with this team\" });\n    }\n\n    domainId = domain.id;\n\n    // Check if the slug is already in use with this domain\n    const existingLink = await prisma.link.findUnique({\n      where: {\n        domainSlug_slug: {\n          slug: link.slug,\n          domainSlug: link.domain,\n        },\n      },\n    });\n\n    if (existingLink) {\n      return res\n        .status(400)\n        .json({ error: \"The link with this domain and slug already exists\" });\n    }\n  }\n\n  // If preset is provided, validate it\n  let preset: LinkPreset | null = null;\n  let metaImage: string | null = null;\n  let metaFavicon: string | null = null;\n  if (link.presetId) {\n    preset = await prisma.linkPreset.findUnique({\n      where: { pId: link.presetId, teamId: teamId },\n    });\n\n    if (!preset) {\n      return res.status(400).json({\n        error: \"Link preset not found or not associated with this team\",\n      });\n    }\n\n    // 4. Handle image files for custom meta tag (if enabled)\n    if (preset.enableCustomMetaTag) {\n      // Process meta image if present\n      if (preset.metaImage && isDataUrl(preset.metaImage)) {\n        const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n          preset.metaImage,\n        );\n        const blob = await put(filename, buffer, {\n          access: \"public\",\n          addRandomSuffix: true,\n        });\n        metaImage = blob.url;\n      }\n\n      // Process favicon if present\n      if (preset.metaFavicon && isDataUrl(preset.metaFavicon)) {\n        const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n          preset.metaFavicon,\n        );\n        const blob = await put(filename, buffer, {\n          access: \"public\",\n          addRandomSuffix: true,\n        });\n        metaFavicon = blob.url;\n      }\n    }\n  }\n\n  // Create the link\n  try {\n    // Hash password if provided\n    const hashedPassword = link.password\n      ? await generateEncrpytedPassword(link.password)\n      : preset?.password\n        ? await generateEncrpytedPassword(preset.password)\n        : null;\n\n    const expiresAtDate = link.expiresAt\n      ? new Date(link.expiresAt)\n      : preset?.expiresAt\n        ? new Date(preset.expiresAt)\n        : null;\n\n    const isGroupAudience = link.audienceType === \"GROUP\";\n\n    const newLink = await prisma.link.create({\n      data: {\n        documentId: linkType === \"DOCUMENT_LINK\" ? targetId : null,\n        dataroomId: linkType === \"DATAROOM_LINK\" ? targetId : null,\n        linkType,\n        teamId,\n        name: link.name,\n        password: hashedPassword,\n        domainId: domainId,\n        domainSlug: link.domain || null,\n        slug: link.slug || null,\n        expiresAt: expiresAtDate,\n        emailProtected: link.emailProtected ?? preset?.emailProtected ?? false,\n        emailAuthenticated:\n          link.emailAuthenticated ?? preset?.emailAuthenticated ?? false,\n        allowDownload: link.allowDownload ?? preset?.allowDownload,\n        enableNotification:\n          link.enableNotification ?? preset?.enableNotification ?? false,\n        enableFeedback: link.enableFeedback,\n        enableScreenshotProtection: link.enableScreenshotProtection,\n        showBanner: link.showBanner ?? preset?.showBanner ?? false,\n        audienceType: link.audienceType,\n        groupId: isGroupAudience ? link.groupId : null,\n        // For group links, ignore allow/deny lists from presets as access is controlled by group membership\n        allowList: isGroupAudience\n          ? link.allowList\n          : link.allowList || preset?.allowList,\n        denyList: isGroupAudience\n          ? link.denyList\n          : link.denyList || preset?.denyList,\n        ...(preset?.enableCustomMetaTag && {\n          enableCustomMetatag: preset?.enableCustomMetaTag,\n          metaTitle: preset?.metaTitle,\n          metaDescription: preset?.metaDescription,\n          metaImage: metaImage,\n          metaFavicon: metaFavicon,\n        }),\n      },\n    });\n\n    waitUntil(\n      sendLinkCreatedWebhook({\n        teamId,\n        data: {\n          document_id: linkType === \"DOCUMENT_LINK\" ? targetId : null,\n          dataroom_id: linkType === \"DATAROOM_LINK\" ? targetId : null,\n          link_id: newLink.id,\n        },\n      }),\n    );\n\n    return res.status(200).json({\n      message: \"Link created successfully\",\n      linkId: newLink.id,\n      targetId,\n      linkType,\n      linkUrl:\n        domainId && link.domain && link.slug\n          ? `https://${newLink.domainSlug}/${newLink.slug}`\n          : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${newLink.id}`,\n    });\n  } catch (error) {\n    console.error(\"Link creation error:\", error);\n    return res.status(500).json({ error: \"Failed to create link\" });\n  }\n}\n\n/**\n * Handle link.update resource type\n */\nasync function handleLinkUpdate(\n  data: z.infer<typeof LinkUpdateSchema>,\n  teamId: string,\n  token: string,\n  res: NextApiResponse,\n) {\n  const { linkId, link } = data;\n\n  // Check if team is paused\n  const teamIsPaused = await isTeamPausedById(teamId);\n  if (teamIsPaused) {\n    return res.status(403).json({\n      error: \"Team is currently paused. Link updates are not available.\",\n    });\n  }\n\n  // Validate link exists and belongs to the team\n  const existingLink = await prisma.link.findUnique({\n    where: {\n      id: linkId,\n      teamId: teamId,\n    },\n    select: {\n      id: true,\n      domainSlug: true,\n      slug: true,\n      documentId: true,\n      dataroomId: true,\n      linkType: true,\n    },\n  });\n\n  if (!existingLink) {\n    return res\n      .status(404)\n      .json({ error: \"Link not found or not associated with this team\" });\n  }\n\n  // If domain and slug are provided, validate them\n  let domainId = null;\n\n  // Reject requests where exactly one of domain/slug is present\n  if (link.domain && !link.slug) {\n    return res.status(400).json({\n      error:\n        \"Both 'domain' and 'slug' must be provided together. 'slug' is missing.\",\n    });\n  }\n  if (link.slug && !link.domain) {\n    return res.status(400).json({\n      error:\n        \"Both 'domain' and 'slug' must be provided together. 'domain' is missing.\",\n    });\n  }\n\n  if (link.domain && link.slug) {\n    // Check if domain exists\n    const domain = await prisma.domain.findUnique({\n      where: {\n        slug: link.domain,\n        teamId: teamId,\n      },\n      select: { id: true },\n    });\n\n    if (!domain) {\n      return res\n        .status(400)\n        .json({ error: \"Domain not found or not associated with this team\" });\n    }\n\n    domainId = domain.id;\n\n    // Check if the slug is already in use with this domain (excluding the current link)\n    const conflictingLink = await prisma.link.findUnique({\n      where: {\n        domainSlug_slug: {\n          slug: link.slug,\n          domainSlug: link.domain,\n        },\n      },\n    });\n\n    if (conflictingLink && conflictingLink.id !== linkId) {\n      return res\n        .status(400)\n        .json({ error: \"The link with this domain and slug already exists\" });\n    }\n  }\n\n  // If preset is provided, validate it\n  let preset: LinkPreset | null = null;\n  let metaImage: string | null = null;\n  let metaFavicon: string | null = null;\n  if (link.presetId) {\n    preset = await prisma.linkPreset.findUnique({\n      where: { pId: link.presetId, teamId: teamId },\n    });\n\n    if (!preset) {\n      return res.status(400).json({\n        error: \"Link preset not found or not associated with this team\",\n      });\n    }\n\n    // Handle image files for custom meta tag (if enabled)\n    if (preset.enableCustomMetaTag) {\n      // Process meta image if present\n      if (preset.metaImage && isDataUrl(preset.metaImage)) {\n        const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n          preset.metaImage,\n        );\n        const blob = await put(filename, buffer, {\n          access: \"public\",\n          addRandomSuffix: true,\n        });\n        metaImage = blob.url;\n      }\n\n      // Process favicon if present\n      if (preset.metaFavicon && isDataUrl(preset.metaFavicon)) {\n        const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n          preset.metaFavicon,\n        );\n        const blob = await put(filename, buffer, {\n          access: \"public\",\n          addRandomSuffix: true,\n        });\n        metaFavicon = blob.url;\n      }\n    }\n  }\n\n  // Update the link\n  try {\n    // Build update payload conditionally – only fields explicitly provided in\n    // the incoming link payload (or supplied by a preset) are included.\n    // Prisma treats missing / undefined keys as \"do not update\".\n    const data: Record<string, unknown> = {};\n\n    /** Returns true when the property was explicitly sent in the link payload */\n    const has = (key: string): boolean => key in link;\n\n    // name\n    if (has(\"name\")) {\n      data.name = link.name;\n    }\n\n    // password – hash when provided via link or preset\n    if (has(\"password\")) {\n      data.password = link.password\n        ? await generateEncrpytedPassword(link.password)\n        : null;\n    } else if (preset?.password) {\n      data.password = await generateEncrpytedPassword(preset.password);\n    }\n\n    // domain + slug (validated to always be paired earlier)\n    if (has(\"domain\") && has(\"slug\")) {\n      data.domainId = domainId;\n      data.domainSlug = link.domain || null;\n      data.slug = link.slug || null;\n    }\n\n    // expiresAt\n    if (has(\"expiresAt\")) {\n      data.expiresAt = link.expiresAt ? new Date(link.expiresAt) : null;\n    } else if (preset?.expiresAt) {\n      data.expiresAt = new Date(preset.expiresAt);\n    }\n\n    // boolean flags – include when explicitly provided or when preset supplies a value\n    if (has(\"emailProtected\")) {\n      data.emailProtected = link.emailProtected;\n    } else if (preset?.emailProtected != null) {\n      data.emailProtected = preset.emailProtected;\n    }\n\n    if (has(\"emailAuthenticated\")) {\n      data.emailAuthenticated = link.emailAuthenticated;\n    } else if (preset?.emailAuthenticated != null) {\n      data.emailAuthenticated = preset.emailAuthenticated;\n    }\n\n    if (has(\"allowDownload\")) {\n      data.allowDownload = link.allowDownload;\n    } else if (preset?.allowDownload != null) {\n      data.allowDownload = preset.allowDownload;\n    }\n\n    if (has(\"enableNotification\")) {\n      data.enableNotification = link.enableNotification;\n    } else if (preset?.enableNotification != null) {\n      data.enableNotification = preset.enableNotification;\n    }\n\n    if (has(\"enableFeedback\")) {\n      data.enableFeedback = link.enableFeedback;\n    }\n\n    if (has(\"enableScreenshotProtection\")) {\n      data.enableScreenshotProtection = link.enableScreenshotProtection;\n    }\n\n    if (has(\"showBanner\")) {\n      data.showBanner = link.showBanner;\n    } else if (preset?.showBanner != null) {\n      data.showBanner = preset.showBanner;\n    }\n\n    // audienceType & groupId\n    if (has(\"audienceType\")) {\n      data.audienceType = link.audienceType;\n      // When switching away from GROUP, clear groupId\n      if (link.audienceType !== \"GROUP\") {\n        data.groupId = null;\n      } else if (has(\"groupId\")) {\n        data.groupId = link.groupId;\n      }\n    } else if (has(\"groupId\")) {\n      data.groupId = link.groupId;\n    }\n\n    // allow / deny lists\n    // For group links, ignore preset lists as access is controlled by group membership\n    const isGroupAudience =\n      has(\"audienceType\") && link.audienceType === \"GROUP\";\n\n    if (has(\"allowList\")) {\n      data.allowList = link.allowList;\n    } else if (!isGroupAudience && preset?.allowList) {\n      data.allowList = preset.allowList;\n    }\n\n    if (has(\"denyList\")) {\n      data.denyList = link.denyList;\n    } else if (!isGroupAudience && preset?.denyList) {\n      data.denyList = preset.denyList;\n    }\n\n    // Preset custom meta tag fields – only applied when the preset flag is set\n    if (preset?.enableCustomMetaTag) {\n      data.enableCustomMetatag = preset.enableCustomMetaTag;\n      data.metaTitle = preset.metaTitle;\n      data.metaDescription = preset.metaDescription;\n      data.metaImage = metaImage;\n      data.metaFavicon = metaFavicon;\n    }\n\n    const updatedLink = await prisma.link.update({\n      where: { id: linkId, teamId: teamId },\n      data,\n    });\n\n    return res.status(200).json({\n      message: \"Link updated successfully\",\n      linkId: updatedLink.id,\n      linkUrl:\n        updatedLink.domainSlug && updatedLink.slug\n          ? `https://${updatedLink.domainSlug}/${updatedLink.slug}`\n          : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${updatedLink.id}`,\n    });\n  } catch (error) {\n    console.error(\"Link update error:\", error);\n    return res.status(500).json({ error: \"Failed to update link\" });\n  }\n}\n\n/**\n * Helper function to create dataroom folders recursively\n */\nasync function createDataroomFoldersRecursive(\n  dataroomId: string,\n  folders: Array<{ name: string; subfolders?: any[] }>,\n  parentPath: string = \"\",\n  parentId: string | null = null,\n): Promise<void> {\n  for (const folder of folders) {\n    const folderPath = parentPath + \"/\" + safeSlugify(folder.name);\n\n    // Create the folder\n    const createdFolder = await prisma.dataroomFolder.create({\n      data: {\n        name: folder.name,\n        path: folderPath,\n        parentId: parentId,\n        dataroomId: dataroomId,\n      },\n    });\n\n    // If the folder has subfolders, create them recursively\n    if (folder.subfolders && folder.subfolders.length > 0) {\n      await createDataroomFoldersRecursive(\n        dataroomId,\n        folder.subfolders,\n        folderPath,\n        createdFolder.id,\n      );\n    }\n  }\n}\n\n/**\n * Handle dataroom.create resource type\n */\nasync function handleDataroomCreate(\n  data: z.infer<typeof DataroomCreateSchema>,\n  teamId: string,\n  token: string,\n  res: NextApiResponse,\n) {\n  const { name, description, createLink, link, folders } = data;\n\n  // Check if team is paused\n  const teamIsPaused = await isTeamPausedById(teamId);\n  if (teamIsPaused) {\n    return res.status(403).json({\n      error:\n        \"Team is currently paused. New dataroom creation is not available.\",\n    });\n  }\n\n  // If custom domain and slug are provided for link, validate them\n  let domainId = null;\n  if (createLink && link?.domain && link?.slug) {\n    // Check if domain exists\n    const domain = await prisma.domain.findUnique({\n      where: {\n        slug: link.domain,\n        teamId: teamId,\n      },\n    });\n\n    if (!domain) {\n      return res\n        .status(400)\n        .json({ error: \"Domain not found or not associated with this team\" });\n    }\n\n    domainId = domain.id;\n\n    // Check if the slug is already in use with this domain\n    const existingLink = await prisma.link.findUnique({\n      where: {\n        domainSlug_slug: {\n          slug: link.slug,\n          domainSlug: link.domain,\n        },\n      },\n    });\n\n    if (existingLink) {\n      return res\n        .status(400)\n        .json({ error: \"The link with this domain and slug already exists\" });\n    }\n  }\n\n  // If preset is provided, validate it\n  let preset: LinkPreset | null = null;\n  let metaImage: string | null = null;\n  let metaFavicon: string | null = null;\n  if (createLink && link?.presetId) {\n    preset = await prisma.linkPreset.findUnique({\n      where: { pId: link.presetId, teamId: teamId },\n    });\n\n    if (!preset) {\n      return res.status(400).json({\n        error: \"Link preset not found or not associated with this team\",\n      });\n    }\n\n    // Handle image files for custom meta tag (if enabled)\n    if (preset.enableCustomMetaTag) {\n      // Process meta image if present\n      if (preset.metaImage && isDataUrl(preset.metaImage)) {\n        const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n          preset.metaImage,\n        );\n        const blob = await put(filename, buffer, {\n          access: \"public\",\n          addRandomSuffix: true,\n        });\n        metaImage = blob.url;\n      }\n\n      // Process favicon if present\n      if (preset.metaFavicon && isDataUrl(preset.metaFavicon)) {\n        const { buffer, mimeType, filename } = convertDataUrlToBuffer(\n          preset.metaFavicon,\n        );\n        const blob = await put(filename, buffer, {\n          access: \"public\",\n          addRandomSuffix: true,\n        });\n        metaFavicon = blob.url;\n      }\n    }\n  }\n\n  // Create the dataroom\n  try {\n    // Generate unique public ID for the dataroom\n    const pId = newId(\"dataroom\");\n\n    // Create dataroom with link if requested\n    let createData: any = {\n      name,\n      description,\n      teamId,\n      pId,\n    };\n\n    if (createLink && link) {\n      const isGroupAudience = link.audienceType === \"GROUP\";\n      const hashedPassword = link.password\n        ? await generateEncrpytedPassword(link.password)\n        : preset?.password\n          ? await generateEncrpytedPassword(preset.password)\n          : null;\n      const expiresAtDate = link.expiresAt\n        ? new Date(link.expiresAt)\n        : preset?.expiresAt\n          ? new Date(preset?.expiresAt)\n          : null;\n\n      createData.links = {\n        create: {\n          name: link.name,\n          teamId,\n          linkType: \"DATAROOM_LINK\",\n          domainId: domainId,\n          domainSlug: link.domain || null,\n          slug: link.slug || null,\n          password: hashedPassword,\n          expiresAt: expiresAtDate,\n          emailProtected:\n            link.emailProtected ?? preset?.emailProtected ?? false,\n          emailAuthenticated:\n            link.emailAuthenticated ?? preset?.emailAuthenticated ?? false,\n          allowDownload: link.allowDownload ?? preset?.allowDownload,\n          enableNotification:\n            link.enableNotification ?? preset?.enableNotification ?? false,\n          enableFeedback: link.enableFeedback,\n          enableScreenshotProtection: link.enableScreenshotProtection,\n          showBanner: link.showBanner ?? preset?.showBanner ?? false,\n          audienceType: link.audienceType,\n          groupId: isGroupAudience ? link.groupId : null,\n          allowList: link.allowList || preset?.allowList,\n          denyList: link.denyList || preset?.denyList,\n          ...(preset?.enableCustomMetaTag && {\n            enableCustomMetatag: preset?.enableCustomMetaTag,\n            metaTitle: preset?.metaTitle,\n            metaDescription: preset?.metaDescription,\n            metaImage: metaImage,\n            metaFavicon: metaFavicon,\n          }),\n        },\n      };\n    }\n\n    const dataroom = await prisma.dataroom.create({\n      data: createData,\n      include: {\n        links: createLink, // Only include links if we're creating one\n      },\n    });\n\n    // Create folders if provided\n    if (folders && folders.length > 0) {\n      await createDataroomFoldersRecursive(dataroom.id, folders);\n    }\n\n    if (createLink) {\n      waitUntil(\n        sendLinkCreatedWebhook({\n          teamId,\n          data: {\n            dataroom_id: dataroom.id,\n            link_id: dataroom.links?.[0]?.id,\n          },\n        }),\n      );\n    }\n\n    return res.status(200).json({\n      message: \"Dataroom created successfully\",\n      dataroomId: dataroom.id,\n      linkId: createLink ? dataroom.links?.[0]?.id : undefined,\n      linkUrl: createLink\n        ? dataroom.links?.[0]?.domainSlug && dataroom.links?.[0]?.slug\n          ? `https://${dataroom.links?.[0]?.domainSlug}/${dataroom.links?.[0]?.slug}`\n          : `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${dataroom.links?.[0]?.id}`\n        : undefined,\n    });\n  } catch (error) {\n    console.error(\"Dataroom creation error:\", error);\n    return res.status(500).json({ error: \"Failed to create dataroom\" });\n  }\n}\n"
  },
  {
    "path": "pages/branding.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { Check, CircleHelpIcon, UploadIcon } from \"lucide-react\";\nimport { HexColorInput, HexColorPicker } from \"react-colorful\";\nimport sanitizeHtml from \"sanitize-html\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { useDebounce } from \"use-debounce\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useBrand } from \"@/lib/swr/use-brand\";\nimport { cn, convertDataUrlToFile, uploadImage } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { NavMenu } from \"@/components/navigation-menu\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardFooter } from \"@/components/ui/card\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Label } from \"@/components/ui/label\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\nimport { UpgradeButton } from \"@/components/ui/upgrade-button\";\n\nexport default function Branding() {\n  const teamInfo = useTeam();\n  const router = useRouter();\n  const { brand } = useBrand();\n  const { plan, isTrial, isBusiness, isDatarooms, isDataroomsPlus } = usePlan();\n\n  const [brandColor, setBrandColor] = useState<string>(\"#000000\");\n  const [accentColor, setAccentColor] = useState<string>(\"#030712\");\n  const [logo, setLogo] = useState<string | null>(null);\n  const [blobUrl, setBlobUrl] = useState<string | null>(null);\n  const [banner, setBanner] = useState<string | null>(null);\n  const [bannerBlobUrl, setBannerBlobUrl] = useState<string | null>(null);\n  const [welcomeMessage, setWelcomeMessage] = useState<string>(\n    \"Your action is requested to continue\",\n  );\n  const [applyAccentColorToDataroomView, setApplyAccentColorToDataroomView] =\n    useState<boolean>(false);\n  const [debouncedBrandColor] = useDebounce(brandColor, 300);\n  const [debouncedAccentColor] = useDebounce(accentColor, 300);\n  const [debouncedWelcomeMessage] = useDebounce(welcomeMessage, 500);\n\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [fileError, setFileError] = useState<string | null>(null);\n  const [bannerError, setBannerError] = useState<string | null>(null);\n  const [dragActive, setDragActive] = useState(false);\n  const [bannerDragActive, setBannerDragActive] = useState(false);\n  const [welcomeMessageError, setWelcomeMessageError] = useState<string | null>(\n    null,\n  );\n\n  // Check if user has access to dataroom branding features\n  const hasDataroomAccess =\n    isBusiness || isDatarooms || isDataroomsPlus || isTrial;\n\n  // Welcome message validation\n  const MAX_WELCOME_MESSAGE_LENGTH = 80; // Roughly 2 lines of text\n\n  const validateWelcomeMessage = (message: string): string | null => {\n    if (!message.trim()) {\n      return \"Welcome message cannot be empty\";\n    }\n\n    // Strip HTML tags and validate plain text only\n    const sanitized = sanitizeHtml(message, {\n      allowedTags: [],\n      allowedAttributes: {},\n    });\n\n    if (sanitized !== message) {\n      return \"Welcome message must contain only plain text\";\n    }\n\n    if (sanitized.length > MAX_WELCOME_MESSAGE_LENGTH) {\n      return `Welcome message must be ${MAX_WELCOME_MESSAGE_LENGTH} characters or less (currently ${sanitized.length})`;\n    }\n\n    return null;\n  };\n\n  const onChangeLogo = useCallback(\n    (e: any) => {\n      setFileError(null);\n      const file = e.target.files[0];\n      if (file) {\n        if (file.size / 1024 / 1024 > 2) {\n          setFileError(\"File size too big (max 2MB)\");\n        } else if (file.type !== \"image/png\" && file.type !== \"image/jpeg\") {\n          setFileError(\"File type not supported (.png or .jpg only)\");\n        } else {\n          const reader = new FileReader();\n          reader.onload = (e) => {\n            const dataUrl = e.target?.result as string;\n            setLogo(dataUrl);\n            // create a blob url for preview\n            const blob = convertDataUrlToFile({ dataUrl });\n            const blobUrl = URL.createObjectURL(blob);\n            setBlobUrl(blobUrl);\n          };\n          reader.readAsDataURL(file);\n        }\n      }\n    },\n    [setLogo],\n  );\n\n  const onChangeBanner = useCallback(\n    (e: any) => {\n      setBannerError(null);\n      const file = e.target.files[0];\n      if (file) {\n        if (file.size / 1024 / 1024 > 2) {\n          setBannerError(\"File size too big (max 2MB)\");\n        } else if (file.type !== \"image/png\" && file.type !== \"image/jpeg\") {\n          setBannerError(\"File type not supported (.png or .jpg only)\");\n        } else {\n          const reader = new FileReader();\n          reader.onload = (e) => {\n            const dataUrl = e.target?.result as string;\n            setBanner(dataUrl);\n            // create a blob url for preview\n            const blob = convertDataUrlToFile({ dataUrl });\n            const blobUrl = URL.createObjectURL(blob);\n            setBannerBlobUrl(blobUrl);\n          };\n          reader.readAsDataURL(file);\n        }\n      }\n    },\n    [setBanner],\n  );\n\n  useEffect(() => {\n    if (brand) {\n      setBrandColor(brand.brandColor || \"#000000\");\n      setAccentColor(brand.accentColor || \"#FFFFFF\");\n      setLogo(brand.logo || null);\n      setBanner(brand.banner || null);\n      setApplyAccentColorToDataroomView(\n        (brand as any)?.applyAccentColorToDataroomView ?? false,\n      );\n      const message =\n        brand.welcomeMessage || \"Your action is requested to continue\";\n      setWelcomeMessage(message);\n      // Validate existing message\n      const error = validateWelcomeMessage(message);\n      setWelcomeMessageError(error);\n    }\n  }, [brand]);\n\n  // Handle welcome message change with validation\n  const handleWelcomeMessageChange = (value: string) => {\n    setWelcomeMessage(value);\n    const error = validateWelcomeMessage(value);\n    setWelcomeMessageError(error);\n  };\n\n  const saveBranding = async (e: any) => {\n    e.preventDefault();\n\n    // Validate welcome message before saving\n    const welcomeError = validateWelcomeMessage(welcomeMessage);\n    if (welcomeError) {\n      setWelcomeMessageError(welcomeError);\n      toast.error(\"Please fix the validation errors before saving\");\n      return;\n    }\n\n    setIsLoading(true);\n    let logoBlobUrl: string | null =\n      logo && logo.startsWith(\"data:\") ? null : logo;\n    if (logo && logo.startsWith(\"data:\")) {\n      const blob = convertDataUrlToFile({ dataUrl: logo });\n      logoBlobUrl = await uploadImage(blob);\n      setLogo(logoBlobUrl);\n    }\n\n    let bannerBlobUrl: string | null =\n      banner && banner.startsWith(\"data:\") ? null : banner;\n    if (banner && banner.startsWith(\"data:\")) {\n      const blob = convertDataUrlToFile({ dataUrl: banner });\n      bannerBlobUrl = await uploadImage(blob);\n      setBanner(bannerBlobUrl);\n    }\n\n    const data = {\n      welcomeMessage:\n        welcomeMessage.trim() || \"Your action is requested to continue\",\n      brandColor: brandColor,\n      accentColor: accentColor,\n      applyAccentColorToDataroomView,\n      logo: logoBlobUrl,\n      // Only include banner if user has dataroom access (Business+)\n      ...(hasDataroomAccess && { banner: bannerBlobUrl }),\n    };\n\n    const res = await fetch(\n      `/api/teams/${teamInfo?.currentTeam?.id}/branding`,\n      {\n        method: brand ? \"PUT\" : \"POST\",\n        body: JSON.stringify(data),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n\n    if (res.ok) {\n      mutate(`/api/teams/${teamInfo?.currentTeam?.id}/branding`);\n      setIsLoading(false);\n      toast.success(\"Branding updated successfully\");\n    } else {\n      setIsLoading(false);\n      const errorData = await res.json().catch(() => ({}));\n      console.error(\"Save error:\", errorData);\n      toast.error(errorData.message || \"Failed to save branding\");\n    }\n  };\n\n  const handleDelete = async () => {\n    setIsLoading(true);\n    const res = await fetch(\n      `/api/teams/${teamInfo?.currentTeam?.id}/branding`,\n      {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n\n    if (res.ok) {\n      setLogo(null);\n      setBanner(null);\n      setBrandColor(\"#000000\");\n      setAccentColor(\"#030712\");\n      setApplyAccentColorToDataroomView(false);\n      setWelcomeMessage(\"Your action is requested to continue\");\n      setWelcomeMessageError(null);\n      setIsLoading(false);\n      toast.success(\"Branding reset successfully\");\n      router.reload();\n    }\n  };\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <section className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h1 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n                Global Branding\n              </h1>\n              <p className=\"text-xs text-muted-foreground sm:text-sm\">\n                Customize how your brand appears globally across Papermark\n                documents and data rooms your visitors see.\n              </p>\n            </div>\n          </section>\n\n          <NavMenu\n            navigation={[\n              {\n                label: \"Document Branding\",\n                href: \"/branding\",\n                segment: `branding`,\n              },\n              {\n                label: \"Domains\",\n                href: \"/settings/domains\",\n                segment: \"domains\",\n              },\n              {\n                label: \"Link Previews\",\n                href: \"/settings/presets\",\n                segment: \"presets\",\n              },\n            ]}\n          />\n        </header>\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n                Global Branding\n              </h3>\n              <div className=\"text-sm text-muted-foreground\">\n                All direct links to documents and data rooms will have your\n                branding applied.\n                <div className=\"flex items-center gap-2\">\n                  <span className=\"italic\">\n                    You can overwrite the branding for each data room\n                    individually.\n                  </span>\n                  <BadgeTooltip\n                    linkText=\"Click here\"\n                    content=\"How to customize document branding?\"\n                    key=\"branding\"\n                    link=\"https://www.papermark.com/help/article/document-branding\"\n                  >\n                    <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                  </BadgeTooltip>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          {/* Main Layout */}\n          <div className=\"flex w-full flex-col gap-6 lg:flex-row lg:gap-8\">\n            {/* Settings Column */}\n            <div className=\"flex w-full flex-col gap-6 lg:w-[420px] lg:shrink-0\">\n              {/* Scrollable Settings */}\n              <div className=\"flex flex-col gap-6 lg:max-h-[calc(100vh-400px)] lg:overflow-y-auto lg:pr-4\">\n                {/* Logo Card */}\n                <Card>\n                  <CardContent className=\"pt-6\">\n                    <div className=\"space-y-2\">\n                      <Label htmlFor=\"image\">\n                        Logo{\" \"}\n                        <span className=\"font-normal text-muted-foreground\">\n                          (max 2 MB)\n                        </span>\n                      </Label>\n                      <label\n                        htmlFor=\"image\"\n                        className=\"group relative mt-2 flex h-20 w-48 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 transition-all hover:border-gray-400 hover:bg-gray-100\"\n                      >\n                        <div\n                          className=\"absolute z-[5] h-full w-full rounded-lg\"\n                          onDragOver={(e) => {\n                            e.preventDefault();\n                            e.stopPropagation();\n                            setDragActive(true);\n                          }}\n                          onDragEnter={(e) => {\n                            e.preventDefault();\n                            e.stopPropagation();\n                            setDragActive(true);\n                          }}\n                          onDragLeave={(e) => {\n                            e.preventDefault();\n                            e.stopPropagation();\n                            setDragActive(false);\n                          }}\n                          onDrop={(e) => {\n                            e.preventDefault();\n                            e.stopPropagation();\n                            setDragActive(false);\n                            setFileError(null);\n                            const file =\n                              e.dataTransfer.files && e.dataTransfer.files[0];\n                            if (file) {\n                              if (file.size / 1024 / 1024 > 2) {\n                                setFileError(\"File size too big (max 2MB)\");\n                              } else if (\n                                file.type !== \"image/png\" &&\n                                file.type !== \"image/jpeg\"\n                              ) {\n                                setFileError(\n                                  \"File type not supported (.png or .jpg only)\",\n                                );\n                              } else {\n                                const reader = new FileReader();\n                                reader.onload = (e) => {\n                                  const dataUrl = e.target?.result as string;\n                                  setLogo(dataUrl);\n                                  const blob = convertDataUrlToFile({\n                                    dataUrl,\n                                  });\n                                  const blobUrl = URL.createObjectURL(blob);\n                                  setBlobUrl(blobUrl);\n                                };\n                                reader.readAsDataURL(file);\n                              }\n                            }\n                          }}\n                        />\n                        {!logo ? (\n                          <div\n                            className={cn(\n                              \"flex flex-col items-center justify-center gap-2\",\n                              dragActive && \"scale-105\",\n                            )}\n                          >\n                            <UploadIcon\n                              className=\"h-8 w-8 text-gray-400\"\n                              aria-hidden=\"true\"\n                            />\n                          </div>\n                        ) : (\n                          <div className=\"relative flex h-full w-full items-center justify-center p-4\">\n                            <img\n                              src={logo}\n                              alt=\"Logo preview\"\n                              className=\"max-h-full max-w-full object-contain\"\n                            />\n                          </div>\n                        )}\n                      </label>\n                      <input\n                        id=\"image\"\n                        name=\"image\"\n                        type=\"file\"\n                        accept=\"image/jpeg,image/png\"\n                        className=\"sr-only\"\n                        onChange={onChangeLogo}\n                      />\n                      {fileError && (\n                        <p className=\"text-sm text-red-500\">{fileError}</p>\n                      )}\n                    </div>\n                  </CardContent>\n                </Card>\n\n                {/* Banner Card - Only for Business+ users */}\n                {hasDataroomAccess && (\n                  <Card>\n                    <CardContent className=\"pt-6\">\n                      <div className=\"space-y-2\">\n                        <Label htmlFor=\"banner\">\n                          Banner{\" \"}\n                          <span className=\"font-normal text-muted-foreground\">\n                            (for data rooms, max 2 MB)\n                          </span>\n                        </Label>\n                        <label\n                          htmlFor=\"banner\"\n                          className=\"group relative mt-2 flex h-32 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 transition-all hover:border-gray-400 hover:bg-gray-100\"\n                        >\n                          <div\n                            className=\"absolute z-[5] h-full w-full rounded-lg\"\n                            onDragOver={(e) => {\n                              e.preventDefault();\n                              e.stopPropagation();\n                              setBannerDragActive(true);\n                            }}\n                            onDragEnter={(e) => {\n                              e.preventDefault();\n                              e.stopPropagation();\n                              setBannerDragActive(true);\n                            }}\n                            onDragLeave={(e) => {\n                              e.preventDefault();\n                              e.stopPropagation();\n                              setBannerDragActive(false);\n                            }}\n                            onDrop={(e) => {\n                              e.preventDefault();\n                              e.stopPropagation();\n                              setBannerDragActive(false);\n                              setBannerError(null);\n                              const file =\n                                e.dataTransfer.files && e.dataTransfer.files[0];\n                              if (file) {\n                                if (file.size / 1024 / 1024 > 2) {\n                                  setBannerError(\"File size too big (max 2MB)\");\n                                } else if (\n                                  file.type !== \"image/png\" &&\n                                  file.type !== \"image/jpeg\"\n                                ) {\n                                  setBannerError(\n                                    \"File type not supported (.png or .jpg only)\",\n                                  );\n                                } else {\n                                  const reader = new FileReader();\n                                  reader.onload = (e) => {\n                                    const dataUrl = e.target?.result as string;\n                                    setBanner(dataUrl);\n                                    const blob = convertDataUrlToFile({\n                                      dataUrl,\n                                    });\n                                    const blobUrl = URL.createObjectURL(blob);\n                                    setBannerBlobUrl(blobUrl);\n                                  };\n                                  reader.readAsDataURL(file);\n                                }\n                              }\n                            }}\n                          />\n                          {!banner ? (\n                            <div\n                              className={cn(\n                                \"flex flex-col items-center justify-center gap-2\",\n                                bannerDragActive && \"scale-105\",\n                              )}\n                            >\n                              <UploadIcon\n                                className=\"h-8 w-8 text-gray-400\"\n                                aria-hidden=\"true\"\n                              />\n                              <p className=\"text-xs text-muted-foreground\">\n                                Upload banner image\n                              </p>\n                            </div>\n                          ) : (\n                            <div className=\"relative flex h-full w-full items-center justify-center p-4\">\n                              <img\n                                src={banner}\n                                alt=\"Banner preview\"\n                                className=\"max-h-full max-w-full object-cover\"\n                              />\n                            </div>\n                          )}\n                        </label>\n                        <input\n                          id=\"banner\"\n                          name=\"banner\"\n                          type=\"file\"\n                          accept=\"image/jpeg,image/png\"\n                          className=\"sr-only\"\n                          onChange={onChangeBanner}\n                        />\n                        {bannerError && (\n                          <p className=\"text-sm text-red-500\">{bannerError}</p>\n                        )}\n                        {banner && (\n                          <Button\n                            type=\"button\"\n                            variant=\"ghost\"\n                            size=\"sm\"\n                            onClick={() => {\n                              setBanner(null);\n                              setBannerBlobUrl(null);\n                            }}\n                            className=\"text-xs\"\n                          >\n                            Remove banner\n                          </Button>\n                        )}\n                      </div>\n                    </CardContent>\n                  </Card>\n                )}\n\n                {/* Brand Color Card */}\n                <Card>\n                  <CardContent className=\"pt-6\">\n                    <div className=\"space-y-3\">\n                      <Label htmlFor=\"primary-color\">Brand Color</Label>\n                      <div className=\"flex items-center space-x-3\">\n                        <Popover>\n                          <PopoverTrigger>\n                            <div\n                              className=\"h-10 w-10 cursor-pointer rounded-md border-2 border-gray-300 shadow-sm transition-all hover:border-gray-400\"\n                              style={{ backgroundColor: brandColor }}\n                            />\n                          </PopoverTrigger>\n                          <PopoverContent>\n                            <HexColorPicker\n                              color={brandColor}\n                              onChange={setBrandColor}\n                            />\n                          </PopoverContent>\n                        </Popover>\n                        <HexColorInput\n                          className=\"flex h-10 w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-400\"\n                          color={brandColor}\n                          onChange={setBrandColor}\n                          prefixed\n                        />\n                      </div>\n                    </div>\n                  </CardContent>\n                </Card>\n\n                {/* Background Color Card */}\n                <Card>\n                  <CardContent className=\"pt-6\">\n                    <div className=\"space-y-3\">\n                      <Label htmlFor=\"accent-color\">\n                        Background Color{\" \"}\n                        <span className=\"font-normal text-muted-foreground\">\n                          (front page)\n                        </span>\n                      </Label>\n                      <div className=\"flex items-center space-x-3\">\n                        <Popover>\n                          <PopoverTrigger>\n                            <div\n                              className=\"h-10 w-10 cursor-pointer rounded-md border-2 border-gray-300 shadow-sm transition-all hover:border-gray-400\"\n                              style={{ backgroundColor: accentColor }}\n                            />\n                          </PopoverTrigger>\n                          <PopoverContent>\n                            <HexColorPicker\n                              color={accentColor}\n                              onChange={setAccentColor}\n                            />\n                          </PopoverContent>\n                        </Popover>\n                        <HexColorInput\n                          className=\"flex h-10 w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-400\"\n                          color={accentColor}\n                          onChange={setAccentColor}\n                          prefixed\n                        />\n                      </div>\n                      <div className=\"flex flex-wrap gap-2\">\n                        <div\n                          className=\"relative h-10 w-10 cursor-pointer rounded-md bg-white shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                          onClick={() => setAccentColor(\"#ffffff\")}\n                        >\n                          {accentColor === \"#ffffff\" && (\n                            <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-gray-600\" />\n                          )}\n                        </div>\n                        <div\n                          className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-50 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                          onClick={() => setAccentColor(\"#f9fafb\")}\n                        >\n                          {accentColor === \"#f9fafb\" && (\n                            <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-gray-600\" />\n                          )}\n                        </div>\n                        <div\n                          className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-200 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                          onClick={() => setAccentColor(\"#e5e7eb\")}\n                        >\n                          {accentColor === \"#e5e7eb\" && (\n                            <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-gray-600\" />\n                          )}\n                        </div>\n                        <div\n                          className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-400 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                          onClick={() => setAccentColor(\"#9ca3af\")}\n                        >\n                          {accentColor === \"#9ca3af\" && (\n                            <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-white\" />\n                          )}\n                        </div>\n                        <div\n                          className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-800 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                          onClick={() => setAccentColor(\"#1f2937\")}\n                        >\n                          {accentColor === \"#1f2937\" && (\n                            <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-white\" />\n                          )}\n                        </div>\n                        <div\n                          className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-950 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                          onClick={() => setAccentColor(\"#030712\")}\n                        >\n                          {accentColor === \"#030712\" && (\n                            <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-white\" />\n                          )}\n                        </div>\n                      </div>\n                      {hasDataroomAccess && (\n                        <div className=\"rounded-md border border-border/70 p-3\">\n                          <div className=\"flex items-start space-x-3\">\n                            <Checkbox\n                              id=\"global-apply-accent-to-dataroom-view\"\n                              checked={applyAccentColorToDataroomView}\n                              onCheckedChange={(checked) =>\n                                setApplyAccentColorToDataroomView(\n                                  checked === true,\n                                )\n                              }\n                              className=\"mt-0.5\"\n                            />\n                            <div className=\"space-y-1\">\n                              <Label\n                                htmlFor=\"global-apply-accent-to-dataroom-view\"\n                                className=\"cursor-pointer text-sm font-medium\"\n                              >\n                                Apply background color to dataroom view by\n                                default\n                              </Label>\n                              <p className=\"text-xs text-muted-foreground\">\n                                Dataroom-specific branding can still override\n                                this.\n                              </p>\n                            </div>\n                          </div>\n                        </div>\n                      )}\n                    </div>\n                  </CardContent>\n                </Card>\n\n                {/* Welcome Message Card */}\n                <Card>\n                  <CardContent className=\"pt-6\">\n                    <div className=\"space-y-3\">\n                      <div className=\"flex items-center justify-between\">\n                        <Label htmlFor=\"accent-color\">\n                          Welcome Message{\" \"}\n                          <span className=\"font-normal text-muted-foreground\">\n                            (front page)\n                          </span>\n                        </Label>\n                        <span className=\"text-sm text-muted-foreground\">\n                          <span\n                            className={cn(\n                              welcomeMessageError && \"text-red-500\",\n                            )}\n                          >\n                            {welcomeMessage.length}\n                          </span>\n                          /{MAX_WELCOME_MESSAGE_LENGTH}\n                        </span>\n                      </div>\n                      <Textarea\n                        id=\"welcome-message\"\n                        value={welcomeMessage}\n                        onChange={(e) =>\n                          handleWelcomeMessageChange(e.target.value)\n                        }\n                        placeholder=\"Your action is requested to continue\"\n                        className={cn(\n                          \"min-h-24 resize-none\",\n                          welcomeMessageError &&\n                            \"border-red-500 focus:border-red-500 focus:ring-red-500\",\n                        )}\n                      />\n                      {welcomeMessageError && (\n                        <p className=\"text-xs text-red-500\">\n                          {welcomeMessageError}\n                        </p>\n                      )}\n                      <p className=\"text-xs text-muted-foreground\">\n                        Keep the message concise - it should fit within two\n                        lines for the best user experience.\n                      </p>\n                    </div>\n                  </CardContent>\n                </Card>\n              </div>\n\n              {/* Action Buttons - Always Visible */}\n              <div className=\"flex items-center gap-4 border-t bg-background pt-4\">\n                {plan === \"free\" && !isTrial ? (\n                  <UpgradeButton\n                    text=\"Save changes\"\n                    clickedPlan={PlanEnum.Pro}\n                    trigger=\"branding_page\"\n                    highlightItem={[\"custom-branding\"]}\n                  />\n                ) : (\n                  <Button\n                    onClick={saveBranding}\n                    loading={isLoading}\n                    disabled={!!welcomeMessageError}\n                    className=\"bg-black text-white hover:bg-gray-800\"\n                  >\n                    Save changes\n                  </Button>\n                )}\n                <Button\n                  variant=\"ghost\"\n                  onClick={handleDelete}\n                  disabled={!brand}\n                >\n                  Reset branding\n                </Button>\n              </div>\n            </div>\n\n            {/* Separator Line */}\n            <div className=\"hidden lg:block lg:w-px lg:self-stretch lg:bg-border\"></div>\n\n            {/* Preview Column */}\n            <div className=\"flex-1 lg:pl-4\">\n              <Tabs defaultValue=\"document-view\" className=\"w-full\">\n                <div className=\"w-full overflow-x-auto\">\n                  <TabsList\n                    className={cn(\n                      \"grid w-full\",\n                      hasDataroomAccess ? \"grid-cols-3\" : \"grid-cols-2\",\n                    )}\n                  >\n                    <TabsTrigger value=\"document-view\">\n                      Document View\n                    </TabsTrigger>\n                    {hasDataroomAccess && (\n                      <TabsTrigger value=\"dataroom-view\">\n                        Dataroom View\n                      </TabsTrigger>\n                    )}\n                    <TabsTrigger value=\"front-page\">Front Page</TabsTrigger>\n                  </TabsList>\n                </div>\n                <TabsContent value=\"document-view\" className=\"mt-6\">\n                  <div className=\"flex justify-center\">\n                    <div\n                      className=\"relative w-full max-w-[698px] rounded-lg bg-gray-200 p-1 shadow-lg\"\n                      style={{ height: \"450px\" }}\n                    >\n                      <div className=\"relative flex h-full flex-col overflow-hidden rounded-lg bg-gray-100\">\n                        <div className=\"mx-auto flex h-7 shrink-0 items-center justify-center\">\n                          <div className=\"pointer-events-none absolute left-3\">\n                            <div className=\"flex flex-row flex-nowrap justify-start\">\n                              <div className=\"pointer-events-auto\">\n                                <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                              </div>\n                              <div className=\"pointer-events-auto\">\n                                <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                              </div>\n                              <div className=\"pointer-events-auto\">\n                                <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                              </div>\n                            </div>\n                          </div>\n                          <div className=\"flex items-center justify-center rounded-xl bg-white p-1 px-2 opacity-70\">\n                            <div\n                              aria-hidden=\"true\"\n                              className=\"mr-1 mt-0.5 flex text-muted-foreground\"\n                            >\n                              <svg\n                                aria-hidden=\"true\"\n                                height=\"8\"\n                                width=\"8\"\n                                viewBox=\"0 0 16 16\"\n                                fill=\"currentColor\"\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                              >\n                                <path d=\"M8.75 11.25a1.25 1.25 0 1 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z\"></path>\n                                <path\n                                  fillRule=\"evenodd\"\n                                  clipRule=\"evenodd\"\n                                  d=\"M3.5 4v2h-1a1 1 0 0 0-1 1v6a3 3 0 0 0 3 3h7a3 3 0 0 0 3-3V7a1 1 0 0 0-1-1h-1V4a4 4 0 0 0-4-4h-1a4 4 0 0 0-4 4ZM11 6V4a2.5 2.5 0 0 0-2.5-2.5h-1A2.5 2.5 0 0 0 5 4v2h6Zm-8 7V7.5h10V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13Z\"\n                                ></path>\n                              </svg>\n                            </div>\n                            <span className=\"whitespace-normal text-xs text-muted-foreground\">\n                              papermark.com/view/...\n                            </span>\n                          </div>\n                        </div>\n                        <div className=\"relative min-h-0 flex-1 overflow-x-auto\">\n                          <div className=\"relative h-full max-w-[1396px]\">\n                            <iframe\n                              key={`document-view-${debouncedBrandColor}-${debouncedAccentColor}`}\n                              name=\"document-view\"\n                              id=\"document-view\"\n                              src={`/nav_ppreview_demo?brandColor=${encodeURIComponent(debouncedBrandColor)}&accentColor=${encodeURIComponent(debouncedAccentColor)}&brandLogo=${blobUrl ? encodeURIComponent(blobUrl) : logo ? encodeURIComponent(logo) : \"\"}`}\n                              className=\"absolute left-0 top-0 h-full w-full origin-top-left scale-50 overflow-hidden rounded-b-lg border-0 bg-white\"\n                              style={{\n                                width: \"200%\",\n                                height: \"200%\",\n                                pointerEvents: \"none\",\n                              }}\n                            ></iframe>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </TabsContent>\n                {hasDataroomAccess && (\n                  <TabsContent value=\"dataroom-view\" className=\"mt-6\">\n                    <div className=\"flex justify-center\">\n                      <div\n                        className=\"relative w-full max-w-[698px] rounded-lg bg-gray-200 p-1 shadow-lg\"\n                        style={{ height: \"450px\" }}\n                      >\n                        <div className=\"relative flex h-full flex-col overflow-hidden rounded-lg bg-gray-100\">\n                          <div className=\"mx-auto flex h-7 shrink-0 items-center justify-center\">\n                            <div className=\"pointer-events-none absolute left-3\">\n                              <div className=\"flex flex-row flex-nowrap justify-start\">\n                                <div className=\"pointer-events-auto\">\n                                  <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                                </div>\n                                <div className=\"pointer-events-auto\">\n                                  <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                                </div>\n                                <div className=\"pointer-events-auto\">\n                                  <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                                </div>\n                              </div>\n                            </div>\n                            <div className=\"flex items-center justify-center rounded-xl bg-white p-1 px-2 opacity-70\">\n                              <div\n                                aria-hidden=\"true\"\n                                className=\"mr-1 mt-0.5 flex text-muted-foreground\"\n                              >\n                                <svg\n                                  aria-hidden=\"true\"\n                                  height=\"8\"\n                                  width=\"8\"\n                                  viewBox=\"0 0 16 16\"\n                                  fill=\"currentColor\"\n                                  xmlns=\"http://www.w3.org/2000/svg\"\n                                >\n                                  <path d=\"M8.75 11.25a1.25 1.25 0 1 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z\"></path>\n                                  <path\n                                    fillRule=\"evenodd\"\n                                    clipRule=\"evenodd\"\n                                    d=\"M3.5 4v2h-1a1 1 0 0 0-1 1v6a3 3 0 0 0 3 3h7a3 3 0 0 0 3-3V7a1 1 0 0 0-1-1h-1V4a4 4 0 0 0-4-4h-1a4 4 0 0 0-4 4ZM11 6V4a2.5 2.5 0 0 0-2.5-2.5h-1A2.5 2.5 0 0 0 5 4v2h6Zm-8 7V7.5h10V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13Z\"\n                                  ></path>\n                                </svg>\n                              </div>\n                              <span className=\"whitespace-normal text-xs text-muted-foreground\">\n                                papermark.com/view/...\n                              </span>\n                            </div>\n                          </div>\n                          <div className=\"relative min-h-0 flex-1 overflow-x-auto\">\n                            <div className=\"relative h-full max-w-[1396px]\">\n                              <iframe\n                                key={`dataroom-view-${debouncedBrandColor}-${debouncedAccentColor}-${banner}-${applyAccentColorToDataroomView}`}\n                                name=\"dataroom-view\"\n                                id=\"dataroom-view\"\n                                src={`/room_ppreview_demo?brandColor=${encodeURIComponent(debouncedBrandColor)}&accentColor=${encodeURIComponent(debouncedAccentColor)}&applyAccentColorToDataroomView=${applyAccentColorToDataroomView ? \"1\" : \"0\"}&brandLogo=${blobUrl ? encodeURIComponent(blobUrl) : logo ? encodeURIComponent(logo) : \"\"}&brandBanner=${banner === \"no-banner\" ? encodeURIComponent(\"no-banner\") : bannerBlobUrl ? encodeURIComponent(bannerBlobUrl) : banner ? encodeURIComponent(banner) : \"\"}`}\n                                className=\"absolute left-0 top-0 h-full w-full origin-top-left scale-50 overflow-hidden rounded-b-lg border-0 bg-white\"\n                                style={{\n                                  width: \"200%\",\n                                  height: \"200%\",\n                                  pointerEvents: \"none\",\n                                }}\n                              />\n                            </div>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </TabsContent>\n                )}\n                <TabsContent value=\"front-page\" className=\"mt-6\">\n                  <div className=\"flex justify-center\">\n                    <div\n                      className=\"relative w-full max-w-[698px] rounded-lg bg-gray-200 p-1 shadow-lg\"\n                      style={{ height: \"450px\" }}\n                    >\n                      <div className=\"relative flex h-full flex-col overflow-hidden rounded-lg bg-gray-100\">\n                        <div className=\"mx-auto flex h-7 shrink-0 items-center justify-center\">\n                          <div className=\"pointer-events-none absolute left-3\">\n                            <div className=\"flex flex-row flex-nowrap justify-start\">\n                              <div className=\"pointer-events-auto\">\n                                <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                              </div>\n                              <div className=\"pointer-events-auto\">\n                                <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                              </div>\n                              <div className=\"pointer-events-auto\">\n                                <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                              </div>\n                            </div>\n                          </div>\n                          <div className=\"flex items-center justify-center rounded-xl bg-white p-1 px-2 opacity-70\">\n                            <div\n                              aria-hidden=\"true\"\n                              className=\"mr-1 mt-0.5 flex text-muted-foreground\"\n                            >\n                              <svg\n                                aria-hidden=\"true\"\n                                height=\"8\"\n                                width=\"8\"\n                                viewBox=\"0 0 16 16\"\n                                fill=\"currentColor\"\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                              >\n                                <path d=\"M8.75 11.25a1.25 1.25 0 1 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z\"></path>\n                                <path\n                                  fillRule=\"evenodd\"\n                                  clipRule=\"evenodd\"\n                                  d=\"M3.5 4v2h-1a1 1 0 0 0-1 1v6a3 3 0 0 0 3 3h7a3 3 0 0 0 3-3V7a1 1 0 0 0-1-1h-1V4a4 4 0 0 0-4-4h-1a4 4 0 0 0-4 4ZM11 6V4a2.5 2.5 0 0 0-2.5-2.5h-1A2.5 2.5 0 0 0 5 4v2h6Zm-8 7V7.5h10V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13Z\"\n                                ></path>\n                              </svg>\n                            </div>\n                            <span className=\"whitespace-normal text-xs text-muted-foreground\">\n                              papermark.com/view/...\n                            </span>\n                          </div>\n                        </div>\n                        <div className=\"relative min-h-0 flex-1 overflow-x-auto\">\n                          <div className=\"relative h-full max-w-[1396px]\">\n                            <iframe\n                              key={`access-screen-${debouncedBrandColor}-${debouncedAccentColor}-${debouncedWelcomeMessage}`}\n                              name=\"access-screen\"\n                              id=\"access-screen\"\n                              src={`/entrance_ppreview_demo?brandColor=${encodeURIComponent(debouncedBrandColor)}&accentColor=${encodeURIComponent(debouncedAccentColor)}&brandLogo=${blobUrl ? encodeURIComponent(blobUrl) : logo ? encodeURIComponent(logo) : \"\"}&welcomeMessage=${encodeURIComponent(debouncedWelcomeMessage)}`}\n                              className=\"absolute left-0 top-0 h-full w-full origin-top-left scale-50 overflow-hidden rounded-b-lg border-0 bg-white\"\n                              style={{\n                                width: \"200%\",\n                                height: \"200%\",\n                                pointerEvents: \"none\",\n                              }}\n                            ></iframe>\n                          </div>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </TabsContent>\n              </Tabs>\n\n              {/* Preview Mode Info */}\n              {/* <div className=\"mt-6 flex justify-center\">\n                <div className=\"w-full max-w-[698px] space-y-2 rounded-lg border-border bg-card p-4\">\n                  <h4 className=\"text-sm font-semibold text-foreground\">\n                    Preview Mode\n                  </h4>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Changes will be reflected in real-time as you adjust\n                    settings.\n                  </p>\n                </div>\n              </div> */}\n            </div>\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/dashboard.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useRef, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { addDays, format } from \"date-fns\";\nimport { BarChart3 } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { fetcher } from \"@/lib/utils\";\n\nimport { AnalyticsCard } from \"@/components/analytics/analytics-card\";\nimport DashboardViewsChart from \"@/components/analytics/dashboard-views-chart\";\nimport DocumentsTable from \"@/components/analytics/documents-table\";\nimport LinksTable from \"@/components/analytics/links-table\";\nimport {\n  TimeRange,\n  TimeRangeSelect,\n} from \"@/components/analytics/time-range-select\";\nimport ViewsTable from \"@/components/analytics/views-table\";\nimport VisitorsTable from \"@/components/analytics/visitors-table\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { TabMenu } from \"@/components/tab-menu\";\n\ninterface OverviewData {\n  counts: {\n    links: number;\n    documents: number;\n    visitors: number;\n    views: number;\n  };\n  graph: {\n    date: string;\n    views: number;\n  }[];\n}\nexport const defaultRange = {\n  start: addDays(new Date(), -7),\n  end: addDays(new Date(), 0),\n};\n\nexport default function DashboardPage() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { plan, trial } = usePlan();\n  const slug = useRef<boolean>(false);\n  const [customRange, setCustomRange] = useState<{\n    start: Date;\n    end: Date;\n  }>(defaultRange);\n\n  // Check if user has access to data beyond 30 days\n  const isPremium = plan !== \"free\" || !!trial;\n\n  const {\n    interval = \"7d\",\n    type = \"links\",\n    start,\n    end,\n  } = router.query as {\n    interval: TimeRange;\n    type: string;\n    start: string;\n    end: string;\n  };\n\n  const {\n    data: overview,\n    isLoading,\n    error,\n  } = useSWR<OverviewData>(\n    teamInfo?.currentTeam?.id\n      ? `/api/analytics?type=overview&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === \"custom\" ? `&startDate=${format(customRange.start, \"MM-dd-yyyy\")}&endDate=${format(customRange.end, \"MM-dd-yyyy\")}` : \"\"}`\n      : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n      revalidateOnFocus: false,\n    },\n  );\n\n  if (error && !slug.current) {\n    const errorObj = JSON.parse(error.message);\n    const errorMessage = errorObj?.error;\n    toast.info(errorMessage);\n    setCustomRange(defaultRange);\n    slug.current = true;\n  }\n\n  // Update the URL when time range changes\n  const handleTimeRangeChange = (newTimeRange: TimeRange) => {\n    const params = new URLSearchParams(window.location.search);\n    params.set(\"interval\", newTimeRange);\n    if (type) {\n      params.set(\"type\", type);\n    }\n    // Only remove date params when switching to preset ranges\n    if (newTimeRange !== \"custom\") {\n      params.delete(\"start\");\n      params.delete(\"end\");\n    }\n    router.push(`/dashboard?${params.toString()}`, undefined, {\n      shallow: true,\n    });\n  };\n\n  // Handle custom range URL updates\n  const handleCustomRangeComplete = (range: { start: Date; end: Date }) => {\n    const params = new URLSearchParams(window.location.search);\n    params.set(\"interval\", \"custom\");\n    params.set(\"start\", range.start.toISOString());\n    params.set(\"end\", range.end.toISOString());\n    if (type) {\n      params.set(\"type\", type);\n    }\n    router.push(`/dashboard?${params.toString()}`, undefined, {\n      shallow: true,\n    });\n  };\n\n  return (\n    <AppLayout>\n      <div className=\"flex-1 space-y-4 p-4 pt-6 md:p-8\">\n        <div className=\"flex items-center justify-between\">\n          <h2 className=\"text-3xl font-bold tracking-tight\">Dashboard</h2>\n          <TimeRangeSelect\n            value={interval}\n            onChange={handleTimeRangeChange}\n            customRange={customRange}\n            setCustomRange={setCustomRange}\n            onCustomRangeComplete={handleCustomRangeComplete}\n            slug={slug}\n            isPremium={isPremium}\n          />\n        </div>\n\n        <div className=\"space-y-4\">\n          <AnalyticsCard\n            title=\"Views Overview\"\n            icon={<BarChart3 className=\"h-4 w-4\" />}\n            contentClassName=\"space-y-4\"\n          >\n            <DashboardViewsChart\n              timeRange={interval}\n              data={overview?.graph}\n              startDate={customRange.start}\n              endDate={customRange.end}\n            />\n          </AnalyticsCard>\n\n          <TabMenu\n            navigation={[\n              {\n                label: \"Links\",\n                href: `/dashboard?interval=${interval}&type=links`,\n                value: \"links\",\n                currentValue: type,\n                count: overview?.counts.links,\n              },\n              {\n                label: \"Documents\",\n                href: `/dashboard?interval=${interval}&type=documents`,\n                value: \"documents\",\n                currentValue: type,\n                count: overview?.counts.documents,\n              },\n              {\n                label: \"Visitors\",\n                href: `/dashboard?interval=${interval}&type=visitors`,\n                value: \"visitors\",\n                currentValue: type,\n                count: overview?.counts.visitors,\n              },\n              {\n                label: \"Recent Views\",\n                href: `/dashboard?interval=${interval}&type=views`,\n                value: \"views\",\n                currentValue: type,\n                count: overview?.counts.views,\n              },\n            ]}\n            className=\"z-10\"\n          />\n\n          <div className=\"grid grid-cols-1\">\n            {type === \"links\" && (\n              <LinksTable\n                startDate={customRange.start}\n                endDate={customRange.end}\n              />\n            )}\n            {type === \"documents\" && (\n              <DocumentsTable\n                startDate={customRange.start}\n                endDate={customRange.end}\n              />\n            )}\n            {type === \"visitors\" && (\n              <VisitorsTable\n                startDate={customRange.start}\n                endDate={customRange.end}\n              />\n            )}\n            {type === \"views\" && (\n              <ViewsTable\n                startDate={customRange.start}\n                endDate={customRange.end}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/analytics/index.tsx",
    "content": "import { useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { ChartNoAxesColumnIcon, LogsIcon } from \"lucide-react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\n\nimport DataroomAnalyticsOverview from \"@/components/datarooms/analytics/analytics-overview\";\nimport DocumentAnalyticsTree from \"@/components/datarooms/analytics/document-analytics-tree\";\nimport MockAnalyticsTable from \"@/components/datarooms/analytics/mock-analytics-table\";\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport StatsCard from \"@/components/datarooms/stats-card\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { FeaturePreview } from \"@/components/ui/feature-preview\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport DataroomVisitorsTable from \"@/components/visitors/dataroom-visitors-table\";\n\nexport default function DataroomAnalyticsPage() {\n  const { dataroom } = useDataroom();\n  const { isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n\n  // State for the selected document\n  const [selectedDocument, setSelectedDocument] = useState<{\n    id: string;\n    name: string;\n  } | null>(null);\n\n  // Determine default tab based on plan\n  const defaultTab =\n    isTrial || isDatarooms || isDataroomsPlus ? \"analytics\" : \"audit-log\";\n  // Check if user has access to analytics features\n  const hasAnalyticsAccess = isDatarooms || isDataroomsPlus || isTrial;\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  const AnalyticsContent = () => (\n    <>\n      <DataroomAnalyticsOverview\n        selectedDocument={selectedDocument}\n        setSelectedDocument={setSelectedDocument}\n      />\n      <div>\n        <h3 className=\"mb-4 text-lg font-medium\">\n          Dataroom Analytics{\" \"}\n          {selectedDocument &&\n            `- Showing detailed metrics for \"${selectedDocument.name}\"`}\n        </h3>\n        <DocumentAnalyticsTree\n          dataroomId={dataroom.id}\n          selectedDocument={selectedDocument}\n          setSelectedDocument={setSelectedDocument}\n        />\n      </div>\n    </>\n  );\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <div className=\"space-y-8\">\n          <StatsCard />\n\n          <Tabs defaultValue={defaultTab} className=\"space-y-6\">\n            <TabsList>\n              <TabsTrigger\n                value=\"analytics\"\n                className=\"flex items-center gap-2\"\n              >\n                <ChartNoAxesColumnIcon className=\"h-4 w-4\" />\n                Analytics\n              </TabsTrigger>\n              <TabsTrigger\n                value=\"audit-log\"\n                className=\"flex items-center gap-2\"\n              >\n                <LogsIcon className=\"h-4 w-4\" />\n                Audit Log\n              </TabsTrigger>\n            </TabsList>\n\n            <TabsContent value=\"analytics\" className=\"space-y-6\">\n              {hasAnalyticsAccess ? (\n                <AnalyticsContent />\n              ) : (\n                <FeaturePreview\n                  title=\"Advanced Dataroom Analytics\"\n                  description=\"Get detailed insights into document engagement, completion rates, and visitor behavior patterns across your dataroom.\"\n                  requiredPlan={PlanEnum.DataRooms}\n                  trigger=\"dataroom_analytics_tab\"\n                  upgradeButtonText=\"Data Rooms\"\n                >\n                  <MockAnalyticsTable />\n                </FeaturePreview>\n              )}\n            </TabsContent>\n\n            <TabsContent value=\"audit-log\">\n              <DataroomVisitorsTable dataroomId={dataroom.id} />\n            </TabsContent>\n          </Tabs>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/branding/index.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { Check, CircleHelpIcon, UploadIcon } from \"lucide-react\";\nimport { HexColorInput, HexColorPicker } from \"react-colorful\";\nimport sanitizeHtml from \"sanitize-html\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\nimport { useDebounce } from \"use-debounce\";\n\nimport { useBrand, useDataroomBrand } from \"@/lib/swr/use-brand\";\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { cn, convertDataUrlToFile, uploadImage } from \"@/lib/utils\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nconst DEFAULT_BANNER_IMAGE = \"/_static/papermark-banner.png\";\n\nexport default function DataroomBrandPage() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { dataroom } = useDataroom();\n  const { brand: dataroomBrand } = useDataroomBrand({\n    dataroomId: dataroom?.id,\n  });\n  const { brand: globalBrand } = useBrand();\n\n  const [brandColor, setBrandColor] = useState<string>(\"#000000\");\n  const [accentColor, setAccentColor] = useState<string>(\"#FFFFFF\");\n  const [applyAccentColorToDataroomView, setApplyAccentColorToDataroomView] =\n    useState<boolean>(false);\n  const [logo, setLogo] = useState<string | null>(null);\n  const [banner, setBanner] = useState<string | null>(null);\n  const [originalBanner, setOriginalBanner] = useState<string | null>(null);\n  const [blobUrl, setBlobUrl] = useState<string | null>(null);\n  const [bannerBlobUrl, setBannerBlobUrl] = useState<string | null>(null);\n  const [welcomeMessage, setWelcomeMessage] = useState<string>(\n    \"Your action is requested to continue\",\n  );\n  const [debouncedBrandColor] = useDebounce(brandColor, 300);\n  const [debouncedAccentColor] = useDebounce(accentColor, 300);\n  const [debouncedWelcomeMessage] = useDebounce(welcomeMessage, 500);\n  const [isLoading, setIsLoading] = useState<boolean>(false);\n  const [fileError, setFileError] = useState<string | null>(null);\n  const [dragActive, setDragActive] = useState(false);\n  const [welcomeMessageError, setWelcomeMessageError] = useState<string | null>(\n    null,\n  );\n\n  // Welcome message validation\n  const MAX_WELCOME_MESSAGE_LENGTH = 80; // Roughly 2 lines of text\n\n  const validateWelcomeMessage = (message: string): string | null => {\n    if (!message.trim()) {\n      return \"Welcome message cannot be empty\";\n    }\n\n    // Strip HTML tags and validate plain text only\n    const sanitized = sanitizeHtml(message, {\n      allowedTags: [],\n      allowedAttributes: {},\n    });\n\n    if (sanitized !== message) {\n      return \"Welcome message must contain only plain text\";\n    }\n\n    if (sanitized.length > MAX_WELCOME_MESSAGE_LENGTH) {\n      return `Welcome message must be ${MAX_WELCOME_MESSAGE_LENGTH} characters or less (currently ${sanitized.length})`;\n    }\n\n    return null;\n  };\n\n  const onChangeLogo = useCallback(\n    (e: any) => {\n      setFileError(null);\n      const file = e.target.files[0];\n      if (file) {\n        if (file.size / 1024 / 1024 > 2) {\n          setFileError(\"File size too big (max 2MB)\");\n        } else if (file.type !== \"image/png\" && file.type !== \"image/jpeg\") {\n          setFileError(\"File type not supported (.png or .jpg only)\");\n        } else {\n          const reader = new FileReader();\n          reader.onload = (e) => {\n            const dataUrl = e.target?.result as string;\n            setLogo(dataUrl);\n            // create a blob url for preview\n            const blob = convertDataUrlToFile({ dataUrl });\n            const blobUrl = URL.createObjectURL(blob);\n            setBlobUrl(blobUrl);\n          };\n          reader.readAsDataURL(file);\n        }\n      }\n    },\n    [setLogo],\n  );\n\n  const onChangeBanner = useCallback(\n    (e: any) => {\n      setFileError(null);\n      const file = e.target.files[0];\n      if (file) {\n        if (file.size / 1024 / 1024 > 5) {\n          setFileError(\"File size too big (max 5MB)\");\n        } else if (file.type !== \"image/png\" && file.type !== \"image/jpeg\") {\n          setFileError(\"File type not supported (.png or .jpg only)\");\n        } else {\n          const reader = new FileReader();\n          reader.onload = (e) => {\n            const dataUrl = e.target?.result as string;\n            setBanner(dataUrl);\n            // When uploading a new image, this becomes the new \"original\" until saved\n            setOriginalBanner(dataUrl);\n            // create a blob url for preview\n            const blob = convertDataUrlToFile({ dataUrl });\n            const bannerBlobUrl = URL.createObjectURL(blob);\n            setBannerBlobUrl(bannerBlobUrl);\n          };\n          reader.readAsDataURL(file);\n        }\n      }\n    },\n    [setBanner],\n  );\n\n  useEffect(() => {\n    // Merge dataroom brand with global brand as fallback\n    if (dataroomBrand || globalBrand) {\n      setBrandColor(\n        dataroomBrand?.brandColor || globalBrand?.brandColor || \"#000000\",\n      );\n      setAccentColor(\n        dataroomBrand?.accentColor || globalBrand?.accentColor || \"#FFFFFF\",\n      );\n      setApplyAccentColorToDataroomView(\n        (dataroomBrand as any)?.applyAccentColorToDataroomView ??\n          (globalBrand as any)?.applyAccentColorToDataroomView ??\n          false,\n      );\n      setLogo(dataroomBrand?.logo || globalBrand?.logo || null);\n      const bannerValue = dataroomBrand?.banner || globalBrand?.banner || null;\n      setBanner(bannerValue);\n      setOriginalBanner(bannerValue);\n      const message =\n        dataroomBrand?.welcomeMessage ||\n        globalBrand?.welcomeMessage ||\n        \"Your action is requested to continue\";\n      setWelcomeMessage(message);\n      // Validate existing message\n      const error = validateWelcomeMessage(message);\n      setWelcomeMessageError(error);\n    }\n  }, [dataroomBrand, globalBrand]);\n\n  // Handle welcome message change with validation\n  const handleWelcomeMessageChange = (value: string) => {\n    setWelcomeMessage(value);\n    const error = validateWelcomeMessage(value);\n    setWelcomeMessageError(error);\n  };\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  const saveBranding = async (e: any) => {\n    e.preventDefault();\n\n    // Validate welcome message before saving\n    const welcomeError = validateWelcomeMessage(welcomeMessage);\n    if (welcomeError) {\n      setWelcomeMessageError(welcomeError);\n      toast.error(\"Please fix the validation errors before saving\");\n      return;\n    }\n\n    setIsLoading(true);\n\n    // Upload the image if it's a data URL\n    let blobUrl: string | null = logo && logo.startsWith(\"data:\") ? null : logo;\n    if (logo && logo.startsWith(\"data:\")) {\n      // Convert the data URL to a blob\n      const blob = convertDataUrlToFile({ dataUrl: logo });\n      // Upload the blob to vercel storage\n      blobUrl = await uploadImage(blob);\n      setLogo(blobUrl);\n    }\n\n    let bannerBlobUrl: string | null =\n      banner && banner.startsWith(\"data:\") ? null : banner;\n    // Don't upload if banner is set to hide\n    if (banner && banner.startsWith(\"data:\")) {\n      // Convert the data URL to a blob\n      const blob = convertDataUrlToFile({ dataUrl: banner });\n      // Upload the blob to vercel storage\n      bannerBlobUrl = await uploadImage(blob);\n      setBanner(bannerBlobUrl);\n    } else if (banner === \"no-banner\") {\n      // Use the special value to hide the banner\n      bannerBlobUrl = \"no-banner\";\n    }\n\n    const data = {\n      welcomeMessage:\n        welcomeMessage.trim() || \"Your action is requested to continue\",\n      brandColor: brandColor,\n      accentColor: accentColor,\n      applyAccentColorToDataroomView,\n      logo: blobUrl,\n      banner: bannerBlobUrl,\n    };\n\n    const res = await fetch(\n      `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroom.id}/branding`,\n      {\n        method: dataroomBrand ? \"PUT\" : \"POST\",\n        body: JSON.stringify(data),\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n    if (res.ok) {\n      mutate(\n        `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroom.id}/branding`,\n      );\n      // Update the original banner state to the new saved value\n      setOriginalBanner(data.banner);\n      setIsLoading(false);\n      toast.success(\"Branding updated successfully\");\n    }\n  };\n\n  const handleDelete = async () => {\n    setIsLoading(true);\n\n    const res = await fetch(\n      `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroom.id}/branding`,\n      {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n      },\n    );\n    if (res.ok) {\n      setLogo(null);\n      setBanner(DEFAULT_BANNER_IMAGE);\n      setOriginalBanner(DEFAULT_BANNER_IMAGE);\n      setBrandColor(\"#000000\");\n      setApplyAccentColorToDataroomView(\n        (globalBrand as any)?.applyAccentColorToDataroomView ?? false,\n      );\n      setIsLoading(false);\n      toast.success(\"Branding reset successfully\");\n      router.reload();\n    }\n  };\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n          <div className=\"space-y-1\">\n            <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n              Dataroom Branding\n            </h3>\n            <p className=\"flex flex-row items-center gap-2 text-sm text-muted-foreground\">\n              Customize your data room&apos;s branding for a cohesive user\n              experience.\n              <BadgeTooltip\n                linkText=\"Click here\"\n                content=\"How to customize data room branding?\"\n                key=\"branding\"\n                link=\"https://www.papermark.com/help/article/dataroom-branding\"\n              >\n                <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n              </BadgeTooltip>\n            </p>\n          </div>\n        </div>\n\n        {/* Main Layout */}\n        <div className=\"flex w-full flex-col gap-6 lg:flex-row lg:gap-8\">\n          {/* Settings Column */}\n          <div className=\"flex w-full flex-col gap-6 lg:w-[420px] lg:shrink-0\">\n            {/* Scrollable Settings */}\n            <div className=\"flex flex-col gap-6 lg:max-h-[calc(100vh-400px)] lg:overflow-y-auto lg:pr-4\">\n              {/* Logo Card */}\n              <Card>\n                <CardContent className=\"pt-6\">\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"image\">\n                      Logo{\" \"}\n                      <span className=\"font-normal text-muted-foreground\">\n                        (max 2 MB)\n                      </span>\n                    </Label>\n                    <label\n                      htmlFor=\"image\"\n                      className=\"group relative mt-2 flex h-20 w-48 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 transition-all hover:border-gray-400 hover:bg-gray-100\"\n                    >\n                      <div\n                        className=\"absolute z-[5] h-full w-full rounded-lg\"\n                        onDragOver={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(true);\n                        }}\n                        onDragEnter={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(true);\n                        }}\n                        onDragLeave={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(false);\n                        }}\n                        onDrop={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(false);\n                          setFileError(null);\n                          const file =\n                            e.dataTransfer.files && e.dataTransfer.files[0];\n                          if (file) {\n                            if (file.size / 1024 / 1024 > 2) {\n                              setFileError(\"File size too big (max 2MB)\");\n                            } else if (\n                              file.type !== \"image/png\" &&\n                              file.type !== \"image/jpeg\"\n                            ) {\n                              setFileError(\n                                \"File type not supported (.png or .jpg only)\",\n                              );\n                            } else {\n                              const reader = new FileReader();\n                              reader.onload = (e) => {\n                                const dataUrl = e.target?.result as string;\n                                setLogo(dataUrl);\n                                const blob = convertDataUrlToFile({\n                                  dataUrl,\n                                });\n                                const blobUrl = URL.createObjectURL(blob);\n                                setBlobUrl(blobUrl);\n                              };\n                              reader.readAsDataURL(file);\n                            }\n                          }\n                        }}\n                      />\n                      {!logo ? (\n                        <div\n                          className={cn(\n                            \"flex flex-col items-center justify-center gap-2\",\n                            dragActive && \"scale-105\",\n                          )}\n                        >\n                          <UploadIcon\n                            className=\"h-8 w-8 text-gray-400\"\n                            aria-hidden=\"true\"\n                          />\n                        </div>\n                      ) : (\n                        <div className=\"relative flex h-full w-full items-center justify-center p-4\">\n                          <img\n                            src={logo}\n                            alt=\"Logo preview\"\n                            className=\"max-h-full max-w-full object-contain\"\n                          />\n                        </div>\n                      )}\n                    </label>\n                    <input\n                      id=\"image\"\n                      name=\"image\"\n                      type=\"file\"\n                      accept=\"image/jpeg,image/png\"\n                      className=\"sr-only\"\n                      onChange={onChangeLogo}\n                    />\n                    {fileError && (\n                      <p className=\"text-sm text-red-500\">{fileError}</p>\n                    )}\n                  </div>\n                </CardContent>\n              </Card>\n\n              {/* Banner Card */}\n              <Card>\n                <CardContent className=\"pt-6\">\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"banner\">\n                      Banner{\" \"}\n                      <span className=\"font-normal text-muted-foreground\">\n                        (max 5 MB, min. 1920×320)\n                      </span>\n                    </Label>\n                    <label\n                      htmlFor=\"banner\"\n                      className=\"group relative mt-2 flex h-20 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 transition-all hover:border-gray-400 hover:bg-gray-100\"\n                    >\n                      <div\n                        className=\"absolute z-[5] h-full w-full rounded-lg\"\n                        onDragOver={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(true);\n                        }}\n                        onDragEnter={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(true);\n                        }}\n                        onDragLeave={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(false);\n                        }}\n                        onDrop={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          setDragActive(false);\n                          setFileError(null);\n                          const file =\n                            e.dataTransfer.files && e.dataTransfer.files[0];\n                          if (file) {\n                            if (file.size / 1024 / 1024 > 5) {\n                              setFileError(\"File size too big (max 5MB)\");\n                            } else if (\n                              file.type !== \"image/png\" &&\n                              file.type !== \"image/jpeg\"\n                            ) {\n                              setFileError(\n                                \"File type not supported (.png or .jpg only)\",\n                              );\n                            } else {\n                              const reader = new FileReader();\n                              reader.onload = (e) => {\n                                const dataUrl = e.target?.result as string;\n                                setBanner(dataUrl);\n                                // When uploading a new image, this becomes the new \"original\" until saved\n                                setOriginalBanner(dataUrl);\n                                const blob = convertDataUrlToFile({\n                                  dataUrl,\n                                });\n                                const bannerBlobUrl = URL.createObjectURL(blob);\n                                setBannerBlobUrl(bannerBlobUrl);\n                              };\n                              reader.readAsDataURL(file);\n                            }\n                          }\n                        }}\n                      />\n                      {!banner || banner === DEFAULT_BANNER_IMAGE ? (\n                        <div\n                          className={cn(\n                            \"flex flex-col items-center justify-center gap-2\",\n                            dragActive && \"scale-105\",\n                          )}\n                        >\n                          <UploadIcon\n                            className=\"h-8 w-8 text-gray-400\"\n                            aria-hidden=\"true\"\n                          />\n                        </div>\n                      ) : banner === \"no-banner\" ? (\n                        <div className=\"flex flex-col items-center justify-center gap-2\">\n                          <p className=\"text-center text-sm font-medium text-gray-600\">\n                            Banner Hidden <br />\n                            Upload to add banner\n                          </p>\n                        </div>\n                      ) : (\n                        <div className=\"relative flex h-full w-full items-center justify-center p-4\">\n                          <img\n                            src={banner}\n                            alt=\"Banner preview\"\n                            className=\"max-h-full max-w-full object-contain\"\n                          />\n                        </div>\n                      )}\n                    </label>\n                    <input\n                      id=\"banner\"\n                      name=\"banner\"\n                      type=\"file\"\n                      accept=\"image/jpeg,image/png\"\n                      className=\"sr-only\"\n                      onChange={onChangeBanner}\n                    />\n                    <div className=\"flex items-center gap-2\">\n                      <Button\n                        type=\"button\"\n                        variant=\"outline\"\n                        size=\"sm\"\n                        onClick={() => setBanner(\"no-banner\")}\n                        className={cn(\n                          \"text-xs\",\n                          banner === \"no-banner\" && \"border-black\",\n                        )}\n                      >\n                        Hide Banner\n                      </Button>\n                      {(banner === \"no-banner\" &&\n                        originalBanner !== \"no-banner\") ||\n                      (banner &&\n                        banner !== DEFAULT_BANNER_IMAGE &&\n                        !banner.startsWith(\"data:\") &&\n                        banner !== originalBanner) ? (\n                        <Button\n                          type=\"button\"\n                          variant=\"outline\"\n                          size=\"sm\"\n                          onClick={() => setBanner(originalBanner)}\n                          className=\"text-xs\"\n                        >\n                          {originalBanner === DEFAULT_BANNER_IMAGE\n                            ? \"Use Default Banner\"\n                            : \"Restore Banner\"}\n                        </Button>\n                      ) : null}\n                    </div>\n                    {fileError && (\n                      <p className=\"text-sm text-red-500\">{fileError}</p>\n                    )}\n                  </div>\n                </CardContent>\n              </Card>\n\n              {/* Brand Color Card */}\n              <Card>\n                <CardContent className=\"pt-6\">\n                  <div className=\"space-y-3\">\n                    <Label htmlFor=\"primary-color\">Brand Color</Label>\n                    <div className=\"flex items-center space-x-3\">\n                      <Popover>\n                        <PopoverTrigger>\n                          <div\n                            className=\"h-10 w-10 cursor-pointer rounded-md border-2 border-gray-300 shadow-sm transition-all hover:border-gray-400\"\n                            style={{ backgroundColor: brandColor }}\n                          />\n                        </PopoverTrigger>\n                        <PopoverContent>\n                          <HexColorPicker\n                            color={brandColor}\n                            onChange={setBrandColor}\n                          />\n                        </PopoverContent>\n                      </Popover>\n                      <HexColorInput\n                        className=\"flex h-10 w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-400\"\n                        color={brandColor}\n                        onChange={setBrandColor}\n                        prefixed\n                      />\n                    </div>\n                  </div>\n                </CardContent>\n              </Card>\n\n              {/* Background Color Card */}\n              <Card>\n                <CardContent className=\"pt-6\">\n                  <div className=\"space-y-3\">\n                    <Label htmlFor=\"accent-color\">\n                      Background Color{\" \"}\n                      <span className=\"font-normal text-muted-foreground\">\n                        (front page &amp; document view)\n                      </span>\n                    </Label>\n                    <div className=\"flex items-center space-x-3\">\n                      <Popover>\n                        <PopoverTrigger>\n                          <div\n                            className=\"h-10 w-10 cursor-pointer rounded-md border-2 border-gray-300 shadow-sm transition-all hover:border-gray-400\"\n                            style={{ backgroundColor: accentColor }}\n                          />\n                        </PopoverTrigger>\n                        <PopoverContent>\n                          <HexColorPicker\n                            color={accentColor}\n                            onChange={setAccentColor}\n                          />\n                        </PopoverContent>\n                      </Popover>\n                      <HexColorInput\n                        className=\"flex h-10 w-full rounded-md border border-gray-300 bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:border-gray-400 focus:outline-none focus:ring-1 focus:ring-gray-400\"\n                        color={accentColor}\n                        onChange={setAccentColor}\n                        prefixed\n                      />\n                    </div>\n                    <div className=\"flex flex-wrap gap-2\">\n                      <div\n                        className=\"relative h-10 w-10 cursor-pointer rounded-md bg-white shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                        onClick={() => setAccentColor(\"#ffffff\")}\n                      >\n                        {accentColor === \"#ffffff\" && (\n                          <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-gray-600\" />\n                        )}\n                      </div>\n                      <div\n                        className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-50 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                        onClick={() => setAccentColor(\"#f9fafb\")}\n                      >\n                        {accentColor === \"#f9fafb\" && (\n                          <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-gray-600\" />\n                        )}\n                      </div>\n                      <div\n                        className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-200 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                        onClick={() => setAccentColor(\"#e5e7eb\")}\n                      >\n                        {accentColor === \"#e5e7eb\" && (\n                          <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-gray-600\" />\n                        )}\n                      </div>\n                      <div\n                        className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-400 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                        onClick={() => setAccentColor(\"#9ca3af\")}\n                      >\n                        {accentColor === \"#9ca3af\" && (\n                          <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-white\" />\n                        )}\n                      </div>\n                      <div\n                        className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-800 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                        onClick={() => setAccentColor(\"#1f2937\")}\n                      >\n                        {accentColor === \"#1f2937\" && (\n                          <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-white\" />\n                        )}\n                      </div>\n                      <div\n                        className=\"relative h-10 w-10 cursor-pointer rounded-md bg-gray-950 shadow-sm ring-2 ring-gray-300 transition-all hover:ring-gray-400\"\n                        onClick={() => setAccentColor(\"#030712\")}\n                      >\n                        {accentColor === \"#030712\" && (\n                          <Check className=\"absolute left-1/2 top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 text-white\" />\n                        )}\n                      </div>\n                    </div>\n                    <div className=\"flex items-start space-x-2 pt-2\">\n                      <Checkbox\n                        id=\"apply-accent-to-dataroom-view\"\n                        checked={applyAccentColorToDataroomView}\n                        onCheckedChange={(checked) =>\n                          setApplyAccentColorToDataroomView(checked === true)\n                        }\n                      />\n                      <div className=\"space-y-1\">\n                        <Label\n                          htmlFor=\"apply-accent-to-dataroom-view\"\n                          className=\"cursor-pointer\"\n                        >\n                          Also apply this background color to dataroom view\n                        </Label>\n                        <p className=\"text-xs text-muted-foreground\">\n                          When disabled, dataroom view stays white.\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                </CardContent>\n              </Card>\n\n              {/* Welcome Message Card */}\n              <Card>\n                <CardContent className=\"pt-6\">\n                  <div className=\"space-y-3\">\n                    <div className=\"flex items-center justify-between\">\n                      <Label htmlFor=\"accent-color\">\n                        Welcome Message{\" \"}\n                        <span className=\"font-normal text-muted-foreground\">\n                          (front page)\n                        </span>\n                      </Label>\n                      <span className=\"text-sm text-muted-foreground\">\n                        <span\n                          className={cn(welcomeMessageError && \"text-red-500\")}\n                        >\n                          {welcomeMessage.length}\n                        </span>\n                        /{MAX_WELCOME_MESSAGE_LENGTH}\n                      </span>\n                    </div>\n                    <Textarea\n                      id=\"welcome-message\"\n                      value={welcomeMessage}\n                      onChange={(e) =>\n                        handleWelcomeMessageChange(e.target.value)\n                      }\n                      placeholder=\"Your action is requested to continue\"\n                      className={cn(\n                        \"min-h-24 resize-none\",\n                        welcomeMessageError &&\n                          \"border-red-500 focus:border-red-500 focus:ring-red-500\",\n                      )}\n                    />\n                    {welcomeMessageError && (\n                      <p className=\"text-xs text-red-500\">\n                        {welcomeMessageError}\n                      </p>\n                    )}\n                    <p className=\"text-xs text-muted-foreground\">\n                      Keep the message concise - it should fit within two lines\n                      for the best user experience.\n                    </p>\n                  </div>\n                </CardContent>\n              </Card>\n            </div>\n\n            {/* Action Buttons - Always Visible */}\n            <div className=\"flex items-center gap-4 border-t bg-background pt-4\">\n              <Button\n                onClick={saveBranding}\n                loading={isLoading}\n                disabled={!!welcomeMessageError}\n                className=\"bg-black text-white hover:bg-gray-800\"\n              >\n                Save changes\n              </Button>\n              <Button\n                variant=\"ghost\"\n                onClick={handleDelete}\n                disabled={!dataroomBrand}\n              >\n                Reset branding\n              </Button>\n            </div>\n          </div>\n\n          {/* Separator Line */}\n          <div className=\"hidden lg:block lg:w-px lg:self-stretch lg:bg-border\"></div>\n\n          {/* Preview Column */}\n          <div className=\"flex-1 lg:pl-4\">\n            <Tabs defaultValue=\"dataroom-view\" className=\"w-full\">\n              <div className=\"w-full overflow-x-auto\">\n                <TabsList className=\"grid w-full grid-cols-3\">\n                  <TabsTrigger value=\"dataroom-view\">Dataroom View</TabsTrigger>\n                  <TabsTrigger value=\"document-view\">Document View</TabsTrigger>\n                  <TabsTrigger value=\"access-view\">Front Page</TabsTrigger>\n                </TabsList>\n              </div>\n              {/* Dataroom View */}\n              <TabsContent value=\"dataroom-view\" className=\"mt-6\">\n                <div className=\"flex justify-center\">\n                  <div\n                    className=\"relative w-full max-w-[698px] rounded-lg bg-gray-200 p-1 shadow-lg\"\n                    style={{ height: \"450px\" }}\n                  >\n                    <div className=\"relative flex h-full flex-col overflow-hidden rounded-lg bg-gray-100\">\n                      <div className=\"mx-auto flex h-7 shrink-0 items-center justify-center\">\n                        <div className=\"pointer-events-none absolute left-3\">\n                          <div className=\"flex flex-row flex-nowrap justify-start\">\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                          </div>\n                        </div>\n                        <div className=\"flex items-center justify-center rounded-xl bg-white p-1 px-2 opacity-70\">\n                          <div\n                            aria-hidden=\"true\"\n                            className=\"mr-1 mt-0.5 flex text-muted-foreground\"\n                          >\n                            <svg\n                              aria-hidden=\"true\"\n                              height=\"8\"\n                              width=\"8\"\n                              viewBox=\"0 0 16 16\"\n                              fill=\"currentColor\"\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                            >\n                              <path d=\"M8.75 11.25a1.25 1.25 0 1 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z\"></path>\n                              <path\n                                fillRule=\"evenodd\"\n                                clipRule=\"evenodd\"\n                                d=\"M3.5 4v2h-1a1 1 0 0 0-1 1v6a3 3 0 0 0 3 3h7a3 3 0 0 0 3-3V7a1 1 0 0 0-1-1h-1V4a4 4 0 0 0-4-4h-1a4 4 0 0 0-4 4ZM11 6V4a2.5 2.5 0 0 0-2.5-2.5h-1A2.5 2.5 0 0 0 5 4v2h6Zm-8 7V7.5h10V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13Z\"\n                              ></path>\n                            </svg>\n                          </div>\n                          <span className=\"whitespace-normal text-xs text-muted-foreground\">\n                            papermark.com/dataroom/...\n                          </span>\n                        </div>\n                      </div>\n                      <div className=\"relative min-h-0 flex-1 overflow-x-auto\">\n                        <div className=\"relative h-full max-w-[1396px]\">\n                          <iframe\n                            key={`dataroom-view-${debouncedBrandColor}-${debouncedAccentColor}-${banner}`}\n                            name=\"dataroom-view\"\n                            id=\"dataroom-view\"\n                            src={`/room_ppreview_demo?brandColor=${encodeURIComponent(debouncedBrandColor)}&accentColor=${encodeURIComponent(debouncedAccentColor)}&applyAccentColorToDataroomView=${applyAccentColorToDataroomView ? \"1\" : \"0\"}&brandLogo=${blobUrl ? encodeURIComponent(blobUrl) : logo ? encodeURIComponent(logo) : \"\"}&brandBanner=${banner === \"no-banner\" ? encodeURIComponent(\"no-banner\") : bannerBlobUrl ? encodeURIComponent(bannerBlobUrl) : banner ? encodeURIComponent(banner) : \"\"}`}\n                            className=\"absolute left-0 top-0 h-full w-full origin-top-left scale-50 overflow-hidden rounded-b-lg border-0 bg-white\"\n                            style={{\n                              width: \"200%\",\n                              height: \"200%\",\n                              pointerEvents: \"none\",\n                            }}\n                          />\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </TabsContent>\n              {/* Document View */}\n              <TabsContent value=\"document-view\" className=\"mt-6\">\n                <div className=\"flex justify-center\">\n                  <div\n                    className=\"relative w-full max-w-[698px] rounded-lg bg-gray-200 p-1 shadow-lg\"\n                    style={{ height: \"450px\" }}\n                  >\n                    <div className=\"relative flex h-full flex-col overflow-hidden rounded-lg bg-gray-100\">\n                      <div className=\"mx-auto flex h-7 shrink-0 items-center justify-center\">\n                        <div className=\"pointer-events-none absolute left-3\">\n                          <div className=\"flex flex-row flex-nowrap justify-start\">\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                          </div>\n                        </div>\n                        <div className=\"flex items-center justify-center rounded-xl bg-white p-1 px-2 opacity-70\">\n                          <div\n                            aria-hidden=\"true\"\n                            className=\"mr-1 mt-0.5 flex text-muted-foreground\"\n                          >\n                            <svg\n                              aria-hidden=\"true\"\n                              height=\"8\"\n                              width=\"8\"\n                              viewBox=\"0 0 16 16\"\n                              fill=\"currentColor\"\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                            >\n                              <path d=\"M8.75 11.25a1.25 1.25 0 1 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z\"></path>\n                              <path\n                                fillRule=\"evenodd\"\n                                clipRule=\"evenodd\"\n                                d=\"M3.5 4v2h-1a1 1 0 0 0-1 1v6a3 3 0 0 0 3 3h7a3 3 0 0 0 3-3V7a1 1 0 0 0-1-1h-1V4a4 4 0 0 0-4-4h-1a4 4 0 0 0-4 4ZM11 6V4a2.5 2.5 0 0 0-2.5-2.5h-1A2.5 2.5 0 0 0 5 4v2h6Zm-8 7V7.5h10V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13Z\"\n                              ></path>\n                            </svg>\n                          </div>\n                          <span className=\"whitespace-normal text-xs text-muted-foreground\">\n                            papermark.com/view/...\n                          </span>\n                        </div>\n                      </div>\n                      <div className=\"relative min-h-0 flex-1 overflow-x-auto\">\n                        <div className=\"relative h-full max-w-[1396px]\">\n                          <iframe\n                            key={`document-view-${debouncedBrandColor}-${debouncedAccentColor}`}\n                            name=\"document-view\"\n                            id=\"document-view\"\n                            src={`/nav_ppreview_demo?brandColor=${encodeURIComponent(debouncedBrandColor)}&accentColor=${encodeURIComponent(debouncedAccentColor)}&brandLogo=${blobUrl ? encodeURIComponent(blobUrl) : logo ? encodeURIComponent(logo) : \"\"}`}\n                            className=\"absolute left-0 top-0 h-full w-full origin-top-left scale-50 overflow-hidden rounded-b-lg border-0 bg-white\"\n                            style={{\n                              width: \"200%\",\n                              height: \"200%\",\n                              pointerEvents: \"none\",\n                            }}\n                          />\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </TabsContent>\n              <TabsContent value=\"access-view\" className=\"mt-6\">\n                <div className=\"flex justify-center\">\n                  <div\n                    className=\"relative w-full max-w-[698px] rounded-lg bg-gray-200 p-1 shadow-lg\"\n                    style={{ height: \"450px\" }}\n                  >\n                    <div className=\"relative flex h-full flex-col overflow-hidden rounded-lg bg-gray-100\">\n                      <div className=\"mx-auto flex h-7 shrink-0 items-center justify-center\">\n                        <div className=\"pointer-events-none absolute left-3\">\n                          <div className=\"flex flex-row flex-nowrap justify-start\">\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                            <div className=\"pointer-events-auto\">\n                              <div className=\"mr-1 inline-block size-2 rounded-full bg-gray-300\"></div>\n                            </div>\n                          </div>\n                        </div>\n                        <div className=\"flex items-center justify-center rounded-xl bg-white p-1 px-2 opacity-70\">\n                          <div\n                            aria-hidden=\"true\"\n                            className=\"mr-1 mt-0.5 flex text-muted-foreground\"\n                          >\n                            <svg\n                              aria-hidden=\"true\"\n                              height=\"8\"\n                              width=\"8\"\n                              viewBox=\"0 0 16 16\"\n                              fill=\"currentColor\"\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                            >\n                              <path d=\"M8.75 11.25a1.25 1.25 0 1 0-1.5 0v1a.75.75 0 0 0 1.5 0v-1Z\"></path>\n                              <path\n                                fillRule=\"evenodd\"\n                                clipRule=\"evenodd\"\n                                d=\"M3.5 4v2h-1a1 1 0 0 0-1 1v6a3 3 0 0 0 3 3h7a3 3 0 0 0 3-3V7a1 1 0 0 0-1-1h-1V4a4 4 0 0 0-4-4h-1a4 4 0 0 0-4 4ZM11 6V4a2.5 2.5 0 0 0-2.5-2.5h-1A2.5 2.5 0 0 0 5 4v2h6Zm-8 7V7.5h10V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13Z\"\n                              ></path>\n                            </svg>\n                          </div>\n                          <span className=\"whitespace-normal text-xs text-muted-foreground\">\n                            papermark.com/view/...\n                          </span>\n                        </div>\n                      </div>\n                      <div className=\"relative min-h-0 flex-1 overflow-x-auto\">\n                        <div className=\"relative h-full max-w-[1396px]\">\n                          <iframe\n                            key={`access-screen-${debouncedBrandColor}-${debouncedAccentColor}-${debouncedWelcomeMessage}`}\n                            name=\"access-screen\"\n                            id=\"access-screen\"\n                            src={`/entrance_ppreview_demo?brandColor=${encodeURIComponent(debouncedBrandColor)}&accentColor=${encodeURIComponent(debouncedAccentColor)}&brandLogo=${blobUrl ? encodeURIComponent(blobUrl) : logo ? encodeURIComponent(logo) : \"\"}&welcomeMessage=${encodeURIComponent(debouncedWelcomeMessage)}`}\n                            className=\"absolute left-0 top-0 h-full w-full origin-top-left scale-50 overflow-hidden rounded-b-lg border-0 bg-white\"\n                            style={{\n                              width: \"200%\",\n                              height: \"200%\",\n                              pointerEvents: \"none\",\n                            }}\n                          />\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </TabsContent>\n            </Tabs>\n\n            {/* Preview Mode Info */}\n            {/* <div className=\"mt-6 flex justify-center\">\n              <div className=\"w-full max-w-[698px] space-y-2 rounded-lg border border-border bg-card p-4\">\n                <h4 className=\"text-sm font-semibold text-foreground\">\n                  Preview Mode\n                </h4>\n                <p className=\"text-sm text-muted-foreground\">\n                  Changes will be reflected in real-time as you adjust settings.\n                </p>\n              </div>\n            </div> */}\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/conversations/[conversationId]/index.tsx",
    "content": "import ConversationDetail from \"@/ee/features/conversations/pages/conversation-detail\";\n\nexport default function ConversationDetailPage() {\n  return <ConversationDetail />;\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/conversations/faqs/index.tsx",
    "content": "export { default } from \"@/ee/features/conversations/pages/faq-overview\";\n"
  },
  {
    "path": "pages/datarooms/[id]/conversations/index.tsx",
    "content": "import ConversationOverview from \"@/ee/features/conversations/pages/conversation-overview\";\n\nexport default function ConversationOverviewPage() {\n  return <ConversationOverview />;\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/documents/[...name].tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ArrowUpDownIcon, FolderPlusIcon, PlusIcon } from \"lucide-react\";\n\nimport { useDataroom, useDataroomItems } from \"@/lib/swr/use-dataroom\";\n\nimport DownloadDataroomButton from \"@/components/datarooms/actions/download-dataroom\";\nimport GenerateIndexButton from \"@/components/datarooms/actions/generate-index-button\";\nimport RebuildIndexButton from \"@/components/datarooms/actions/rebuild-index-button\";\nimport { BreadcrumbComponent } from \"@/components/datarooms/dataroom-breadcrumb\";\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomItemsList } from \"@/components/datarooms/dataroom-items-list\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport { SidebarFolderTree } from \"@/components/datarooms/folders\";\nimport { DataroomSortableList } from \"@/components/datarooms/sortable/sortable-list\";\nimport { AddDocumentModal } from \"@/components/documents/add-document-modal\";\nimport { LoadingDocuments } from \"@/components/documents/loading-document\";\nimport { AddFolderModal } from \"@/components/folders/add-folder-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\n\nexport default function Documents() {\n  const router = useRouter();\n  const { name } = router.query as { name: string[] };\n\n  const { dataroom } = useDataroom();\n  const { items, folderCount, documentCount, isLoading } = useDataroomItems({\n    name,\n  });\n\n  const teamInfo = useTeam();\n\n  const [isReordering, setIsReordering] = useState<boolean>(false);\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-4 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom?.name ?? \"\"}\n            description={dataroom?.pId ?? \"\"}\n            internalName={dataroom?.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom?.id} />\n        </header>\n\n        <div className=\"flex items-center justify-between gap-x-2\">\n          <div className=\"flex items-center gap-x-2\">\n            <GenerateIndexButton\n              teamId={teamInfo?.currentTeam?.id!}\n              dataroomId={dataroom?.id!}\n            />\n            <RebuildIndexButton\n              teamId={teamInfo?.currentTeam?.id!}\n              dataroomId={dataroom?.id!}\n            />\n            <DownloadDataroomButton\n              teamId={teamInfo?.currentTeam?.id!}\n              dataroomId={dataroom?.id!}\n              dataroomName={dataroom?.name}\n            />\n          </div>\n          <div className=\"flex items-center justify-end gap-x-2\">\n            <AddDocumentModal\n              isDataroom={true}\n              dataroomId={dataroom?.id}\n              key={1}\n            >\n              <Button\n                size=\"sm\"\n                className=\"group flex items-center justify-start gap-x-3 px-3 text-left\"\n                title=\"Add Document\"\n              >\n                <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n                <span>Add Document</span>\n              </Button>\n            </AddDocumentModal>\n            <AddFolderModal isDataroom={true} dataroomId={dataroom?.id} key={2}>\n              <Button\n                size=\"sm\"\n                variant=\"outline\"\n                className=\"group flex items-center justify-start gap-x-3 px-3 text-left\"\n              >\n                <FolderPlusIcon\n                  className=\"h-5 w-5 shrink-0\"\n                  aria-hidden=\"true\"\n                />\n                <span>Add Folder</span>\n              </Button>\n            </AddFolderModal>\n            <div id=\"dataroom-reordering-action\">\n              {!isReordering ? (\n                <Button\n                  size=\"sm\"\n                  variant=\"outline\"\n                  className=\"gap-x-1\"\n                  onClick={() => setIsReordering(!isReordering)}\n                >\n                  <ArrowUpDownIcon className=\"h-4 w-4\" />\n                  Reorder\n                </Button>\n              ) : null}\n            </div>\n          </div>\n        </div>\n\n        <div className=\"grid h-full gap-4 pb-2 md:grid-cols-4\">\n          <div className=\"h-full truncate md:col-span-1\">\n            <ScrollArea showScrollbar>\n              <SidebarFolderTree dataroomId={dataroom?.id!} />\n              <ScrollBar orientation=\"horizontal\" />\n            </ScrollArea>\n          </div>\n          <div className=\"space-y-4 md:col-span-3\">\n            <section id=\"documents-header-count\" className=\"min-h-8\" />\n\n            {isLoading ? <LoadingDocuments count={3} /> : null}\n\n            {isReordering ? (\n              <DataroomSortableList\n                mixedItems={items}\n                folderPathName={name}\n                teamInfo={teamInfo}\n                dataroomId={dataroom?.id!}\n                setIsReordering={setIsReordering}\n              />\n            ) : (\n              <DataroomItemsList\n                mixedItems={items}\n                teamInfo={teamInfo}\n                dataroomId={dataroom?.id!}\n                folderPathName={name}\n                folderCount={folderCount}\n                documentCount={documentCount}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/documents/index.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ArrowUpDownIcon, FolderPlusIcon, PlusIcon } from \"lucide-react\";\n\nimport { useDataroom, useDataroomItems } from \"@/lib/swr/use-dataroom\";\n\nimport DownloadDataroomButton from \"@/components/datarooms/actions/download-dataroom\";\nimport GenerateIndexButton from \"@/components/datarooms/actions/generate-index-button\";\nimport RebuildIndexButton from \"@/components/datarooms/actions/rebuild-index-button\";\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomItemsList } from \"@/components/datarooms/dataroom-items-list\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport { SidebarFolderTree } from \"@/components/datarooms/folders\";\nimport { DataroomSortableList } from \"@/components/datarooms/sortable/sortable-list\";\nimport { AddDocumentModal } from \"@/components/documents/add-document-modal\";\nimport { LoadingDocuments } from \"@/components/documents/loading-document\";\nimport { AddFolderModal } from \"@/components/folders/add-folder-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport { ResponsiveButton } from \"@/components/ui/responsive-button\";\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\n\nexport default function Documents() {\n  const { dataroom } = useDataroom();\n  const { items, folderCount, documentCount, isLoading } = useDataroomItems({\n    root: true,\n  });\n  const teamInfo = useTeam();\n\n  const [isReordering, setIsReordering] = useState<boolean>(false);\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-4 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom?.name ?? \"\"}\n            description={dataroom?.pId ?? \"\"}\n            internalName={dataroom?.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom?.id} />\n        </header>\n\n        <div className=\"flex items-center justify-between gap-x-2\">\n          <div className=\"flex items-center gap-x-2\">\n            <GenerateIndexButton\n              teamId={teamInfo?.currentTeam?.id!}\n              dataroomId={dataroom?.id!}\n            />\n            <RebuildIndexButton\n              teamId={teamInfo?.currentTeam?.id!}\n              dataroomId={dataroom?.id!}\n            />\n            <DownloadDataroomButton\n              teamId={teamInfo?.currentTeam?.id!}\n              dataroomId={dataroom?.id!}\n              dataroomName={dataroom?.name}\n            />\n          </div>\n          <div className=\"flex items-center justify-end gap-x-2\">\n            <AddDocumentModal\n              isDataroom={true}\n              dataroomId={dataroom?.id}\n              key={1}\n            >\n              <Button\n                size=\"sm\"\n                className=\"group flex items-center justify-start gap-x-3 px-3 text-left\"\n                title=\"Add Document\"\n              >\n                <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n                <span>Add Document</span>\n              </Button>\n            </AddDocumentModal>\n\n            <AddFolderModal isDataroom={true} dataroomId={dataroom?.id} key={2}>\n              <ResponsiveButton\n                icon={<FolderPlusIcon className=\"h-5 w-5 shrink-0\" />}\n                text=\"Add Folder\"\n                size=\"sm\"\n                variant=\"outline\"\n              />\n            </AddFolderModal>\n            <div id=\"dataroom-reordering-action\">\n              {!isReordering ? (\n                <ResponsiveButton\n                  icon={<ArrowUpDownIcon className=\"h-4 w-4\" />}\n                  text=\"Reorder\"\n                  size=\"sm\"\n                  variant=\"outline\"\n                  onClick={() => setIsReordering(!isReordering)}\n                />\n              ) : null}\n            </div>\n          </div>\n        </div>\n\n        <div className=\"grid h-full gap-4 pb-2 md:grid-cols-4\">\n          <div className=\"h-full truncate md:col-span-1\">\n            <ScrollArea showScrollbar>\n              <SidebarFolderTree dataroomId={dataroom?.id!} />\n              <ScrollBar orientation=\"horizontal\" />\n            </ScrollArea>\n          </div>\n          <div className=\"space-y-4 md:col-span-3\">\n            <section id=\"documents-header-count\" className=\"min-h-8\" />\n\n            {isLoading ? <LoadingDocuments count={3} /> : null}\n\n            {isReordering ? (\n              <DataroomSortableList\n                mixedItems={items}\n                teamInfo={teamInfo}\n                dataroomId={dataroom?.id!}\n                setIsReordering={setIsReordering}\n              />\n            ) : (\n              <DataroomItemsList\n                mixedItems={items}\n                teamInfo={teamInfo}\n                dataroomId={dataroom?.id!}\n                folderCount={folderCount}\n                documentCount={documentCount}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/groups/[groupId]/group-analytics.tsx",
    "content": "import { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport { GroupHeader } from \"@/components/datarooms/groups/group-header\";\nimport { GroupNavigation } from \"@/components/datarooms/groups/group-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport DataroomVisitorsTable from \"@/components/visitors/dataroom-visitors-table\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomGroup } from \"@/lib/swr/use-dataroom-groups\";\n\nexport default function DataroomGroupPage() {\n  const { dataroom } = useDataroom();\n  const { viewerGroup } = useDataroomGroup();\n\n  if (!dataroom || !viewerGroup) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <GroupHeader dataroomId={dataroom.id} groupName={viewerGroup.name} />\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <GroupNavigation\n            dataroomId={dataroom.id}\n            viewerGroupId={viewerGroup?.id!}\n          />\n          <div className=\"grid gap-6\">\n            <DataroomVisitorsTable\n              dataroomId={dataroom.id}\n              groupId={viewerGroup.id}\n              name={viewerGroup.name}\n            />\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/groups/[groupId]/index.tsx",
    "content": "import { useTeam } from \"@/context/team-context\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport DeleteGroup from \"@/components/datarooms/groups/delete-group\";\nimport { GroupHeader } from \"@/components/datarooms/groups/group-header\";\nimport { GroupNavigation } from \"@/components/datarooms/groups/group-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Form } from \"@/components/ui/form\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomGroup } from \"@/lib/swr/use-dataroom-groups\";\n\nexport default function DataroomGroupPage() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { dataroom } = useDataroom();\n  const { viewerGroup } = useDataroomGroup();\n\n  if (!dataroom || !viewerGroup) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <GroupHeader dataroomId={dataroom.id} groupName={viewerGroup.name} />\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <GroupNavigation\n            dataroomId={dataroom.id}\n            viewerGroupId={viewerGroup.id}\n          />\n          <div className=\"grid gap-6\">\n            <Form\n              title=\"Group Name\"\n              description=\"This is the name of your data room group on Papermark.\"\n              inputAttrs={{\n                name: \"name\",\n                placeholder: \"e.g. Management Team\",\n                maxLength: 32,\n              }}\n              defaultValue={viewerGroup?.name ?? \"\"}\n              helpText=\"Max 32 characters\"\n              handleSubmit={(updateData) =>\n                fetch(\n                  `/api/teams/${teamId}/datarooms/${dataroom.id}/groups/${viewerGroup.id}`,\n                  {\n                    method: \"PATCH\",\n                    headers: {\n                      \"Content-Type\": \"application/json\",\n                    },\n                    body: JSON.stringify(updateData),\n                  },\n                ).then(async (res) => {\n                  if (res.status === 200) {\n                    await Promise.all([\n                      mutate(\n                        `/api/teams/${teamId}/datarooms/${dataroom.id}/groups`,\n                      ),\n                      mutate(\n                        `/api/teams/${teamId}/datarooms/${dataroom.id}/groups/${viewerGroup.id}`,\n                      ),\n                    ]);\n                    toast.success(\"Successfully updated group name!\");\n                  } else {\n                    const { error } = await res.json();\n                    toast.error(error.message);\n                  }\n                })\n              }\n            />\n            <DeleteGroup\n              dataroomId={dataroom.id}\n              groupId={viewerGroup.id}\n              groupName={viewerGroup.name.trim()}\n            />\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/groups/[groupId]/links.tsx",
    "content": "import { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport {\n  useDataroomGroup,\n  useDataroomGroupLinks,\n} from \"@/lib/swr/use-dataroom-groups\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport { GroupHeader } from \"@/components/datarooms/groups/group-header\";\nimport { GroupNavigation } from \"@/components/datarooms/groups/group-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport LinksTable from \"@/components/links/links-table\";\n\nexport default function DataroomGroupLinksPage() {\n  const { dataroom } = useDataroom();\n  const { viewerGroup } = useDataroomGroup();\n  const { links, loading } = useDataroomGroupLinks();\n\n  if (!dataroom || !viewerGroup) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <GroupHeader dataroomId={dataroom.id} groupName={viewerGroup.name} />\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <GroupNavigation\n            dataroomId={dataroom.id}\n            viewerGroupId={viewerGroup.id}\n          />\n          <div className=\"grid gap-6\">\n            {loading ? (\n              <div>Loading...</div>\n            ) : (\n              <LinksTable\n                links={links}\n                targetType={\"DATAROOM\"}\n                dataroomName={dataroom.name}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/groups/[groupId]/members.tsx",
    "content": "import { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport { GroupHeader } from \"@/components/datarooms/groups/group-header\";\nimport GroupMemberTable from \"@/components/datarooms/groups/group-member-table\";\nimport { GroupNavigation } from \"@/components/datarooms/groups/group-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomGroup } from \"@/lib/swr/use-dataroom-groups\";\n\nexport default function DataroomGroupPage() {\n  const { dataroom } = useDataroom();\n  const { viewerGroup } = useDataroomGroup();\n\n  if (!dataroom || !viewerGroup) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <GroupHeader dataroomId={dataroom.id} groupName={viewerGroup.name} />\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <GroupNavigation\n            dataroomId={dataroom.id}\n            viewerGroupId={viewerGroup.id}\n          />\n          <div className=\"grid gap-6\">\n            <GroupMemberTable\n              dataroomId={dataroom.id}\n              groupId={viewerGroup.id}\n            />\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/groups/[groupId]/permissions.tsx",
    "content": "import { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport { GroupHeader } from \"@/components/datarooms/groups/group-header\";\nimport { GroupNavigation } from \"@/components/datarooms/groups/group-navigation\";\nimport ExpandableTable from \"@/components/datarooms/groups/group-permissions\";\nimport AppLayout from \"@/components/layouts/app\";\n\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport { useDataroomGroup } from \"@/lib/swr/use-dataroom-groups\";\n\nexport default function DataroomGroupPage() {\n  const { dataroom } = useDataroom();\n  const { viewerGroup } = useDataroomGroup();\n\n  if (!dataroom || !viewerGroup) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <GroupHeader dataroomId={dataroom.id} groupName={viewerGroup.name} />\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <GroupNavigation\n            dataroomId={dataroom.id}\n            viewerGroupId={viewerGroup?.id!}\n          />\n          <div className=\"grid gap-6\">\n            <ExpandableTable\n              dataroomId={dataroom.id}\n              permissions={viewerGroup.accessControls}\n              groupId={viewerGroup.id}\n            />\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/groups/index.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { CircleHelpIcon, InfoIcon, UsersIcon } from \"lucide-react\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport { AddGroupModal } from \"@/components/datarooms/groups/add-group-modal\";\nimport GroupCard from \"@/components/datarooms/groups/group-card\";\nimport { GroupCardPlaceholder } from \"@/components/datarooms/groups/group-card-placeholder\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\nimport useDataroomGroups from \"@/lib/swr/use-dataroom-groups\";\nimport { cn } from \"@/lib/utils\";\n\nexport default function DataroomGroupPage() {\n  const { isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n  const { dataroom } = useDataroom();\n  const { viewerGroups, loading } = useDataroomGroups();\n\n  const [modalOpen, setModalOpen] = useState<boolean>(false);\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  const ButtonComponent = () => {\n    if (isDatarooms || isDataroomsPlus || isTrial) {\n      return <Button onClick={() => setModalOpen(true)}>Create group</Button>;\n    }\n    return (\n      <UpgradePlanModal\n        clickedPlan={PlanEnum.DataRooms}\n        trigger=\"create_group_button\"\n      >\n        <Button>Upgrade to create group</Button>\n      </UpgradePlanModal>\n    );\n  };\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <Tabs defaultValue=\"groups\" className=\"!mt-4 space-y-4\">\n          <TabsList>\n            <TabsTrigger value=\"links\" asChild>\n              <Link href={`/datarooms/${dataroom.id}/permissions`}>Links</Link>\n            </TabsTrigger>\n            <TabsTrigger value=\"groups\">Groups</TabsTrigger>\n          </TabsList>\n        </Tabs>\n\n        <div className=\"space-y-4\">\n          {/* Groups */}\n          <div className=\"grid gap-5\">\n            <div className=\"flex flex-wrap justify-between gap-6\">\n              <div className=\"flex items-center gap-x-2\">\n                <div className=\"space-y-1\">\n                  <h3 className=\"text-lg font-semibold tracking-tight text-foreground\">\n                    Groups\n                  </h3>\n                  <p className=\"flex flex-row items-center gap-2 text-sm text-muted-foreground\">\n                    Control document access with granular permissions through\n                    groups.{\" \"}\n                    <BadgeTooltip\n                      linkText=\"Learn more\"\n                      content=\"Manage Access with Granular Permissions for Data Room Groups\"\n                      key=\"groups\"\n                      link=\"https://www.papermark.com/help/article/granular-permissions\"\n                    >\n                      <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                    </BadgeTooltip>\n                  </p>\n                </div>\n              </div>\n              <div className=\"flex w-full flex-wrap items-center gap-3 sm:w-auto\">\n                <ButtonComponent />\n              </div>\n            </div>\n            <div className=\"animate-fade-in\">\n              {!loading ? (\n                viewerGroups && viewerGroups.length > 0 ? (\n                  <ul className=\"grid grid-cols-1 gap-3\">\n                    {viewerGroups.map((group) => (\n                      <li key={group.id}>\n                        <Link\n                          href={`/datarooms/${dataroom.id}/groups/${group.id}`}\n                        >\n                          <GroupCard group={group} />\n                        </Link>\n                      </li>\n                    ))}\n                  </ul>\n                ) : (\n                  <div className=\"flex flex-col items-center gap-4 rounded-xl border border-gray-200 py-10\">\n                    <div className=\"hidden rounded-full border border-gray-200 sm:block\">\n                      <div\n                        className={cn(\n                          \"rounded-full border border-white bg-gradient-to-t from-gray-100 p-1 md:p-3\",\n                        )}\n                      >\n                        <UsersIcon className=\"size-6\" />\n                      </div>\n                    </div>\n                    <p>No groups found for this dataroom</p>\n                    <ButtonComponent />\n                  </div>\n                )\n              ) : (\n                <ul className=\"grid grid-cols-1 gap-3\">\n                  {Array.from({ length: 3 }).map((_, idx) => (\n                    <li key={idx}>\n                      <GroupCardPlaceholder />\n                    </li>\n                  ))}\n                </ul>\n              )}\n            </div>\n          </div>\n\n          <AddGroupModal\n            dataroomId={dataroom.id}\n            open={modalOpen}\n            setOpen={setModalOpen}\n          />\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/index.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useDataroom, useDataroomLinks } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport StatsCard from \"@/components/datarooms/stats-card\";\nimport AppLayout from \"@/components/layouts/app\";\nimport LinkSheet from \"@/components/links/link-sheet\";\nimport LinksTable from \"@/components/links/links-table\";\nimport { Button } from \"@/components/ui/button\";\nimport DataroomVisitorsTable from \"@/components/visitors/dataroom-visitors-table\";\n\nexport default function DataroomPage() {\n  const { dataroom } = useDataroom();\n  const { links } = useDataroomLinks();\n\n  const [isLinkSheetOpen, setIsLinkSheetOpen] = useState<boolean>(false);\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[\n              <Button onClick={() => setIsLinkSheetOpen(true)} key={1}>\n                Share\n              </Button>,\n            ]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <div className=\"space-y-4\">\n          {/* Stats */}\n          <StatsCard />\n\n          {/* Links */}\n          <LinksTable\n            links={links}\n            targetType={\"DATAROOM\"}\n            dataroomName={dataroom.name}\n          />\n\n          {/* Visitors */}\n          <DataroomVisitorsTable dataroomId={dataroom.id} />\n\n          <LinkSheet\n            linkType={\"DATAROOM_LINK\"}\n            isOpen={isLinkSheetOpen}\n            setIsOpen={setIsLinkSheetOpen}\n            existingLinks={links}\n          />\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/permissions/index.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useState } from \"react\";\n\nimport { CircleHelpIcon } from \"lucide-react\";\n\nimport { useDataroom, useDataroomLinks } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport LinkSheet from \"@/components/links/link-sheet\";\nimport LinksTable from \"@/components/links/links-table\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nexport default function DataroomAnalyticsPage() {\n  const { dataroom } = useDataroom();\n  const { links } = useDataroomLinks();\n\n  const [isLinkSheetOpen, setIsLinkSheetOpen] = useState<boolean>(false);\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[\n              <Button onClick={() => setIsLinkSheetOpen(true)} key={1}>\n                Share\n              </Button>,\n            ]}\n          />\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <Tabs defaultValue=\"links\" className=\"!mt-4 space-y-4\">\n          <TabsList>\n            <TabsTrigger value=\"links\">Links</TabsTrigger>\n            <TabsTrigger value=\"groups\" asChild>\n              <Link href={`/datarooms/${dataroom.id}/groups`}>Groups</Link>\n            </TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"links\" className=\"space-y-4\">\n            <div className=\"!mt-8 flex items-center gap-x-2\">\n              <div className=\"space-y-1\">\n                <h3 className=\"text-lg font-semibold tracking-tight text-foreground\">\n                  Links\n                </h3>\n                <p className=\"flex flex-row items-center gap-2 text-sm text-muted-foreground\">\n                  Share data room with strong access controls using links.\n                  <BadgeTooltip\n                    linkText=\"Learn more\"\n                    content=\"Configure access controls for data room links.\"\n                    key=\"links\"\n                    link=\"https://www.papermark.com/help/category/links\"\n                  >\n                    <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                  </BadgeTooltip>\n                </p>\n              </div>\n            </div>\n            <LinksTable\n              links={links}\n              targetType={\"DATAROOM\"}\n              dataroomName={dataroom.name}\n            />\n            <LinkSheet\n              linkType={\"DATAROOM_LINK\"}\n              isOpen={isLinkSheetOpen}\n              setIsOpen={setIsLinkSheetOpen}\n              existingLinks={links}\n            />\n          </TabsContent>\n        </Tabs>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/settings/downloads.tsx",
    "content": "import { useDataroom } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport BulkDownloadSettings from \"@/components/datarooms/settings/bulk-download-settings\";\nimport SettingsTabs from \"@/components/datarooms/settings/settings-tabs\";\nimport AppLayout from \"@/components/layouts/app\";\n\nexport default function Downloads() {\n  const { dataroom } = useDataroom();\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        {/* Settings */}\n        <div className=\"mx-auto grid w-full gap-2\">\n          <h1 className=\"text-2xl font-semibold\">Settings</h1>\n        </div>\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <SettingsTabs dataroomId={dataroom.id} />\n          <div className=\"grid gap-6\">\n            <BulkDownloadSettings dataroomId={dataroom.id} />\n          </div>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/settings/file-permissions.tsx",
    "content": "import { useDataroom } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport PermissionSettings from \"@/components/datarooms/settings/permission-settings\";\nimport SettingsTabs from \"@/components/datarooms/settings/settings-tabs\";\nimport AppLayout from \"@/components/layouts/app\";\n\nexport default function PermissionsSettings() {\n  const { dataroom } = useDataroom();\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n        <div className=\"mx-auto grid w-full gap-2\">\n          <h1 className=\"text-2xl font-semibold\">Settings</h1>\n        </div>\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <SettingsTabs dataroomId={dataroom.id} />\n          <div className=\"grid gap-6\">\n            <PermissionSettings dataroomId={dataroom.id} />\n          </div>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/settings/index.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { AgentsSettingsCard } from \"@/ee/features/ai/components/agents-settings-card\";\nimport { Check, Copy } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDataroom } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport DataroomTagSection from \"@/components/datarooms/settings/dataroom-tag-section\";\nimport DeleteDataroom from \"@/components/datarooms/settings/delete-dataroooom\";\nimport DuplicateDataroom from \"@/components/datarooms/settings/duplicate-dataroom\";\nimport SettingsTabs from \"@/components/datarooms/settings/settings-tabs\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Form } from \"@/components/ui/form\";\nimport { Input } from \"@/components/ui/input\";\n\nexport default function Settings() {\n  const { dataroom } = useDataroom();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const [isCopied, setIsCopied] = useState(false);\n\n  const { isBusiness, isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        {/* Settings */}\n        <div className=\"mx-auto grid w-full gap-2\">\n          <h1 className=\"text-2xl font-semibold\">Settings</h1>\n        </div>\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <SettingsTabs dataroomId={dataroom.id} />\n          <div className=\"grid gap-6\">\n            <Form\n              title=\"Dataroom Name\"\n              description=\"This is the public name of your data room visible to all viewers.\"\n              inputAttrs={{\n                name: \"name\",\n                placeholder: \"My Dataroom\",\n                maxLength: 156,\n              }}\n              defaultValue={dataroom.name}\n              helpText=\"Max 156 characters\"\n              handleSubmit={(updateData) =>\n                fetch(`/api/teams/${teamId}/datarooms/${dataroom.id}`, {\n                  method: \"PATCH\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                  },\n                  body: JSON.stringify(updateData),\n                }).then(async (res) => {\n                  if (res.status === 200) {\n                    await Promise.all([\n                      mutate(`/api/teams/${teamId}/datarooms`),\n                      mutate(`/api/teams/${teamId}/datarooms?simple=true`),\n                      mutate(`/api/teams/${teamId}/datarooms/${dataroom.id}`),\n                    ]);\n                    toast.success(\"Successfully updated dataroom name!\");\n                  } else {\n                    const { error } = await res.json();\n                    toast.error(error.message);\n                  }\n                })\n              }\n            />\n            <Form\n              title=\"Internal Name\"\n              description=\"A private name only visible to you. Useful for distinguishing multiple data rooms with the same public name.\"\n              inputAttrs={{\n                name: \"internalName\",\n                placeholder: \"e.g., Series A - Sequoia, Buyer Group A\",\n                maxLength: 156,\n              }}\n              defaultValue={dataroom.internalName ?? \"\"}\n              helpText=\"Max 156 characters. Leave empty to remove.\"\n              handleSubmit={(updateData) =>\n                fetch(`/api/teams/${teamId}/datarooms/${dataroom.id}`, {\n                  method: \"PATCH\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                  },\n                  body: JSON.stringify({\n                    internalName: updateData.internalName || null,\n                  }),\n                }).then(async (res) => {\n                  if (res.status === 200) {\n                    await Promise.all([\n                      mutate(`/api/teams/${teamId}/datarooms`),\n                      mutate(`/api/teams/${teamId}/datarooms/${dataroom.id}`),\n                    ]);\n                    toast.success(\"Successfully updated internal name!\");\n                  } else {\n                    const { error } = await res.json();\n                    toast.error(error.message);\n                  }\n                })\n              }\n            />\n            <Form\n              title=\"Show Last Updated\"\n              description=\"Display the last updated date on your dataroom banner.\"\n              inputAttrs={{\n                name: \"showLastUpdated\",\n                type: \"checkbox\",\n                placeholder: \"Show last updated date\",\n              }}\n              defaultValue={String(dataroom.showLastUpdated ?? true)}\n              helpText=\"When enabled, visitors will see when the dataroom was last updated.\"\n              handleSubmit={(updateData) =>\n                fetch(`/api/teams/${teamId}/datarooms/${dataroom.id}`, {\n                  method: \"PATCH\",\n                  headers: {\n                    \"Content-Type\": \"application/json\",\n                  },\n                  body: JSON.stringify({\n                    showLastUpdated: updateData.showLastUpdated === \"true\",\n                  }),\n                }).then(async (res) => {\n                  if (res.status === 200) {\n                    await Promise.all([\n                      mutate(`/api/teams/${teamId}/datarooms`),\n                      mutate(`/api/teams/${teamId}/datarooms?simple=true`),\n                      mutate(`/api/teams/${teamId}/datarooms/${dataroom.id}`),\n                    ]);\n                    toast.success(\"Successfully updated display settings!\");\n                  } else {\n                    const { error } = await res.json();\n                    toast.error(error.message);\n                  }\n                })\n              }\n            />\n            <DataroomTagSection\n              dataroomId={dataroom.id}\n              teamId={teamId!}\n              initialTags={dataroom.tags}\n            />\n\n            {/* AI Agents Settings */}\n            <AgentsSettingsCard\n              dataroomId={dataroom.id}\n              teamId={teamId!}\n              agentsEnabled={dataroom.agentsEnabled}\n              vectorStoreId={dataroom.vectorStoreId}\n            />\n\n            <DuplicateDataroom dataroomId={dataroom.id} teamId={teamId} />\n            <Card className=\"bg-transparent\">\n              <CardHeader>\n                <CardTitle>Dataroom ID</CardTitle>\n                <CardDescription>\n                  Unique ID of your dataroom on Papermark.\n                </CardDescription>\n              </CardHeader>\n              <CardContent>\n                <div className=\"flex items-center space-x-2\">\n                  <div className=\"relative w-full max-w-md\">\n                    <Input\n                      value={dataroom.id}\n                      className=\"pr-10 font-mono\"\n                      readOnly\n                    />\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"absolute right-0 top-0 h-full px-3 hover:bg-transparent\"\n                      onClick={() => {\n                        navigator.clipboard.writeText(dataroom.id);\n                        toast.success(\"Dataroom ID copied to clipboard\");\n                        setIsCopied(true);\n                        setTimeout(() => setIsCopied(false), 2000);\n                      }}\n                    >\n                      {isCopied ? (\n                        <Check className=\"h-4 w-4\" />\n                      ) : (\n                        <Copy className=\"h-4 w-4\" />\n                      )}\n                    </Button>\n                  </div>\n                </div>\n              </CardContent>\n              <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6\">\n                <p className=\"text-sm text-muted-foreground transition-colors\">\n                  Used to identify your dataroom when interacting with the\n                  Papermark API.\n                </p>\n              </CardFooter>\n            </Card>\n\n            {isBusiness || isDatarooms || isDataroomsPlus || isTrial ? (\n              <DeleteDataroom\n                dataroomId={dataroom.id}\n                dataroomName={dataroom.name}\n              />\n            ) : null}\n            {/* <Card>\n                  <CardHeader className=\"relative\">\n                    <CardTitle>Feedback Question</CardTitle>\n                    <CardDescription>\n                      This question will be shown to visitors after the last\n                      page of your document.\n                    </CardDescription>\n                    <div className=\"absolute right-8 top-6\">\n                      <span\n                        className=\"relative ml-auto flex h-4 w-4\"\n                        title={`Feedback is ${feedback?.enabled ? \"\" : \"not\"} active`}\n                      >\n                        <span\n                          className={cn(\n                            \"absolute inline-flex h-full w-full rounded-full opacity-75\",\n                            feedback?.enabled\n                              ? \"animate-ping bg-green-400\"\n                              : \"\",\n                          )}\n                        />\n                        <span\n                          className={cn(\n                            \"relative inline-flex rounded-full h-4 w-4\",\n                            feedback?.enabled ? \"bg-green-500\" : \"bg-red-500\",\n                          )}\n                        />\n                      </span>\n                      <span className=\"sr-only\">\n                        {feedback?.enabled ? \"Enabled\" : \"Disabled\"}\n                      </span>\n                    </div>\n                  </CardHeader>\n                  <form\n                    onSubmit={async (e) => {\n                      e.preventDefault();\n\n                      if (value == \"\" || isNotBusiness) return null;\n\n                      setLoading(true);\n\n                      try {\n                        const response = await fetch(\n                          `/api/teams/${teamId}/documents/${id}/feedback`,\n                          {\n                            method: \"PUT\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                            body: JSON.stringify({ questionText: value }),\n                          },\n                        );\n\n                        if (response.status === 200) {\n                          await mutate(\n                            `/api/teams/${teamId}/documents/${id}/feedback`,\n                          );\n                          toast.success(\n                            \"Successfully added a feedback question!\",\n                          );\n                        } else {\n                          const { error } = await response.json();\n                          toast.error(error.message);\n                        }\n                      } catch (error) {\n                        // Handle any errors that might occur during fetch\n                        toast.error(\n                          \"An error occurred while adding the question.\",\n                        );\n                        console.error(\"Fetch error:\", error);\n                      } finally {\n                        setLoading(false);\n                      }\n                    }}\n                  >\n                    <CardContent>\n                      <div className=\"grid w-full items-start gap-6 overflow-x-visible pb-4 pt-0\">\n                        <div className=\"grid gap-3\">\n                          <Label>Question Type</Label>\n                          <Select defaultValue=\"yes-no\">\n                            <SelectTrigger>\n                              <SelectValue placeholder=\"Select a question type\" />\n                            </SelectTrigger>\n                            <SelectContent>\n                              <SelectItem value=\"yes-no\">Yes / No</SelectItem>\n                            </SelectContent>\n                          </Select>\n                        </div>\n                        <div className=\"grid gap-3\">\n                          <Label htmlFor=\"question\">Question</Label>\n                          <Input\n                            id=\"question\"\n                            type=\"text\"\n                            name=\"question\"\n                            required={!isNotBusiness}\n                            placeholder=\"Are you interested?\"\n                            value={value || \"\"}\n                            onChange={(e) => setValue(e.target.value)}\n                          />\n                        </div>\n                      </div>\n                    </CardContent>\n                    <CardFooter className=\"border-t py-3 bg-muted rounded-b-lg justify-end gap-x-2\">\n                      {feedback ? (\n                        <Button\n                          type=\"button\"\n                          variant=\"outline\"\n                          loading={loadingStatus}\n                          onClick={async (e) => {\n                            try {\n                              e.preventDefault();\n                              setLoadingStatus(true);\n\n                              const response = await fetch(\n                                `/api/teams/${teamId}/documents/${id}/feedback`,\n                                {\n                                  method: \"PUT\",\n                                  headers: {\n                                    \"Content-Type\": \"application/json\",\n                                  },\n                                  body: JSON.stringify({\n                                    enabled: !feedback?.enabled,\n                                  }),\n                                },\n                              );\n\n                              if (response.status === 200) {\n                                await mutate(\n                                  `/api/teams/${teamId}/documents/${id}/feedback`,\n                                );\n                                toast.success(\n                                  `${feedback?.enabled ? \"Turned off\" : \"Turned on\"} feedback question`,\n                                );\n                              } else {\n                                const { error } = await response.json();\n                                toast.error(error.message);\n                              }\n                            } catch (error) {\n                              // Handle any errors that might occur during fetch\n                              toast.error(\"An error occurred.\");\n                              console.error(\"Fetch error:\", error);\n                            } finally {\n                              setLoadingStatus(false);\n                            }\n                          }}\n                        >\n                          {feedback?.enabled ? \"Turn off\" : \"Turn on\"}\n                        </Button>\n                      ) : null}\n                      {isNotBusiness ? (\n                        <UpgradePlanModal\n                          clickedPlan={\"Business\"}\n                          trigger={\"feedback_question\"}\n                        >\n                          <Button type=\"submit\" loading={loading}>\n                            {feedback ? \"Update question\" : \"Create question\"}\n                          </Button>\n                        </UpgradePlanModal>\n                      ) : (\n                        <Button type=\"submit\" loading={loading}>\n                          {feedback ? \"Update question\" : \"Create question\"}\n                        </Button>\n                      )}\n                    </CardFooter>\n                  </form>\n                </Card> */}\n            {/* <Card className=\"border-red-500\">\n              <CardHeader>\n                <CardTitle>Delete Document</CardTitle>\n              </CardHeader>\n              <CardContent>\n                <p className=\"text-sm text-muted-foreground\">\n                  This actions deletes the document and any associates links and\n                  analytics.\n                </p>\n              </CardContent>\n              <CardFooter className=\"justify-end rounded-b-lg border-t border-red-500 px-6 py-3\">\n                <Button variant=\"destructive\">Delete document</Button>\n              </CardFooter>\n            </Card> */}\n          </div>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/settings/introduction.tsx",
    "content": "import { useDataroom } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport IntroductionSettings from \"@/components/datarooms/settings/introduction-settings\";\nimport SettingsTabs from \"@/components/datarooms/settings/settings-tabs\";\nimport AppLayout from \"@/components/layouts/app\";\n\nexport default function Introduction() {\n  const { dataroom } = useDataroom();\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        {/* Settings */}\n        <div className=\"mx-auto grid w-full gap-2\">\n          <h1 className=\"text-2xl font-semibold\">Settings</h1>\n        </div>\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <SettingsTabs dataroomId={dataroom.id} />\n          <div className=\"grid gap-6\">\n            <IntroductionSettings dataroomId={dataroom.id} />\n          </div>\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/settings/notifications.tsx",
    "content": "import { useDataroom } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport NotificationSettings from \"@/components/datarooms/settings/notification-settings\";\nimport SettingsTabs from \"@/components/datarooms/settings/settings-tabs\";\nimport AppLayout from \"@/components/layouts/app\";\n\nexport default function Notifications() {\n  const { dataroom } = useDataroom();\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader\n            title={dataroom.name}\n            description={dataroom.pId}\n            internalName={dataroom.internalName}\n            actions={[]}\n          />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        {/* Settings */}\n        <div className=\"mx-auto grid w-full gap-2\">\n          <h1 className=\"text-2xl font-semibold\">Settings</h1>\n        </div>\n        <div className=\"mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]\">\n          <SettingsTabs dataroomId={dataroom.id} />\n          <div className=\"grid gap-6\">\n            <NotificationSettings dataroomId={dataroom.id} />\n          </div>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/[id]/users/index.tsx",
    "content": "import { useDataroom } from \"@/lib/swr/use-dataroom\";\n\nimport { DataroomHeader } from \"@/components/datarooms/dataroom-header\";\nimport { DataroomNavigation } from \"@/components/datarooms/dataroom-navigation\";\nimport AppLayout from \"@/components/layouts/app\";\nimport DataroomViewersTable from \"@/components/visitors/dataroom-viewers\";\n\nexport default function DataroomUsersPage() {\n  const { dataroom } = useDataroom();\n\n  if (!dataroom) {\n    return <div>Loading...</div>;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <header>\n          <DataroomHeader title={dataroom.name} description={dataroom.pId} internalName={dataroom.internalName} />\n\n          <DataroomNavigation dataroomId={dataroom.id} />\n        </header>\n\n        <div className=\"space-y-4\">\n          {/* Visitors */}\n          <DataroomViewersTable dataroomId={dataroom.id} />\n        </div>\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/datarooms/index.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { FilterIcon, PlusIcon } from \"lucide-react\";\nimport { useQueryState } from \"nuqs\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useDatarooms from \"@/lib/swr/use-datarooms\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { useTags } from \"@/lib/swr/use-tags\";\nimport { daysLeft } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport { AddDataroomModal } from \"@/components/datarooms/add-dataroom-modal\";\nimport DataroomCard from \"@/components/datarooms/dataroom-card\";\nimport { DataroomTrialModal } from \"@/components/datarooms/dataroom-trial-modal\";\nimport { EmptyDataroom } from \"@/components/datarooms/empty-dataroom\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { SearchBoxPersisted } from \"@/components/search-box\";\nimport { Button } from \"@/components/ui/button\";\nimport { MultiSelect } from \"@/components/ui/multi-select-v2\";\nimport { Separator } from \"@/components/ui/separator\";\n\nexport default function DataroomsPage() {\n  const teamInfo = useTeam();\n  const { datarooms, totalCount } = useDatarooms();\n  const { isFree, isPro, isBusiness, isDatarooms, isDataroomsPlus, isTrial } =\n    usePlan();\n  const { limits } = useLimits();\n  const router = useRouter();\n\n  const [tagsFilter, setTagsFilter] = useQueryState<string[]>(\"tags\", {\n    parse: (value: string) => value.split(\",\").filter(Boolean),\n    serialize: (value: string[]) => value.join(\",\"),\n  });\n  const [isTagsPopoverOpen, setIsTagsPopoverOpen] = useState(false);\n\n  const { tags: availableTags } = useTags({\n    query: {\n      sortBy: \"name\",\n      sortOrder: \"asc\",\n    },\n  });\n\n  const totalDatarooms = totalCount ?? 0;\n  const limitDatarooms = limits?.datarooms ?? 1;\n\n  const canCreateUnlimitedDatarooms =\n    isDatarooms ||\n    isDataroomsPlus ||\n    (isBusiness && totalDatarooms < limitDatarooms);\n\n  const searchQuery = router.query.search as string | undefined;\n\n  // Sort datarooms alphabetically by name\n  const sortedDatarooms = datarooms?.slice().sort((a, b) => {\n    return a.name.localeCompare(b.name);\n  });\n\n  // Filter out only dataroom tags\n  const dataroomTags = useMemo(() => {\n    if (!availableTags) return [];\n    return availableTags;\n  }, [availableTags]);\n\n  const tagOptions = useMemo(() => {\n    return (\n      dataroomTags?.map((tag) => ({\n        value: tag.name,\n        label: tag.name,\n        meta: { color: tag.color, description: tag.description },\n      })) || []\n    );\n  }, [dataroomTags]);\n\n  const selectedTagValues = useMemo(() => {\n    return tagsFilter || [];\n  }, [tagsFilter]);\n\n  const hasActiveFilters = searchQuery || tagsFilter?.length;\n\n  useEffect(() => {\n    if (!isTrial && (isFree || isPro)) router.push(\"/documents\");\n  }, [isTrial, isFree, isPro]);\n\n  return (\n    <AppLayout>\n      <main className=\"p-4 sm:m-4 sm:px-4 sm:py-4\">\n        <section className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n          <div className=\"space-y-1\">\n            <h2 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n              Datarooms\n            </h2>\n            <p className=\"text-xs text-muted-foreground sm:text-sm\">\n              Manage your datarooms\n            </p>\n          </div>\n          <div className=\"flex items-center gap-x-1\">\n            {isBusiness && !canCreateUnlimitedDatarooms ? (\n              <UpgradePlanModal\n                clickedPlan={PlanEnum.DataRooms}\n                trigger=\"datarooms\"\n              >\n                <Button\n                  className=\"group flex flex-1 items-center justify-start gap-x-3 px-3 text-left\"\n                  title=\"Upgrade to Add Data Room\"\n                >\n                  <span>Upgrade to Add Data Room</span>\n                </Button>\n              </UpgradePlanModal>\n            ) : isTrial &&\n              datarooms &&\n              !isBusiness &&\n              !isDatarooms &&\n              !isDataroomsPlus ? (\n              <div className=\"flex items-center gap-x-4\">\n                <div className=\"text-sm text-destructive\">\n                  <span>Dataroom Trial: </span>\n                  <span className=\"font-medium\">\n                    {(() => {\n                      const startDate =\n                        datarooms && datarooms.length > 0\n                          ? datarooms[datarooms.length - 1]?.createdAt\n                          : new Date(\n                              teamInfo?.currentTeam?.createdAt ?? Date.now(),\n                            );\n                      const days = daysLeft(new Date(startDate), 7);\n                      if (days <= 0) return \"Expired\";\n                      const label = days === 1 ? \"day\" : \"days\";\n                      return `${days} ${label} left`;\n                    })()}\n                  </span>\n                </div>\n                {totalDatarooms < limitDatarooms ? (\n                  <AddDataroomModal>\n                    <Button\n                      className=\"group flex flex-1 items-center justify-start gap-x-3 px-3 text-left\"\n                      title=\"Create New Dataroom\"\n                    >\n                      <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n                      <span>Create New Dataroom</span>\n                    </Button>\n                  </AddDataroomModal>\n                ) : (\n                  <UpgradePlanModal\n                    clickedPlan={PlanEnum.DataRooms}\n                    trigger=\"datarooms\"\n                  >\n                    <Button\n                      className=\"group flex flex-1 items-center justify-start gap-x-3 px-3 text-left\"\n                      title=\"Upgrade to Add Data Room\"\n                    >\n                      <span>Upgrade to Add Data Room</span>\n                    </Button>\n                  </UpgradePlanModal>\n                )}\n              </div>\n            ) : isBusiness || isDatarooms || isDataroomsPlus ? (\n              <AddDataroomModal>\n                <Button\n                  className=\"group flex flex-1 items-center justify-start gap-x-3 px-3 text-left\"\n                  title=\"Create New Document\"\n                >\n                  <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n                  <span>Create New Dataroom</span>\n                </Button>\n              </AddDataroomModal>\n            ) : (\n              <DataroomTrialModal>\n                <Button\n                  className=\"group flex flex-1 items-center justify-start gap-x-3 px-3 text-left\"\n                  title=\"Start Data Room Trial\"\n                >\n                  <span>Start Data Room Trial</span>\n                </Button>\n              </DataroomTrialModal>\n            )}\n          </div>\n        </section>\n        {/* Search and Filters */}\n        <div className=\"mb-4 flex justify-end gap-3\">\n          <div className=\"w-full sm:w-[280px]\">\n            <SearchBoxPersisted\n              placeholder=\"Search datarooms...\"\n              inputClassName=\"h-10\"\n            />\n          </div>\n\n          <div className=\"w-full sm:w-[320px]\">\n            <MultiSelect\n              options={tagOptions}\n              value={selectedTagValues}\n              onValueChange={(value) =>\n                setTagsFilter(value.length > 0 ? value : null)\n              }\n              placeholder=\"Tags\"\n              maxCount={2}\n              searchPlaceholder=\"Search tags...\"\n              isPopoverOpen={isTagsPopoverOpen}\n              setIsPopoverOpen={setIsTagsPopoverOpen}\n              popoverClassName=\"sm:w-[320px]\"\n            />\n          </div>\n        </div>\n\n        {hasActiveFilters && (\n          <div className=\"mb-4 flex items-center gap-2 text-sm text-muted-foreground\">\n            <span>\n              Showing {sortedDatarooms?.length || 0} of {totalDatarooms}{\" \"}\n              dataroom\n              {totalDatarooms !== 1 ? \"s\" : \"\"}\n            </span>\n            <Button\n              variant=\"link\"\n              size=\"sm\"\n              className=\"h-auto p-0 text-xs\"\n              onClick={() => {\n                router.push(\n                  {\n                    pathname: router.pathname,\n                    query: {},\n                  },\n                  undefined,\n                  { shallow: true },\n                );\n              }}\n            >\n              Clear filters\n            </Button>\n          </div>\n        )}\n\n        <Separator className=\"mb-5 bg-gray-200 dark:bg-gray-800\" />\n\n        <div className=\"space-y-4\">\n          <ul className=\"grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-2 xl:grid-cols-3\">\n            {sortedDatarooms &&\n              sortedDatarooms.map((dataroom) => (\n                <li key={dataroom.id}>\n                  <DataroomCard dataroom={dataroom} />\n                </li>\n              ))}\n          </ul>\n\n          {sortedDatarooms && sortedDatarooms.length === 0 && (\n            <div className=\"flex items-center justify-center\">\n              <EmptyDataroom />\n            </div>\n          )}\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/documents/[id]/index.tsx",
    "content": "// Lazy load heavy components for better performance\nimport dynamic from \"next/dynamic\";\nimport ErrorPage from \"next/error\";\n\nimport { Suspense, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\n\nimport { useDocumentLinks } from \"@/lib/swr/use-document\";\nimport { useDocumentOverview } from \"@/lib/swr/use-document-overview\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport DocumentHeader from \"@/components/documents/document-header\";\nimport { DocumentPreviewButton } from \"@/components/documents/document-preview-button\";\n// Import placeholder components\nimport DocumentStatsPlaceholder from \"@/components/documents/document-stats-placeholder\";\nimport LinkDocumentIndicator from \"@/components/documents/link-document-indicator\";\nimport NotionAccessibilityIndicator from \"@/components/documents/notion-accessibility-indicator\";\nimport VideoStatsPlaceholder from \"@/components/documents/video-stats-placeholder\";\nimport AppLayout from \"@/components/layouts/app\";\nimport LinkSheet from \"@/components/links/link-sheet\";\nimport LinksTable from \"@/components/links/links-table\";\nimport { Button } from \"@/components/ui/button\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\n\nconst StatsComponent = dynamic(\n  () =>\n    import(\"@/components/documents/stats\").then((mod) => ({\n      default: mod.StatsComponent,\n    })),\n  {\n    loading: () => (\n      <div className=\"flex h-48 animate-pulse items-center justify-center rounded-lg bg-gray-100\">\n        <LoadingSpinner className=\"h-6 w-6\" />\n      </div>\n    ),\n    ssr: false,\n  },\n);\n\nconst VideoAnalytics = dynamic(\n  () => import(\"@/components/documents/video-analytics\"),\n  {\n    loading: () => (\n      <div className=\"flex h-48 animate-pulse items-center justify-center rounded-lg bg-gray-100\">\n        <LoadingSpinner className=\"h-6 w-6\" />\n      </div>\n    ),\n    ssr: false,\n  },\n);\n\nconst VisitorsTable = dynamic(\n  () => import(\"@/components/visitors/visitors-table\"),\n  {\n    loading: () => (\n      <div className=\"flex h-64 animate-pulse items-center justify-center rounded-lg bg-gray-100\">\n        <LoadingSpinner className=\"h-6 w-6\" />\n      </div>\n    ),\n    ssr: false,\n  },\n);\n\nexport default function DocumentPage() {\n  const {\n    data: overview,\n    document: prismaDocument,\n    primaryVersion,\n    limits,\n    team,\n    isEmpty,\n    loading: overviewLoading,\n    error,\n    mutate: mutateOverview,\n  } = useDocumentOverview();\n\n  // Always fetch links to show empty states properly\n  const { links, error: linksError, mutate: mutateLinks } = useDocumentLinks();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const [isLinkSheetOpen, setIsLinkSheetOpen] = useState<boolean>(false);\n\n  // Mutate function that updates both overview and links\n  const mutateDocument = () => {\n    mutateOverview();\n    mutateLinks();\n  };\n\n  if (error && error.status === 400) {\n    return <ErrorPage statusCode={400} />;\n  }\n\n  const AddLinkButton = () => {\n    if (!limits?.canAddLinks) {\n      return (\n        <UpgradePlanModal\n          clickedPlan={team?.isTrial ? PlanEnum.Business : PlanEnum.Pro}\n          trigger={\"limit_add_link\"}\n        >\n          <Button className=\"flex h-8 whitespace-nowrap text-xs lg:h-9 lg:text-sm\">\n            Upgrade to Create Link\n          </Button>\n        </UpgradePlanModal>\n      );\n    } else {\n      return (\n        <Button\n          className=\"flex h-8 whitespace-nowrap text-xs lg:h-9 lg:text-sm\"\n          onClick={() => setIsLinkSheetOpen(true)}\n        >\n          Create Link\n        </Button>\n      );\n    }\n  };\n\n  // Show loading only for the initial overview load\n  if (overviewLoading) {\n    return (\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n          <div className=\"flex h-screen items-center justify-center\">\n            <LoadingSpinner className=\"mr-1 h-20 w-20\" />\n          </div>\n        </main>\n      </AppLayout>\n    );\n  }\n\n  if (!prismaDocument || !primaryVersion || !teamId) {\n    return (\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n          <div className=\"flex h-screen items-center justify-center\">\n            <LoadingSpinner className=\"mr-1 h-20 w-20\" />\n          </div>\n        </main>\n      </AppLayout>\n    );\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        {/* Action Header - Shows immediately */}\n        <DocumentHeader\n          primaryVersion={primaryVersion}\n          prismaDocument={prismaDocument}\n          teamId={teamId}\n          actions={[\n            <NotionAccessibilityIndicator\n              key={\"notion-status\"}\n              documentId={prismaDocument.id}\n              primaryVersion={primaryVersion}\n              onUrlUpdate={mutateDocument}\n            />,\n            <LinkDocumentIndicator\n              key={\"link-status\"}\n              documentId={prismaDocument.id}\n              primaryVersion={primaryVersion}\n              onUrlUpdate={mutateDocument}\n            />,\n            <DocumentPreviewButton\n              key={\"preview\"}\n              documentId={prismaDocument.id}\n              primaryVersion={primaryVersion}\n              advancedExcelEnabled={prismaDocument.advancedExcelEnabled}\n              variant=\"outline\"\n              size=\"default\"\n              showTooltip={false}\n              className=\"h-8 whitespace-nowrap text-xs lg:h-9 lg:text-sm\"\n            />,\n            <AddLinkButton key={\"create-link\"} />,\n          ]}\n        />\n\n        {/* Progressive Loading: Always show components, but optimize for empty states */}\n        <Suspense\n          fallback={\n            <div className=\"h-48 animate-pulse rounded-lg bg-gray-100\" />\n          }\n        >\n          <>\n            {/* Document Analytics - Always show, lazy loaded if not empty */}\n            {primaryVersion.type !== \"video\" &&\n              (isEmpty ? (\n                <DocumentStatsPlaceholder\n                  numPages={primaryVersion.numPages || 1}\n                  onCreateLink={() => setIsLinkSheetOpen(true)}\n                />\n              ) : (\n                <StatsComponent\n                  documentId={prismaDocument.id}\n                  numPages={primaryVersion.numPages!}\n                />\n              ))}\n\n            {/* Video Analytics - Always show, lazy loaded if not empty */}\n            {primaryVersion.type === \"video\" &&\n              (isEmpty ? (\n                <VideoStatsPlaceholder\n                  length={primaryVersion.length || 51}\n                  onCreateLink={() => setIsLinkSheetOpen(true)}\n                />\n              ) : (\n                <VideoAnalytics\n                  documentId={prismaDocument.id}\n                  primaryVersion={primaryVersion}\n                  teamId={teamId}\n                />\n              ))}\n\n            {/* Links - Always show */}\n            <LinksTable\n              links={links}\n              targetType={\"DOCUMENT\"}\n              primaryVersion={primaryVersion}\n              mutateDocument={mutateDocument}\n            />\n\n            {/* Visitors - Always show */}\n            <VisitorsTable\n              primaryVersion={primaryVersion}\n              isVideo={primaryVersion.type === \"video\"}\n            />\n          </>\n        </Suspense>\n\n        <LinkSheet\n          isOpen={isLinkSheetOpen}\n          linkType=\"DOCUMENT_LINK\"\n          setIsOpen={setIsLinkSheetOpen}\n          existingLinks={links}\n        />\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/documents/hidden.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ArrowLeftIcon, EyeOffIcon } from \"lucide-react\";\n\nimport { useHiddenDocuments } from \"@/lib/swr/use-documents\";\n\nimport { HiddenDocumentsList } from \"@/components/documents/hidden-documents-list\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\n\nexport default function HiddenDocumentsPage() {\n  const teamInfo = useTeam();\n  const { folders, documents, loading } = useHiddenDocuments();\n\n  return (\n    <AppLayout>\n      <div className=\"sticky top-0 mb-4 min-h-[calc(100vh-72px)] rounded-lg bg-white p-4 dark:bg-gray-900 sm:mx-4 sm:pt-8\">\n        <section className=\"mb-4 flex items-center justify-between space-x-2 sm:space-x-0\">\n          <div className=\"space-y-0 sm:space-y-1\">\n            <div className=\"flex items-center gap-x-2\">\n              <Link href=\"/documents\">\n                <Button variant=\"ghost\" size=\"icon\" className=\"h-8 w-8\">\n                  <ArrowLeftIcon className=\"h-4 w-4\" />\n                </Button>\n              </Link>\n              <div className=\"flex items-center gap-x-2\">\n                <EyeOffIcon className=\"h-6 w-6 text-muted-foreground\" />\n                <h2 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n                  Hidden Documents\n                </h2>\n              </div>\n            </div>\n            <p className=\"ml-10 text-xs leading-4 text-muted-foreground sm:text-sm sm:leading-none\">\n              Documents and folders hidden from All Documents. You can unhide\n              them to show them again.\n            </p>\n          </div>\n        </section>\n\n        <section id=\"documents-header-count\" />\n\n        <Separator className=\"mb-5 bg-gray-200 dark:bg-gray-800\" />\n\n        <HiddenDocumentsList\n          documents={documents}\n          folders={folders}\n          teamInfo={teamInfo}\n          loading={loading}\n          foldersLoading={loading}\n        />\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/documents/index.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport Link from \"next/link\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { EyeOffIcon, FolderPlusIcon, PlusIcon } from \"lucide-react\";\n\nimport useDocuments, { useHiddenDocuments, useRootFolders } from \"@/lib/swr/use-documents\";\nimport { handleInvitationStatus } from \"@/lib/utils\";\n\nimport { AddDocumentModal } from \"@/components/documents/add-document-modal\";\nimport { DocumentsList } from \"@/components/documents/documents-list\";\nimport SortButton from \"@/components/documents/filters/sort-button\";\nimport { Pagination } from \"@/components/documents/pagination\";\nimport { AddFolderModal } from \"@/components/folders/add-folder-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { SearchBoxPersisted } from \"@/components/search-box\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\n\nexport default function Documents() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const queryParams = router.query;\n  const currentPage = Number(queryParams[\"page\"]) || 1;\n  const pageSize = Number(queryParams[\"limit\"]) || 10;\n  const invitation = queryParams[\"invitation\"] as \"accepted\" | \"teamMember\";\n\n  // Handle invitation status\n  if (invitation) {\n    handleInvitationStatus(invitation, queryParams, router);\n  }\n\n  const { folders, loading: foldersLoading } = useRootFolders();\n  const {\n    documents,\n    searchFolders,\n    pagination,\n    isValidating,\n    isFiltered,\n    loading,\n  } = useDocuments();\n  const { folders: hiddenFolders, documents: hiddenDocuments } =\n    useHiddenDocuments();\n\n  const hasHiddenItems =\n    (hiddenFolders && hiddenFolders.length > 0) ||\n    (hiddenDocuments && hiddenDocuments.length > 0);\n\n  const updatePagination = (newPage?: number, newPageSize?: number) => {\n    const params = new URLSearchParams(window.location.search);\n\n    if (newPage) params.set(\"page\", newPage.toString());\n    if (newPageSize) {\n      params.set(\"limit\", newPageSize.toString());\n      params.set(\"page\", \"1\");\n    }\n\n    router.push(`/documents?${params.toString()}`, undefined, {\n      shallow: true,\n    });\n  };\n\n  const displayFolders = isFiltered ? searchFolders ?? [] : folders;\n\n  return (\n    <AppLayout>\n      <div className=\"sticky top-0 mb-4 min-h-[calc(100vh-72px)] rounded-lg bg-white p-4 dark:bg-gray-900 sm:mx-4 sm:pt-8\">\n        <section className=\"mb-4 flex items-center justify-between space-x-2 sm:space-x-0\">\n          <div className=\"space-y-0 sm:space-y-1\">\n            <h2 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n              All Documents\n            </h2>\n            <p className=\"text-xs leading-4 text-muted-foreground sm:text-sm sm:leading-none\">\n              Manage all your documents in one place.\n            </p>\n          </div>\n          <div className=\"flex items-center gap-x-2\">\n            <AddDocumentModal>\n              <Button\n                className=\"group flex flex-1 items-center justify-start gap-x-1 whitespace-nowrap px-1 text-left sm:gap-x-3 sm:px-3\"\n                title=\"Add Document\"\n              >\n                <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n                <span className=\"text-xs sm:text-base\">Add Document</span>\n              </Button>\n            </AddDocumentModal>\n            <AddFolderModal>\n              <Button\n                size=\"icon\"\n                variant=\"outline\"\n                className=\"border-gray-500 bg-gray-50 hover:bg-gray-200 dark:bg-black hover:dark:bg-muted\"\n              >\n                <FolderPlusIcon\n                  className=\"h-5 w-5 shrink-0\"\n                  aria-hidden=\"true\"\n                />\n              </Button>\n            </AddFolderModal>\n          </div>\n        </section>\n\n        <div className=\"mb-2 flex justify-end gap-x-2\">\n          <div className=\"relative w-full sm:max-w-xs\">\n            <SearchBoxPersisted loading={isValidating} inputClassName=\"h-10\" />\n          </div>\n          <SortButton />\n          {hasHiddenItems && (\n            <Link href=\"/documents/hidden\" aria-label=\"View hidden documents\">\n              <Button\n                variant=\"outline\"\n                size=\"icon\"\n                className=\"border-gray-500 bg-gray-50 hover:bg-gray-200 dark:bg-black hover:dark:bg-muted\"\n              >\n                <EyeOffIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n              </Button>\n            </Link>\n          )}\n        </div>\n\n        <section id=\"documents-header-count\" />\n\n        <Separator className=\"mb-5 bg-gray-200 dark:bg-gray-800\" />\n\n        <DocumentsList\n          documents={documents}\n          folders={displayFolders}\n          teamInfo={teamInfo}\n          loading={loading}\n          foldersLoading={isFiltered ? loading : foldersLoading}\n        />\n\n        {isFiltered && pagination && (\n          <Pagination\n            currentPage={currentPage}\n            pageSize={pageSize}\n            totalItems={pagination.total}\n            totalShownItems={documents.length}\n            totalPages={pagination.pages}\n            onPageChange={updatePagination}\n            onPageSizeChange={(size) => updatePagination(undefined, size)}\n            itemName=\"documents\"\n            extraInfo={\n              searchFolders && searchFolders.length > 0\n                ? `${searchFolders.length} folder${searchFolders.length > 1 ? \"s\" : \"\"} found`\n                : undefined\n            }\n          />\n        )}\n      </div>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/documents/new.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { ArrowLeft as ArrowLeftIcon } from \"lucide-react\";\nimport { AnimatePresence } from \"motion/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport Dataroom from \"@/components/welcome/dataroom\";\nimport DataroomTrial from \"@/components/welcome/dataroom-trial\";\nimport Intro from \"@/components/welcome/intro\";\nimport Next from \"@/components/welcome/next\";\nimport DeckGeneratorUpload from \"@/components/welcome/special-upload\";\nimport Upload from \"@/components/welcome/upload\";\n\nexport default function DocumentNew() {\n  const router = useRouter();\n\n  return (\n    <div className=\"mx-auto flex h-screen max-w-3xl flex-col items-center justify-center overflow-x-hidden\">\n      <div\n        className=\"absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl\"\n        aria-hidden=\"true\"\n      >\n        <div\n          className=\"aspect-[1108/632] w-[69.25rem] flex-none bg-gradient-to-r from-[#80caff] to-[#4f46e5] opacity-20\"\n          style={{\n            clipPath:\n              \"polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)\",\n          }}\n        />\n      </div>\n      <AnimatePresence mode=\"wait\">\n        {router.query.type ? (\n          <>\n            <button\n              className=\"group absolute left-2 top-10 z-40 rounded-full p-2 transition-all hover:bg-gray-400 sm:left-10\"\n              onClick={() => router.back()}\n            >\n              <ArrowLeftIcon className=\"h-8 w-8 text-gray-500 group-hover:text-gray-800 group-active:scale-90\" />\n            </button>\n\n            <Button\n              variant={\"link\"}\n              onClick={() => router.push(\"/documents\")}\n              className=\"absolute right-2 top-10 z-40 p-2 text-muted-foreground sm:right-10\"\n            >\n              Skip to dashboard\n            </Button>\n          </>\n        ) : (\n          <DeckGeneratorUpload key=\"document\" />\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
  },
  {
    "path": "pages/documents/tree/[...name].tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { FolderPlusIcon, PlusIcon } from \"lucide-react\";\n\nimport { AddDocumentModal } from \"@/components/documents/add-document-modal\";\nimport { DocumentsList } from \"@/components/documents/documents-list\";\nimport { AddFolderModal } from \"@/components/folders/add-folder-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { Button } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\n\nimport { useFolder, useFolderDocuments } from \"@/lib/swr/use-documents\";\n\nexport default function DocumentTreePage() {\n  const router = useRouter();\n  const { name } = router.query as { name: string[] };\n\n  const { folders, loading: foldersLoading } = useFolder({ name });\n  const { documents, loading } = useFolderDocuments({ name });\n  const teamInfo = useTeam();\n\n  return (\n    <AppLayout>\n      <main className=\"p-4 sm:m-4 sm:px-4 sm:py-4\">\n        <section className=\"mb-4 mt-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n          <div className=\"space-y-1\">\n            <h2 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n              All Documents\n            </h2>\n            <p className=\"text-xs text-muted-foreground sm:text-sm\">\n              Manage all your documents in one place.\n            </p>\n          </div>\n          <div className=\"flex items-center gap-x-2\">\n            <AddDocumentModal>\n              <Button className=\"gap-x-3 px-3\" title=\"Add Document\">\n                <PlusIcon className=\"h-5 w-5 shrink-0\" aria-hidden=\"true\" />\n                <span>Add Document</span>\n              </Button>\n            </AddDocumentModal>\n            <AddFolderModal>\n              <Button\n                // size=\"icon\"\n                variant=\"outline\"\n                className=\"gap-x-3 px-3\"\n              >\n                <FolderPlusIcon\n                  className=\"h-5 w-5 shrink-0\"\n                  aria-hidden=\"true\"\n                />\n                <span>Add Folder</span>\n              </Button>\n            </AddFolderModal>\n          </div>\n        </section>\n\n        {/* Portaled in from DocumentsList component */}\n        <section id=\"documents-header-count\" />\n\n        <Separator className=\"mb-5 bg-gray-200 dark:bg-gray-800\" />\n\n        <DocumentsList\n          documents={documents}\n          folders={folders}\n          teamInfo={teamInfo}\n          folderPathName={name}\n          loading={loading}\n          foldersLoading={foldersLoading}\n        />\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/entrance_ppreview_demo.tsx",
    "content": "import { useRouter } from \"next/router\";\nimport type { CSSProperties } from \"react\";\n\nimport { createAdaptiveSurfacePalette } from \"@/lib/utils/create-adaptive-surface-palette\";\n\nimport { Button } from \"@/components/ui/button\";\n\nexport default function ViewPage() {\n  const router = useRouter();\n  const { accentColor, welcomeMessage } = router.query as {\n    accentColor: string;\n    welcomeMessage?: string;\n  };\n  const palette = createAdaptiveSurfacePalette(accentColor);\n\n  return (\n    <div className=\"bg-gray-950\" style={{ backgroundColor: accentColor }}>\n      <div className=\"mx-auto px-2 sm:px-6 lg:px-8\">\n        <div className=\"relative flex h-16 items-center justify-between\">\n          <div className=\"mt-20 flex flex-1 items-stretch justify-center\"></div>\n        </div>\n        <div className=\"sm:mx-auto sm:w-full sm:max-w-md\">\n          <h1\n            className=\"mt-16 text-2xl font-bold leading-9 tracking-tight text-white\"\n            style={{\n              color: palette.textColor,\n            }}\n          >\n            {welcomeMessage || \"Your action is requested to continue\"}\n          </h1>\n        </div>\n\n        <div className=\"mt-10 sm:mx-auto sm:w-full sm:max-w-md\">\n          <form className=\"space-y-4\">\n            <div className=\"pb-5\">\n              <div className=\"relative space-y-2 rounded-md shadow-sm\">\n                <label\n                  htmlFor=\"email\"\n                  className=\"block text-sm font-medium leading-6 text-white\"\n                  style={{\n                    color: palette.textColor,\n                  }}\n                >\n                  Email address\n                </label>\n                <input\n                  name=\"email\"\n                  id=\"email\"\n                  type=\"email\"\n                  autoCorrect=\"off\"\n                  autoComplete=\"email\"\n                  autoFocus\n                  className=\"flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6\"\n                  style={{\n                    backgroundColor: palette.controlBgColor,\n                    borderColor: palette.controlBorderColor,\n                    \"--access-placeholder\": palette.controlPlaceholderColor,\n                    \"--access-input-focus\": palette.controlBorderStrongColor,\n                    color: palette.textColor,\n                  } as CSSProperties}\n                  placeholder=\"Enter email\"\n                  aria-invalid=\"true\"\n                  data-1p-ignore\n                />\n                <p className=\"text-sm\" style={{ color: palette.subtleTextColor }}>\n                  This data will be shared with the sender.\n                </p>\n              </div>\n            </div>\n\n            <div className=\"flex justify-center\">\n              <Button\n                type=\"submit\"\n                className=\"w-1/3 min-w-fit bg-white text-gray-950 hover:bg-white/90\"\n              >\n                Continue\n              </Button>\n            </div>\n          </form>\n        </div>\n      </div>\n      {/* </nav> */}\n\n      {/* Body */}\n      <div\n        style={{ height: \"calc(100vh - 64px)\" }}\n        className=\"relative flex items-center\"\n      >\n        <div className=\"relative mx-auto flex h-full w-full justify-center\"></div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "pages/nav_ppreview_demo.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\n\nexport default function ViewPage() {\n  const router = useRouter();\n  const { brandLogo, brandColor, accentColor } = router.query as {\n    brandLogo: string;\n    brandColor: string;\n    accentColor: string;\n  };\n\n  return (\n    <div className=\"bg-gray-950\" style={{ backgroundColor: accentColor }}>\n      {/* Nav */}\n      <nav\n        className=\"bg-black\"\n        style={{\n          backgroundColor: brandColor,\n        }}\n      >\n        <div className=\"mx-auto px-2 sm:px-6 lg:px-8\">\n          <div className=\"relative flex h-16 items-center justify-between\">\n            <div className=\"flex flex-1 items-stretch justify-start\">\n              <div className=\"relative flex h-16 w-36 flex-shrink-0 items-center overflow-y-hidden\">\n                {brandLogo ? (\n                  <img\n                    className=\"w-full object-contain\"\n                    src={brandLogo}\n                    alt=\"Logo\"\n                  />\n                ) : (\n                  <div className=\"text-2xl font-bold tracking-tighter text-white\">\n                    Papermark\n                  </div>\n                )}\n              </div>\n            </div>\n            <div className=\"absolute inset-y-0 right-0 flex items-center space-x-4 pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0\">\n              <div className=\"flex h-10 items-center rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white\">\n                <span>1</span>\n                <span className=\"text-gray-400\"> / 13</span>\n              </div>\n            </div>\n          </div>\n        </div>\n      </nav>\n\n      {/* Body */}\n      <div\n        style={{ height: \"calc(100vh - 64px)\" }}\n        className=\"relative flex items-center\"\n      >\n        <div className=\"absolute z-10 flex w-full items-center justify-between px-2\">\n          <button className=\"h-[calc(100vh - 64px)] relative px-2 py-24 focus:z-20\">\n            <span className=\"sr-only\">Previous</span>\n            <div className=\"relative flex items-center justify-center rounded-full bg-gray-950/50 p-1 hover:bg-gray-950/75\">\n              <ChevronLeftIcon\n                className=\"h-10 w-10 text-white\"\n                aria-hidden=\"true\"\n              />\n            </div>\n          </button>\n          <button className=\"h-[calc(100vh - 64px)] relative px-2 py-24 focus:z-20\">\n            <span className=\"sr-only\">Next</span>\n            <div className=\"relative flex items-center justify-center rounded-full bg-gray-950/50 p-1 hover:bg-gray-950/75\">\n              <ChevronRightIcon\n                className=\"h-10 w-10 text-white\"\n                aria-hidden=\"true\"\n              />\n            </div>\n          </button>\n        </div>\n\n        <div className=\"relative mx-auto flex h-full w-full justify-center\">\n          <img\n            className=\"mx-auto block object-contain\"\n            src={\"/_example/papermark-example-page.png\"}\n            alt={`Demo Page 1`}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "pages/notification-preferences.tsx",
    "content": "import { GetServerSidePropsContext, InferGetServerSidePropsType } from \"next\";\nimport Head from \"next/head\";\nimport Image from \"next/image\";\n\nimport { useCallback, useState } from \"react\";\n\nimport {\n  BellOffIcon,\n  BellRingIcon,\n  CalendarClockIcon,\n  CheckIcon,\n  ClockIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { motion } from \"motion/react\";\n\nimport PapermarkLogo from \"@/public/_static/papermark-logo.svg\";\n\nimport { Button } from \"@/components/ui/button\";\n\nimport prisma from \"@/lib/prisma\";\nimport { verifyUnsubscribeToken } from \"@/lib/utils/unsubscribe\";\nimport { ZViewerNotificationPreferencesSchema } from \"@/lib/zod/schemas/notifications\";\n\ntype FrequencyOption = \"instant\" | \"daily\" | \"weekly\" | \"disabled\";\n\nconst FREQUENCY_OPTIONS: {\n  value: FrequencyOption;\n  label: string;\n  description: string;\n  icon: typeof BellRingIcon;\n}[] = [\n  {\n    value: \"instant\",\n    label: \"Every update\",\n    description: \"Get notified immediately when new documents are added\",\n    icon: BellRingIcon,\n  },\n  {\n    value: \"daily\",\n    label: \"Daily digest\",\n    description: \"Receive a summary of changes once per day at 9 AM UTC\",\n    icon: ClockIcon,\n  },\n  {\n    value: \"weekly\",\n    label: \"Weekly digest\",\n    description: \"Receive a summary of changes every Monday at 9 AM UTC\",\n    icon: CalendarClockIcon,\n  },\n  {\n    value: \"disabled\",\n    label: \"Unsubscribed\",\n    description: \"Stop receiving notifications for this dataroom\",\n    icon: BellOffIcon,\n  },\n];\n\nexport async function getServerSideProps(context: GetServerSidePropsContext) {\n  const token = context.query.token as string | undefined;\n\n  if (!token) {\n    return { props: { error: \"Token is required\", token: \"\", data: null } };\n  }\n\n  const payload = verifyUnsubscribeToken(token);\n  if (!payload || !payload.dataroomId) {\n    return {\n      props: { error: \"Invalid or expired token\", token, data: null },\n    };\n  }\n\n  if (payload.exp && payload.exp < Date.now() / 1000) {\n    return { props: { error: \"Token expired\", token, data: null } };\n  }\n\n  const { viewerId, dataroomId, teamId } = payload;\n\n  try {\n    const [viewer, dataroom] = await Promise.all([\n      prisma.viewer.findUnique({\n        where: { id: viewerId, teamId },\n        select: { notificationPreferences: true },\n      }),\n      prisma.dataroom.findUnique({\n        where: { id: dataroomId, teamId },\n        select: { name: true },\n      }),\n    ]);\n\n    if (!viewer) {\n      return { props: { error: \"Viewer not found\", token, data: null } };\n    }\n\n    const parsedPreferences = ZViewerNotificationPreferencesSchema.safeParse(\n      viewer.notificationPreferences,\n    );\n\n    const dataroomPrefs = parsedPreferences.success\n      ? parsedPreferences.data.dataroom[dataroomId]\n      : undefined;\n\n    let currentFrequency: FrequencyOption;\n    if (dataroomPrefs?.enabled === false) {\n      currentFrequency = \"disabled\";\n    } else {\n      currentFrequency = dataroomPrefs?.frequency ?? \"instant\";\n    }\n\n    return {\n      props: {\n        error: null,\n        token,\n        data: {\n          dataroomName: dataroom?.name ?? \"Unknown Dataroom\",\n          currentFrequency,\n        },\n      },\n    };\n  } catch {\n    return {\n      props: { error: \"Failed to load preferences\", token, data: null },\n    };\n  }\n}\n\nexport default function NotificationPreferencesPage({\n  error: serverError,\n  token,\n  data,\n}: InferGetServerSidePropsType<typeof getServerSideProps>) {\n  const [saving, setSaving] = useState(false);\n  const [selected, setSelected] = useState<FrequencyOption>(\n    data?.currentFrequency ?? \"instant\",\n  );\n  const [status, setStatus] = useState<\"idle\" | \"success\" | \"error\">(\n    serverError ? \"error\" : \"idle\",\n  );\n  const [errorMsg, setErrorMsg] = useState(serverError ?? \"\");\n\n  const handleSave = useCallback(async () => {\n    if (!token) return;\n    setSaving(true);\n    try {\n      const res = await fetch(\n        `/api/notification-preferences/dataroom?token=${token}`,\n        {\n          method: \"POST\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify({ frequency: selected }),\n        },\n      );\n      if (!res.ok) {\n        const err = await res.json();\n        throw new Error(err.message || \"Failed to save preferences\");\n      }\n      setStatus(\"success\");\n    } catch (err) {\n      setErrorMsg((err as Error).message);\n      setStatus(\"error\");\n    } finally {\n      setSaving(false);\n    }\n  }, [token, selected]);\n\n  const hasChanged = data ? selected !== data?.currentFrequency : false;\n\n  return (\n    <>\n      <Head>\n        <title>Notification Preferences | Papermark</title>\n      </Head>\n      <div className=\"flex min-h-screen flex-col bg-gray-50\">\n        <header className=\"px-6 py-5\">\n          <a\n            href=\"https://www.papermark.com\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n          >\n            <Image\n              src={PapermarkLogo}\n              width={119}\n              height={32}\n              alt=\"Papermark\"\n            />\n          </a>\n        </header>\n\n        <div className=\"flex flex-1 items-start justify-center px-4 pt-[calc(10vh)]\">\n          <motion.div\n            initial={{ opacity: 0, y: 8 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}\n            className=\"w-full max-w-md\"\n          >\n            <div className=\"overflow-hidden rounded-lg border border-border bg-white shadow-sm\">\n              <div className=\"px-6 pb-4 pt-6\">\n                <h1 className=\"text-lg font-semibold text-foreground\">\n                  Notification Preferences\n                </h1>\n                {data ? (\n                  <p className=\"mt-1 text-sm text-muted-foreground\">\n                    Choose how often you want to hear about updates to{\" \"}\n                    <span className=\"font-medium text-foreground\">\n                      {data.dataroomName}\n                    </span>\n                  </p>\n                ) : null}\n              </div>\n\n              <div className=\"px-6 pb-6\">\n                {status === \"error\" && !data ? (\n                  <div className=\"py-12 text-center\">\n                    <div className=\"mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10\">\n                      <XIcon className=\"h-5 w-5 text-destructive\" />\n                    </div>\n                    <p className=\"text-sm font-medium text-foreground\">\n                      Something went wrong\n                    </p>\n                    <p className=\"mt-1 text-sm text-muted-foreground\">\n                      {errorMsg}\n                    </p>\n                  </div>\n                ) : status === \"success\" ? (\n                  <motion.div\n                    initial={{ opacity: 0, scale: 0.98 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    transition={{ duration: 0.25 }}\n                    className=\"py-12 text-center\"\n                  >\n                    <div className=\"mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100\">\n                      <CheckIcon className=\"h-5 w-5 text-emerald-600\" />\n                    </div>\n                    <p className=\"text-sm font-medium text-foreground\">\n                      Preferences saved\n                    </p>\n                    <p className=\"mx-auto mt-1.5 max-w-xs text-sm text-muted-foreground\">\n                      {selected === \"disabled\"\n                        ? \"You've unsubscribed from notifications for this dataroom.\"\n                        : `You'll receive ${selected === \"instant\" ? \"instant\" : `${selected} digest`} notifications.`}\n                    </p>\n                    <p className=\"mt-6 text-xs text-muted-foreground/60\">\n                      You can close this window now.\n                    </p>\n                  </motion.div>\n                ) : (\n                  <>\n                    <div className=\"space-y-2\">\n                      {FREQUENCY_OPTIONS.map((option) => {\n                        const isSelected = selected === option.value;\n                        const Icon = option.icon;\n                        return (\n                          <button\n                            key={option.value}\n                            onClick={() => {\n                              setSelected(option.value);\n                              setStatus(\"idle\");\n                            }}\n                            className={`group flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors ${\n                              isSelected\n                                ? \"border-foreground bg-foreground/[0.03]\"\n                                : \"border-border bg-white hover:bg-muted/50\"\n                            }`}\n                          >\n                            <Icon\n                              className={`h-4 w-4 flex-shrink-0 ${\n                                isSelected\n                                  ? \"text-foreground\"\n                                  : \"text-muted-foreground\"\n                              }`}\n                            />\n                            <div className=\"min-w-0 flex-1\">\n                              <div\n                                className={`text-sm font-medium ${\n                                  isSelected\n                                    ? \"text-foreground\"\n                                    : \"text-foreground\"\n                                }`}\n                              >\n                                {option.label}\n                              </div>\n                              <div className=\"mt-0.5 text-xs text-muted-foreground\">\n                                {option.description}\n                              </div>\n                            </div>\n                            <div\n                              className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border transition-colors ${\n                                isSelected\n                                  ? \"border-foreground bg-foreground\"\n                                  : \"border-muted-foreground/40 bg-white\"\n                              }`}\n                            >\n                              {isSelected ? (\n                                <CheckIcon className=\"h-2.5 w-2.5 text-white\" />\n                              ) : null}\n                            </div>\n                          </button>\n                        );\n                      })}\n                    </div>\n\n                    {status === \"error\" && data ? (\n                      <p className=\"mt-3 text-center text-sm text-destructive\">\n                        {errorMsg}\n                      </p>\n                    ) : null}\n\n                    <Button\n                      onClick={handleSave}\n                      disabled={saving || !hasChanged}\n                      loading={saving}\n                      className=\"mt-4 w-full\"\n                    >\n                      Save preferences\n                    </Button>\n                  </>\n                )}\n              </div>\n            </div>\n\n            <p className=\"mt-4 text-center text-xs text-muted-foreground/60\">\n              Powered by{\" \"}\n              <a\n                href=\"https://www.papermark.com\"\n                className=\"underline underline-offset-2 transition-colors hover:text-muted-foreground\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n              >\n                Papermark\n              </a>\n            </p>\n          </motion.div>\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "pages/room_ppreview_demo.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { ViewFolderTree } from \"@/components/datarooms/folders\";\nimport DocumentCard from \"@/components/view/dataroom/document-card\";\nimport FolderCard from \"@/components/view/dataroom/folder-card\";\nimport {\n  ViewerSurfaceThemeProvider,\n  createViewerSurfaceTheme,\n} from \"@/components/view/viewer/viewer-surface-theme\";\n\nconst DEFAULT_BANNER_IMAGE = \"/_static/papermark-banner.png\";\n\nexport default function ViewPage() {\n  const router = useRouter();\n  const {\n    brandLogo,\n    brandColor,\n    brandBanner,\n    accentColor,\n    applyAccentColorToDataroomView,\n  } = router.query as {\n    brandLogo: string;\n    brandColor: string;\n    brandBanner: string;\n    accentColor: string;\n    applyAccentColorToDataroomView?: string;\n  };\n\n  const shouldApplyAccentToDataroomView =\n    applyAccentColorToDataroomView === \"1\";\n  const dataroomViewBackgroundColor = shouldApplyAccentToDataroomView\n    ? accentColor\n    : \"#ffffff\";\n  const previewSurfaceTheme = createViewerSurfaceTheme(dataroomViewBackgroundColor);\n\n  return (\n    <div\n      className=\"min-h-screen bg-white\"\n      style={\n        dataroomViewBackgroundColor\n          ? { backgroundColor: dataroomViewBackgroundColor }\n          : undefined\n      }\n    >\n      {/* Nav */}\n      <nav\n        className=\"bg-black\"\n        style={{\n          backgroundColor: brandColor,\n        }}\n      >\n        <div className=\"mx-auto px-2 sm:px-6 lg:px-8\">\n          <div className=\"relative flex h-16 items-center justify-between\">\n            <div className=\"flex flex-1 items-center justify-start\">\n              <div className=\"relative flex h-16 w-36 flex-shrink-0 items-center overflow-y-hidden\">\n                {brandLogo ? (\n                  <img\n                    className=\"w-full object-contain\"\n                    src={brandLogo}\n                    alt=\"Logo\"\n                  />\n                ) : (\n                  <div className=\"text-2xl font-bold tracking-tighter text-white\">\n                    Papermark\n                  </div>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {/* Banner section */}\n        {brandBanner !== \"no-banner\" && (\n          <div className=\"relative h-[30vh]\">\n            <img\n              className=\"h-[30vh] w-full object-cover\"\n              src={brandBanner || DEFAULT_BANNER_IMAGE}\n              alt=\"Banner\"\n              width={1920}\n              height={320}\n            />\n            <div className=\"absolute bottom-5 w-fit rounded-r-md bg-white/30 backdrop-blur-md\">\n              <div className=\"px-5 py-2 sm:px-10\">\n                <div className=\"text-3xl\">Example Data Room</div>\n                <time className=\"text-sm\">Last updated 2 hours ago</time>\n              </div>\n            </div>\n          </div>\n        )}\n      </nav>\n\n      {/* Body */}\n      <ViewerSurfaceThemeProvider value={previewSurfaceTheme}>\n        <div style={{ height: \"calc(100vh - 64px)\" }} className=\"relative flex\">\n          {/* Tree view */}\n          <div\n            className=\"hidden h-full w-1/4 space-y-8 overflow-auto px-3 pb-4 pt-4 md:flex md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14\"\n          >\n            <ViewFolderTree\n              folders={[\n                {\n                  id: \"1\",\n                  name: \"Marketing\",\n                  parentId: null,\n                  dataroomId: \"1\",\n                  orderIndex: 0,\n                  hierarchicalIndex: null,\n                  path: \"/\",\n                  createdAt: new Date(),\n                  updatedAt: new Date(),\n                  icon: null,\n                  color: null,\n                },\n                {\n                  id: \"2\",\n                  name: \"Sales\",\n                  parentId: null,\n                  dataroomId: \"1\",\n                  orderIndex: 1,\n                  hierarchicalIndex: null,\n                  path: \"/\",\n                  createdAt: new Date(),\n                  updatedAt: new Date(),\n                  icon: null,\n                  color: null,\n                },\n              ]}\n              documents={[\n                {\n                  id: \"1\",\n                  name: \"Q4 Report.pdf\",\n                  dataroomDocumentId: \"1\",\n                  folderId: null,\n                  hierarchicalIndex: null,\n                  versions: [\n                    {\n                      id: \"1\",\n                      versionNumber: 1,\n                      hasPages: true,\n                    },\n                  ],\n                },\n              ]}\n              setFolderId={() => {}}\n              folderId={null}\n            />\n          </div>\n\n          {/* Detail view */}\n          <div className=\"flex-grow overflow-auto\">\n            <div className=\"h-full space-y-8 px-3 pb-4 pt-4 md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14\">\n              <div className=\"space-y-4\">\n                <div\n                  className={`text-sm ${previewSurfaceTheme.usesLightText ? \"text-white/70\" : \"text-muted-foreground\"}`}\n                >Home</div>\n                <ul className=\"grid gap-4\">\n                  <li key=\"1\">\n                    <FolderCard\n                      folder={{\n                        id: \"1\",\n                        name: \"Marketing\",\n                        parentId: null,\n                        dataroomId: \"1\",\n                        orderIndex: 0,\n                        hierarchicalIndex: null,\n                        path: \"/\",\n                        createdAt: new Date(),\n                        updatedAt: new Date(),\n                        icon: null,\n                        color: null,\n                      }}\n                      dataroomId=\"1\"\n                      setFolderId={() => {}}\n                      isPreview={false}\n                      linkId=\"1\"\n                      allowDownload={false}\n                    />\n                  </li>\n\n                  <li key=\"2\">\n                    <FolderCard\n                      folder={{\n                        id: \"2\",\n                        name: \"Sales\",\n                        parentId: null,\n                        dataroomId: \"1\",\n                        orderIndex: 1,\n                        hierarchicalIndex: null,\n                        path: \"/\",\n                        createdAt: new Date(),\n                        updatedAt: new Date(),\n                        icon: null,\n                        color: null,\n                      }}\n                      dataroomId=\"1\"\n                      setFolderId={() => {}}\n                      isPreview={false}\n                      linkId=\"1\"\n                      allowDownload={false}\n                    />\n                  </li>\n\n                  <li key=\"3\">\n                    <DocumentCard\n                      document={{\n                        id: \"1\",\n                        name: \"Q4 Report.pdf\",\n                        dataroomDocumentId: \"1\",\n                        downloadOnly: false,\n                        canDownload: false,\n                        hierarchicalIndex: null,\n                        versions: [\n                          {\n                            id: \"1\",\n                            type: \"pdf\",\n                            versionNumber: 1,\n                            hasPages: true,\n                            isVertical: true,\n                            updatedAt: new Date(),\n                          },\n                        ],\n                      }}\n                      linkId=\"1\"\n                      isPreview={false}\n                      allowDownload={false}\n                    />\n                  </li>\n                </ul>\n              </div>\n            </div>\n          </div>\n        </div>\n      </ViewerSurfaceThemeProvider>\n    </div>\n  );\n}\n"
  },
  {
    "path": "pages/settings/agreements.tsx",
    "content": "import { useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { CircleHelpIcon, FileTextIcon, PlusIcon } from \"lucide-react\";\nimport { mutate } from \"swr\";\n\nimport { useAgreements } from \"@/lib/swr/use-agreements\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport AgreementCard from \"@/components/agreements/agreement-card\";\nimport AppLayout from \"@/components/layouts/app\";\nimport AgreementSheet from \"@/components/links/link-sheet/agreement-panel\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Button } from \"@/components/ui/button\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\nimport { createUpgradeButton } from \"@/components/ui/upgrade-button\";\n\nconst AgreementsUpgradeButton = createUpgradeButton(\n  \"Create Agreements\",\n  PlanEnum.Business,\n  \"nda_agreements_page\",\n  { highlightItem: [\"nda\"] },\n);\n\nexport default function NdaAgreements() {\n  const { agreements, loading, error } = useAgreements();\n  const teamInfo = useTeam();\n  const { isTrial, isBusiness, isDatarooms, isDataroomsPlus } = usePlan();\n\n  const [isOpen, setIsOpen] = useState<boolean>(false);\n\n  const activeAgreements = useMemo(() => {\n    return agreements?.filter((agreement) => !agreement.deletedAt) || [];\n  }, [agreements]);\n\n  const handleAgreementDeletion = (deletedAgreementId: string) => {\n    mutate(\n      `/api/teams/${teamInfo?.currentTeam?.id}/agreements`,\n      agreements?.filter((agreement) => agreement.id !== deletedAgreementId),\n      false,\n    );\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n                Agreements\n              </h3>\n              <p className=\"flx-row flex items-center gap-2 text-sm text-muted-foreground\">\n                Manage your one-click agreements for document sharing and data\n                rooms.\n                <BadgeTooltip\n                  content=\"How to require NDA agreement before viewing documents?\"\n                  key=\"nda-help\"\n                  linkText=\"Learn more\"\n                  link=\"https://www.papermark.com/help/article/require-nda-to-view\"\n                >\n                  <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                </BadgeTooltip>\n              </p>\n            </div>\n            <ul className=\"flex items-center justify-between gap-4\">\n              {isTrial || isBusiness || isDatarooms || isDataroomsPlus ? (\n                <Button variant=\"outline\" onClick={() => setIsOpen(true)}>\n                  <FileTextIcon className=\"h-4 w-4\" />\n                  Create agreement\n                </Button>\n              ) : (\n                <AgreementsUpgradeButton />\n              )}\n            </ul>\n          </div>\n          {loading ? (\n            <div className=\"flex items-center justify-center py-12\">\n              <div className=\"spinner\" />\n              <span className=\"ml-2 text-sm text-gray-500\">\n                Loading agreements...\n              </span>\n            </div>\n          ) : error ? (\n            <div className=\"flex flex-col items-center justify-center space-y-4 py-12\">\n              <p className=\"text-center text-sm text-red-500\">\n                Failed to load agreements\n              </p>\n              <Button\n                variant=\"outline\"\n                onClick={() => window.location.reload()}\n              >\n                Try again\n              </Button>\n            </div>\n          ) : activeAgreements.length !== 0 ? (\n            <div>\n              <ul>\n                {[...activeAgreements].reverse().map((agreement) => (\n                  <li key={agreement.id} className=\"mt-4\">\n                    <AgreementCard\n                      agreement={agreement}\n                      onDelete={handleAgreementDeletion}\n                    />\n                  </li>\n                ))}\n              </ul>\n            </div>\n          ) : (\n            <div className=\"flex flex-col items-center justify-center space-y-4 py-12\">\n              <div className=\"rounded-full bg-gray-100 p-3\">\n                <PlusIcon className=\"h-6 w-6 text-gray-600\" />\n              </div>\n              <div className=\"text-center\">\n                <h3 className=\"font-medium\">No NDA agreements yet</h3>\n                <p className=\"mt-1 max-w-sm text-sm text-gray-500\">\n                  Create your first NDA agreement to get started\n                </p>\n              </div>\n              {isTrial || isBusiness || isDatarooms || isDataroomsPlus ? (\n                <Button variant=\"outline\" onClick={() => setIsOpen(true)}>\n                  <FileTextIcon className=\"h-4 w-4\" />\n                  Create NDA agreement\n                </Button>\n              ) : (\n                <AgreementsUpgradeButton\n                  text=\"Create NDA Agreements\"\n                  trigger=\"nda_agreements_page_empty_state\"\n                  variant=\"outline\"\n                />\n              )}\n            </div>\n          )}\n        </div>\n      </main>\n      <AgreementSheet isOpen={isOpen} setIsOpen={setIsOpen} />\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/ai.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { ExternalLink, Shield, Sparkles } from \"lucide-react\";\n\nimport PapermarkSparkle from \"@/components/shared/icons/papermark-sparkle\";\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { useGetTeam } from \"@/lib/swr/use-team\";\nimport { CustomUser } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport { Switch } from \"@/components/ui/switch\";\n\ninterface AISettings {\n  agentsEnabled: boolean;\n  vectorStoreId: string | null;\n}\n\nexport default function AISettings() {\n  const router = useRouter();\n  const { data: session } = useSession();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { team, loading: teamLoading } = useGetTeam();\n\n  const [updating, setUpdating] = useState(false);\n\n  // Check if AI feature is enabled for this team\n  const { isFeatureEnabled, isLoading: featuresLoading } = useFeatureFlags();\n  const isAIFeatureEnabled = isFeatureEnabled(\"ai\");\n\n  // Fetch AI settings\n  const {\n    data: aiSettings,\n    isLoading: aiSettingsLoading,\n    mutate: mutateAISettings,\n  } = useSWR<AISettings>(\n    teamId && isAIFeatureEnabled ? `/api/teams/${teamId}/ai-settings` : null,\n    fetcher,\n  );\n\n  const userId = (session?.user as CustomUser)?.id;\n\n  // Check if current user is admin\n  const isAdmin = team?.users.some(\n    (user) => user.role === \"ADMIN\" && user.userId === userId,\n  );\n\n  // Redirect if feature is not enabled\n  useEffect(() => {\n    if (!featuresLoading && !isAIFeatureEnabled) {\n      router.push(\"/settings/general\");\n      toast.error(\"AI features are not available for your team\");\n    }\n  }, [featuresLoading, isAIFeatureEnabled, router]);\n\n  const handleToggleAI = async (enabled: boolean) => {\n    if (!teamId || !isAdmin) return;\n\n    setUpdating(true);\n\n    try {\n      const response = await fetch(`/api/teams/${teamId}/ai-settings`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ agentsEnabled: enabled }),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error || \"Failed to update AI settings\");\n      }\n\n      await mutateAISettings();\n      toast.success(\n        `AI Agents ${enabled ? \"enabled\" : \"disabled\"} for your team`,\n      );\n    } catch (error) {\n      console.error(\"Error updating AI settings:\", error);\n      toast.error((error as Error).message || \"Failed to update AI settings\");\n    } finally {\n      setUpdating(false);\n    }\n  };\n\n  if (featuresLoading || teamLoading) {\n    return (\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n          <SettingsHeader />\n          <div className=\"flex h-64 items-center justify-center\">\n            <LoadingSpinner className=\"h-8 w-8\" />\n          </div>\n        </main>\n      </AppLayout>\n    );\n  }\n\n  if (!isAIFeatureEnabled) {\n    return null;\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n          <div className=\"space-y-1\">\n            <div className=\"flex items-center gap-2\">\n              <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n                AI Agents\n              </h3>\n              <Badge variant=\"secondary\" className=\"gap-1\">\n                <Sparkles className=\"h-3 w-3\" />\n                Beta\n              </Badge>\n            </div>\n            <p className=\"text-sm text-muted-foreground\">\n              Configure AI-powered chat for your documents and datarooms\n            </p>\n          </div>\n        </div>\n\n        <div className=\"space-y-6\">\n          {/* Main AI Toggle Card */}\n          <Card>\n            <CardHeader>\n              <div className=\"flex items-center gap-2\">\n                <PapermarkSparkle className=\"h-5 w-5 text-primary\" />\n                <CardTitle>Enable AI Agents</CardTitle>\n              </div>\n              <CardDescription>\n                Allow AI-powered chat on documents and datarooms in your team.\n                When enabled, you can activate AI chat on individual documents.\n              </CardDescription>\n            </CardHeader>\n\n            <CardContent className=\"space-y-4\">\n              <div className=\"flex items-center justify-between space-x-2\">\n                <Label htmlFor=\"ai-enabled\" className=\"flex flex-col space-y-1\">\n                  <span>AI Agents for Team</span>\n                  <span className=\"text-xs font-normal leading-snug text-muted-foreground\">\n                    {isAdmin\n                      ? \"Enable to allow AI chat on documents in your team\"\n                      : \"Only team admins can change this setting\"}\n                  </span>\n                </Label>\n                <Switch\n                  id=\"ai-enabled\"\n                  checked={aiSettings?.agentsEnabled ?? false}\n                  onCheckedChange={handleToggleAI}\n                  disabled={updating || aiSettingsLoading || !isAdmin}\n                />\n              </div>\n            </CardContent>\n\n            <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n              <p className=\"text-sm text-muted-foreground transition-colors\">\n                Once enabled, you can turn on AI chat for individual documents\n                from their settings page.\n              </p>\n            </CardFooter>\n          </Card>\n\n          {/* Privacy & Data Card */}\n          <Card>\n            <CardHeader>\n              <div className=\"flex items-center gap-2\">\n                <Shield className=\"h-5 w-5 text-green-600\" />\n                <CardTitle>Privacy & Data Usage</CardTitle>\n              </div>\n              <CardDescription>\n                How we handle your data with AI features\n              </CardDescription>\n            </CardHeader>\n\n            <CardContent className=\"space-y-4\">\n              <div className=\"space-y-3 text-sm\">\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30\">\n                    ✓\n                  </div>\n                  <div>\n                    <p className=\"font-medium\">Powered by OpenAI</p>\n                    <p className=\"text-muted-foreground\">\n                      We use OpenAI&apos;s API to power AI chat features. Your\n                      documents are processed to enable intelligent Q&A.\n                    </p>\n                  </div>\n                </div>\n\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30\">\n                    ✓\n                  </div>\n                  <div>\n                    <p className=\"font-medium\">No Training on Your Data</p>\n                    <p className=\"text-muted-foreground\">\n                      OpenAI does not use data sent through their API to train\n                      their models. Your content remains private.\n                    </p>\n                  </div>\n                </div>\n\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30\">\n                    ✓\n                  </div>\n                  <div>\n                    <p className=\"font-medium\">Data Retention</p>\n                    <p className=\"text-muted-foreground\">\n                      Document embeddings are stored securely and can be deleted\n                      at any time by disabling AI for a document.\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </CardContent>\n\n            <CardFooter className=\"flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3\">\n              <a\n                href=\"https://openai.com/policies/api-data-usage-policies\"\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:underline\"\n              >\n                Learn more about OpenAI&apos;s data usage policies\n                <ExternalLink className=\"h-3 w-3\" />\n              </a>\n            </CardFooter>\n          </Card>\n\n          {/* How it Works Card */}\n          <Card>\n            <CardHeader>\n              <CardTitle>How AI Agents Work</CardTitle>\n              <CardDescription>\n                A quick overview of the AI chat feature\n              </CardDescription>\n            </CardHeader>\n\n            <CardContent>\n              <ol className=\"space-y-3 text-sm\">\n                <li className=\"flex items-start gap-3\">\n                  <span className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground\">\n                    1\n                  </span>\n                  <div>\n                    <p className=\"font-medium\">Enable AI for your team</p>\n                    <p className=\"text-muted-foreground\">\n                      Turn on the toggle above (admin only)\n                    </p>\n                  </div>\n                </li>\n                <li className=\"flex items-start gap-3\">\n                  <span className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground\">\n                    2\n                  </span>\n                  <div>\n                    <p className=\"font-medium\">Activate AI on documents</p>\n                    <p className=\"text-muted-foreground\">\n                      Go to a document&apos;s settings and enable AI Agents\n                    </p>\n                  </div>\n                </li>\n                <li className=\"flex items-start gap-3\">\n                  <span className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground\">\n                    3\n                  </span>\n                  <div>\n                    <p className=\"font-medium\">Index your documents</p>\n                    <p className=\"text-muted-foreground\">\n                      Click &quot;Index Document&quot; to prepare it for AI chat\n                    </p>\n                  </div>\n                </li>\n                <li className=\"flex items-start gap-3\">\n                  <span className=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground\">\n                    4\n                  </span>\n                  <div>\n                    <p className=\"font-medium\">Visitors can chat</p>\n                    <p className=\"text-muted-foreground\">\n                      Viewers can ask questions and get AI-powered answers about\n                      your document\n                    </p>\n                  </div>\n                </li>\n              </ol>\n            </CardContent>\n          </Card>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/billing/invoices.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect } from \"react\";\n\nimport { Download, FileText, Loader2 } from \"lucide-react\";\n\nimport { useIsAdmin } from \"@/lib/hooks/use-is-admin\";\nimport { useInvoices } from \"@/lib/swr/use-invoices\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { TabMenu } from \"@/components/tab-menu\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\n\nexport default function Invoices() {\n  const router = useRouter();\n  const { invoices, loading, error } = useInvoices();\n  const { isAdmin, loading: isAdminLoading } = useIsAdmin();\n\n  // Redirect non-admin users to general settings\n  useEffect(() => {\n    if (!isAdminLoading && !isAdmin) {\n      router.replace(\"/settings/general\");\n    }\n  }, [isAdmin, isAdminLoading, router]);\n\n  const formatCurrency = (amount: number, currency: string) => {\n    return new Intl.NumberFormat(\"en-US\", {\n      style: \"currency\",\n      currency: currency.toUpperCase(),\n    }).format(amount / 100);\n  };\n\n  const formatDate = (timestamp: number) => {\n    return new Date(timestamp * 1000).toLocaleDateString(\"en-US\", {\n      month: \"short\",\n      day: \"numeric\",\n      year: \"numeric\",\n    });\n  };\n\n  const handleDownload = (invoicePdf: string | null) => {\n    if (invoicePdf) {\n      window.open(invoicePdf, \"_blank\");\n    }\n  };\n\n  const handleViewInvoice = (hostedInvoiceUrl: string | null) => {\n    if (hostedInvoiceUrl) {\n      window.open(hostedInvoiceUrl, \"_blank\");\n    }\n  };\n\n  // Show nothing while checking admin status\n  if (isAdminLoading || !isAdmin) {\n    return (\n      <AppLayout>\n        <div />\n      </AppLayout>\n    );\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <TabMenu\n          navigation={[\n            {\n              label: \"Subscription\",\n              href: \"/settings/billing\",\n              value: \"subscription\",\n              currentValue: \"invoices\",\n            },\n            {\n              label: \"Invoices\",\n              href: \"/settings/billing/invoices\",\n              value: \"invoices\",\n              currentValue: \"invoices\",\n            },\n          ]}\n        />\n\n        <div className=\"space-y-4\">\n          <div className=\"space-y-1\">\n            <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n              Invoices\n            </h3>\n            <p className=\"text-sm text-muted-foreground\">\n              A history of all your invoices\n            </p>\n          </div>\n\n          <div className=\"rounded-lg border bg-white\">\n            {loading ? (\n              <div className=\"flex items-center justify-center py-12\">\n                <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n              </div>\n            ) : error ? (\n              <div className=\"flex flex-col items-center justify-center gap-2 py-12\">\n                <p className=\"text-sm text-muted-foreground\">\n                  Failed to load invoices\n                </p>\n                <Button\n                  variant=\"outline\"\n                  size=\"sm\"\n                  onClick={() => router.reload()}\n                >\n                  Retry\n                </Button>\n              </div>\n            ) : invoices.length === 0 ? (\n              <div className=\"flex flex-col items-center justify-center gap-4 py-12\">\n                <div className=\"rounded-full border border-white bg-gradient-to-t from-gray-100 p-3\">\n                  <FileText className=\"size-6\" />\n                </div>\n                <div className=\"text-center\">\n                  <p className=\"text-sm font-medium\">No invoices yet</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Your invoices will appear here once you have a subscription\n                  </p>\n                </div>\n              </div>\n            ) : (\n              <Table>\n                <TableHeader>\n                  <TableRow>\n                    <TableHead>Description</TableHead>\n                    <TableHead>Date</TableHead>\n                    <TableHead>Total</TableHead>\n                    <TableHead className=\"text-right\">Actions</TableHead>\n                  </TableRow>\n                </TableHeader>\n                <TableBody>\n                  {invoices.map((invoice) => (\n                    <TableRow key={invoice.id}>\n                      <TableCell>\n                        <div className=\"flex flex-col\">\n                          <span className=\"font-medium\">\n                            Papermark Subscription\n                          </span>\n                          {invoice.number && (\n                            <span className=\"text-xs text-muted-foreground\">\n                              {invoice.number}\n                            </span>\n                          )}\n                        </div>\n                      </TableCell>\n                      <TableCell>{formatDate(invoice.created)}</TableCell>\n                      <TableCell>\n                        {formatCurrency(invoice.amount, invoice.currency)}\n                      </TableCell>\n                      <TableCell className=\"text-right\">\n                        <div className=\"flex justify-end gap-2\">\n                          <Button\n                            variant=\"outline\"\n                            size=\"sm\"\n                            onClick={() =>\n                              handleViewInvoice(invoice.hostedInvoiceUrl)\n                            }\n                            disabled={!invoice.hostedInvoiceUrl}\n                          >\n                            View invoice\n                          </Button>\n                          <Button\n                            variant=\"outline\"\n                            size=\"sm\"\n                            onClick={() => handleDownload(invoice.invoicePdf)}\n                            disabled={!invoice.invoicePdf}\n                          >\n                            <Download className=\"h-4 w-4\" />\n                          </Button>\n                        </div>\n                      </TableCell>\n                    </TableRow>\n                  ))}\n                </TableBody>\n              </Table>\n            )}\n          </div>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/billing.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { sendGTMEvent } from \"@next/third-parties/google\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { useIsAdmin } from \"@/lib/hooks/use-is-admin\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport UpgradePlanContainer from \"@/components/billing/upgrade-plan-container\";\nimport { GTMComponent } from \"@/components/gtm-component\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { TabMenu } from \"@/components/tab-menu\";\n\nexport default function Billing() {\n  const router = useRouter();\n  const analytics = useAnalytics();\n\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { plan } = usePlan();\n  const { isAdmin, loading: isAdminLoading } = useIsAdmin();\n\n  // Redirect non-admin users to general settings\n  useEffect(() => {\n    if (!isAdminLoading && !isAdmin) {\n      router.replace(\"/settings/general\");\n    }\n  }, [isAdmin, isAdminLoading, router]);\n\n  useEffect(() => {\n    if (router.query.success) {\n      toast.success(\"Upgrade success!\");\n      analytics.capture(\"User Upgraded\", {\n        plan: plan,\n        teamId: teamId,\n        $set: { teamId: teamId, teamPlan: plan },\n      });\n\n      sendGTMEvent({ event: \"upgraded\" });\n\n      // Remove the success query parameter\n      router.replace(\"/settings/billing\", undefined, { shallow: true });\n    }\n\n    if (router.query.cancel) {\n      analytics.capture(\"Stripe Checkout Cancelled\", {\n        teamId: teamId,\n      });\n\n      // Remove the cancel query parameter\n      router.replace(\"/settings/billing\", undefined, { shallow: true });\n    }\n  }, [router.query]);\n\n  // Show nothing while checking admin status\n  if (isAdminLoading || !isAdmin) {\n    return (\n      <AppLayout>\n        <div />\n      </AppLayout>\n    );\n  }\n\n  return (\n    <>\n      <GTMComponent />\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n          <SettingsHeader />\n\n          <TabMenu\n            navigation={[\n              {\n                label: \"Subscription\",\n                href: \"/settings/billing\",\n                value: \"subscription\",\n                currentValue: \"subscription\",\n              },\n              {\n                label: \"Invoices\",\n                href: \"/settings/billing/invoices\",\n                value: \"invoices\",\n                currentValue: \"subscription\",\n              },\n            ]}\n          />\n\n          <UpgradePlanContainer />\n        </main>\n      </AppLayout>\n    </>\n  );\n}\n"
  },
  {
    "path": "pages/settings/domains.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { CircleHelpIcon } from \"lucide-react\";\nimport { mutate } from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useDomains } from \"@/lib/swr/use-domains\";\n\nimport { AddDomainModal } from \"@/components/domains/add-domain-modal\";\nimport DomainCard from \"@/components/domains/domain-card\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Button } from \"@/components/ui/button\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nexport default function Domains() {\n  const { domains } = useDomains({ enabled: true });\n  const teamInfo = useTeam();\n  const { isBusiness, isDatarooms } = usePlan();\n\n  const [open, setOpen] = useState<boolean>(false);\n\n  const handleDomainDeletion = (deletedDomain: string) => {\n    mutate(\n      `/api/teams/${teamInfo?.currentTeam?.id}/domains`,\n      domains?.filter((domain) => domain.slug !== deletedDomain),\n      false,\n    );\n  };\n\n  const handleDomainAddition = (newDomain: string) => {\n    mutate(\n      `/api/teams/${teamInfo?.currentTeam?.id}/domains`,\n      [...(domains || []), newDomain],\n      false,\n    );\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n                Domains\n              </h3>\n              <p className=\"flx-row flex items-center gap-2 text-sm text-muted-foreground\">\n                Manage your custom domain for sharing documents and data rooms.\n                <BadgeTooltip\n                  content=\"How to connect a custom domain to your link?\"\n                  key=\"verified\"\n                  linkText=\"Click here\"\n                  link=\"https://www.papermark.com/help/article/how-to-add-custom-domain-to-link\"\n                >\n                  <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                </BadgeTooltip>\n              </p>\n            </div>\n            <ul className=\"flex items-center justify-between gap-4\">\n              <AddDomainModal\n                open={open}\n                setOpen={setOpen}\n                onAddition={handleDomainAddition}\n              >\n                <Button>Add Domain</Button>\n              </AddDomainModal>\n            </ul>\n          </div>\n          {domains && domains.length !== 0 ? (\n            <div>\n              <ul>\n                {domains.map((domain, index) => (\n                  <li key={index} className=\"mt-4\">\n                    <DomainCard\n                      domain={domain.slug}\n                      isDefault={domain.isDefault}\n                      redirectUrl={domain.redirectUrl}\n                      redirectsAllowed={isBusiness || isDatarooms}\n                      onDelete={handleDomainDeletion}\n                    />\n                  </li>\n                ))}\n              </ul>\n            </div>\n          ) : null}\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/general.tsx",
    "content": "import { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useTeamSettings } from \"@/lib/swr/use-team-settings\";\nimport { validateContent } from \"@/lib/utils/sanitize-html\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport DeleteTeam from \"@/components/settings/delete-team\";\nimport GlobalBlockListForm from \"@/components/settings/global-block-list-form\";\nimport IgnoredDomainsForm from \"@/components/settings/ignored-domains-form\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { SurveySettings } from \"@/components/settings/survey-settings\";\nimport { TimezoneSelector } from \"@/components/settings/timezone-selector\";\nimport { Form } from \"@/components/ui/form\";\n\nexport default function General() {\n  const analytics = useAnalytics();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const { isFree, isPro, isTrial, isStarter } = usePlan();\n  const [selectedPlan, setSelectedPlan] = useState<PlanEnum>(PlanEnum.Pro);\n  const [planModalTrigger, setPlanModalTrigger] = useState<string>(\"\");\n  const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);\n\n  // Fetch fresh team settings with proper revalidation\n  const { settings: teamSettings } = useTeamSettings(teamId);\n\n  const showUpgradeModal = (plan: PlanEnum, trigger: string) => {\n    setSelectedPlan(plan);\n    setPlanModalTrigger(trigger);\n    setPlanModalOpen(true);\n  };\n\n  const handleExcelAdvancedModeChange = async (data: {\n    enableExcelAdvancedMode: string;\n  }) => {\n    if (\n      (isFree || isPro || isStarter) &&\n      !isTrial &&\n      data.enableExcelAdvancedMode === \"true\"\n    ) {\n      showUpgradeModal(PlanEnum.Business, \"advanced-excel-mode\");\n      return;\n    }\n\n    analytics.capture(\"Toggle Excel Advanced Mode\", {\n      teamId,\n      enableExcelAdvancedMode: data.enableExcelAdvancedMode === \"true\",\n    });\n\n    const promise = fetch(`/api/teams/${teamId}/update-advanced-mode`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        enableExcelAdvancedMode: data.enableExcelAdvancedMode === \"true\",\n      }),\n    }).then(async (res) => {\n      if (!res.ok) {\n        const { error } = await res.json();\n        throw new Error(error.message);\n      }\n      await Promise.all([\n        mutate(`/api/teams/${teamId}`),\n        mutate(`/api/teams`),\n        mutate(`/api/teams/${teamId}/settings`),\n      ]);\n      return res.json();\n    });\n\n    toast.promise(promise, {\n      loading: \"Updating Excel advanced mode setting...\",\n      success: \"Successfully updated Excel advanced mode setting!\",\n      error: (err) =>\n        err.message || \"Failed to update Excel advanced mode setting\",\n    });\n\n    return promise;\n  };\n\n  const handleReplicateFoldersChange = async (data: {\n    replicateDataroomFolders: string;\n  }) => {\n    analytics.capture(\"Toggle Replicate Dataroom Folders\", {\n      teamId,\n      replicateDataroomFolders: data.replicateDataroomFolders === \"true\",\n    });\n\n    const promise = fetch(`/api/teams/${teamId}/update-replicate-folders`, {\n      method: \"PATCH\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        replicateDataroomFolders: data.replicateDataroomFolders === \"true\",\n      }),\n    }).then(async (res) => {\n      if (!res.ok) {\n        const { error } = await res.json();\n        throw new Error(error.message);\n      }\n      await Promise.all([\n        mutate(`/api/teams/${teamId}`),\n        mutate(`/api/teams`),\n        mutate(`/api/teams/${teamId}/settings`),\n      ]);\n      return res.json();\n    });\n\n    toast.promise(promise, {\n      loading: \"Updating folder replication setting...\",\n      success: \"Successfully updated folder replication setting!\",\n      error: (err) =>\n        err.message || \"Failed to update folder replication setting\",\n    });\n\n    return promise;\n  };\n\n  const handleTeamNameChange = async (updateData: any) => {\n    try {\n      // Sanitize and validate team name before sending\n      const sanitizedName = validateContent(updateData.name);\n\n      analytics.capture(\"Update Team Name\", {\n        teamId,\n        name: sanitizedName,\n      });\n\n      const promise = fetch(`/api/teams/${teamId}/update-name`, {\n        method: \"PATCH\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ name: sanitizedName }),\n      }).then(async (res) => {\n        if (!res.ok) {\n          const { error } = await res.json();\n          throw new Error(error.message);\n        }\n        await Promise.all([\n          mutate(`/api/teams/${teamId}`),\n          mutate(`/api/teams`),\n        ]);\n        return res.json();\n      });\n\n      toast.promise(promise, {\n        loading: \"Updating team name...\",\n        success: \"Successfully updated team name!\",\n        error: (err) => err.message || \"Failed to update team name\",\n      });\n\n      return promise;\n    } catch (error) {\n      toast.error((error as Error).message || \"Failed to validate team name\");\n      throw error;\n    }\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n          <div className=\"space-y-1\">\n            <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n              General\n            </h3>\n            <p className=\"text-sm text-muted-foreground\">Manage your team</p>\n          </div>\n        </div>\n        <div className=\"space-y-6\">\n          <Form\n            title=\"Team Name\"\n            description=\"This is the name of your team on Papermark.\"\n            inputAttrs={{\n              name: \"name\",\n              placeholder: \"My Personal Team\",\n              maxLength: 32,\n            }}\n            defaultValue={teamInfo?.currentTeam?.name ?? \"\"}\n            helpText=\"Max 32 characters.\"\n            handleSubmit={handleTeamNameChange}\n          />\n\n          <Form\n            title=\"Excel Advanced Mode\"\n            description=\"Enable advanced mode for all new Excel files in your team. Existing files will not be affected.\"\n            inputAttrs={{\n              name: \"enableExcelAdvancedMode\",\n              type: \"checkbox\",\n              placeholder: \"Enable advanced mode for Excel files\",\n            }}\n            defaultValue={String(\n              teamSettings?.enableExcelAdvancedMode ?? false,\n            )}\n            helpText=\"When enabled, newly uploaded Excel files will be viewed using the Microsoft Office viewer for better formatting and compatibility.\"\n            handleSubmit={handleExcelAdvancedModeChange}\n            plan={(isFree && !isTrial) || isPro ? \"Business\" : undefined}\n          />\n\n          <Form\n            title=\"Replicate Dataroom Folders\"\n            description=\"When uploading folders to a dataroom, also replicate the folder structure in 'All Documents'.\"\n            inputAttrs={{\n              name: \"replicateDataroomFolders\",\n              type: \"checkbox\",\n              placeholder: \"Replicate folder structure in All Documents\",\n            }}\n            defaultValue={String(\n              teamSettings?.replicateDataroomFolders ?? true,\n            )}\n            helpText=\"When enabled, folders uploaded to datarooms will be created in 'All Documents' with the same structure. When disabled, all documents will be placed in a single folder named after the dataroom in 'All Documents'.\"\n            handleSubmit={handleReplicateFoldersChange}\n          />\n          <TimezoneSelector />\n          <IgnoredDomainsForm />\n          <GlobalBlockListForm />\n          <SurveySettings />\n\n          <DeleteTeam />\n        </div>\n\n        {planModalOpen ? (\n          <UpgradePlanModal\n            clickedPlan={selectedPlan}\n            trigger={planModalTrigger}\n            open={planModalOpen}\n            setOpen={setPlanModalOpen}\n          />\n        ) : null}\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/incoming-webhooks.tsx",
    "content": "import { useRouter } from \"next/navigation\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { format } from \"date-fns\";\nimport { CircleHelpIcon, CopyIcon, Loader } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nimport { copyToClipboard, fetcher } from \"@/lib/utils\";\n\ninterface Webhook {\n  id: string;\n  name: string;\n  webhookId: string;\n  createdAt: string;\n}\n\nexport default function WebhookSettings() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const router = useRouter();\n  const [name, setName] = useState(\"\");\n  console.log(\"🚀 ~ WebhookSettings ~ name:\", name);\n  const [webhookId, setWebhookId] = useState<string | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n\n  // Feature flag check\n  const { data: features } = useSWR<{ incomingWebhooks: boolean }>(\n    teamId ? `/api/feature-flags?teamId=${teamId}` : null,\n    fetcher,\n  );\n\n  // Redirect if feature is not enabled\n  useEffect(() => {\n    if (features && !features.incomingWebhooks) {\n      router.push(\"/settings/general\");\n      toast.error(\"This feature is not available for your team\");\n    }\n  }, [features, router]);\n\n  const { data: webhooks, mutate } = useSWR<Webhook[]>(\n    teamId ? `/api/teams/${teamId}/incoming-webhooks` : null,\n    fetcher,\n  );\n\n  const createWebhook = async () => {\n    try {\n      setIsLoading(true);\n      const response = await fetch(`/api/teams/${teamId}/incoming-webhooks`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name,\n        }),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to create webhook\");\n      }\n\n      const data = await response.json();\n      setWebhookId(data.webhookId);\n      toast.success(\"Webhook created successfully\");\n\n      // Refresh the webhooks list\n      mutate();\n    } catch (error) {\n      toast.error(\"Failed to create webhook\");\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const deleteWebhook = async (webhookId: string) => {\n    try {\n      const response = await fetch(`/api/teams/${teamId}/incoming-webhooks`, {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ webhookId }),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to delete webhook\");\n      }\n\n      mutate();\n      toast.success(\"Webhook deleted successfully\");\n    } catch (error) {\n      console.error(error);\n      toast.error(\"Failed to delete webhook\");\n    }\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n        <Card>\n          <CardHeader>\n            <CardTitle className=\"flex items-center gap-2\">\n              Incoming Webhooks\n              <BadgeTooltip content=\"Use webhooks to receive data from external services\">\n                <CircleHelpIcon className=\"h-4 w-4 text-gray-500\" />\n              </BadgeTooltip>\n            </CardTitle>\n            <CardDescription>\n              Create incoming webhooks to receive data from external services\n              and automatically create new documents in Papermark.\n            </CardDescription>\n          </CardHeader>\n          <Separator className=\"mb-6\" />\n          <CardContent>\n            <div className=\"flex flex-col space-y-6\">\n              <div className=\"flex flex-col space-y-4\">\n                <div>\n                  <Label htmlFor=\"webhook-name\">Webhook Name</Label>\n                  <Input\n                    id=\"webhook-name\"\n                    placeholder=\"Enter a name for your webhook\"\n                    value={name}\n                    onChange={(e) => setName(e.target.value)}\n                  />\n                </div>\n                {webhookId && (\n                  <Card>\n                    <CardHeader>\n                      <CardTitle className=\"flex items-center gap-2\">\n                        <div className=\"flex items-center gap-2\">\n                          <Label>Your Webhook URL (copy it now)</Label>\n                          <BadgeTooltip content=\"Use webhooks to receive data from external services\">\n                            <CircleHelpIcon className=\"h-4 w-4 text-gray-500\" />\n                          </BadgeTooltip>\n                        </div>\n                      </CardTitle>\n                    </CardHeader>\n                    <Separator className=\"mb-6\" />\n                    <CardContent>\n                      <code className=\"mt-2 flex items-center break-all rounded bg-background p-2 font-mono text-sm dark:bg-gray-900\">\n                        {`${process.env.NEXT_PUBLIC_WEBHOOK_BASE_URL}/services/${webhookId}`}{\" \"}\n                        <Button\n                          variant=\"ghost\"\n                          size=\"icon\"\n                          className=\"ml-2 h-6 w-6\"\n                          onClick={() =>\n                            copyToClipboard(\n                              `${process.env.NEXT_PUBLIC_WEBHOOK_BASE_URL}/services/${webhookId}`,\n                              \"Webhook URL copied to clipboard\",\n                            )\n                          }\n                        >\n                          <CopyIcon />\n                        </Button>\n                      </code>\n                    </CardContent>\n                  </Card>\n                )}\n\n                <Button\n                  onClick={createWebhook}\n                  disabled={!name || isLoading}\n                  className=\"w-fit\"\n                >\n                  {isLoading ? \"Creating...\" : \"Create Webhook\"}\n                </Button>\n              </div>\n              {/* Webhooks List */}\n              <div>\n                <h3 className=\"mb-4 text-lg font-medium\">Existing Webhooks</h3>\n                <Card className=\"min-h-14\">\n                  {webhooks === undefined ? (\n                    <div className=\"flex w-full items-center justify-center p-4\">\n                      <Loader className=\"h-5 w-5 animate-spin\" />\n                    </div>\n                  ) : null}\n                  {webhooks?.length === 0 ? (\n                    <div className=\"flex w-full items-center justify-center p-4\">\n                      <CardDescription>No webhooks created yet</CardDescription>\n                    </div>\n                  ) : (\n                    <div className=\"divide-y divide-border\">\n                      {webhooks?.map((webhook) => (\n                        <div\n                          key={webhook.id}\n                          className=\"flex items-center justify-between p-4\"\n                        >\n                          <div className=\"space-y-1\">\n                            <label className=\"font-medium\">\n                              {webhook.name}\n                            </label>\n                            <div className=\"flex items-center space-x-2 text-sm text-muted-foreground\">\n                              <div className=\"flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 font-mono dark:bg-gray-900\">\n                                <span className=\"max-w-[200px] overflow-x-auto whitespace-nowrap md:max-w-[400px]\">\n                                  {`${process.env.NEXT_PUBLIC_WEBHOOK_BASE_URL}/services/${webhook.webhookId}`}\n                                </span>\n                                <Button\n                                  variant=\"ghost\"\n                                  size=\"icon\"\n                                  className=\"h-6 w-6\"\n                                  onClick={() => {\n                                    navigator.clipboard.writeText(\n                                      `${process.env.NEXT_PUBLIC_WEBHOOK_BASE_URL}/services/${webhook.webhookId}`,\n                                    );\n                                    toast.success(\n                                      \"Webhook URL copied to clipboard\",\n                                    );\n                                  }}\n                                >\n                                  <CopyIcon className=\"h-3 w-3\" />\n                                </Button>\n                              </div>\n                              <span>•</span>\n                              <span>\n                                {format(\n                                  new Date(webhook.createdAt),\n                                  \"MMM d, yyyy\",\n                                )}\n                              </span>\n                            </div>\n                          </div>\n                          <Button\n                            variant=\"destructive\"\n                            size=\"sm\"\n                            onClick={() => deleteWebhook(webhook.id)}\n                          >\n                            Delete\n                          </Button>\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </Card>\n              </div>\n            </div>\n          </CardContent>\n        </Card>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/people.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { useSession } from \"next-auth/react\";\nimport { toast } from \"sonner\";\nimport { mutate } from \"swr\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { useInvitations } from \"@/lib/swr/use-invitations\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { useGetTeam } from \"@/lib/swr/use-team\";\nimport { useTeams } from \"@/lib/swr/use-teams\";\nimport { CustomUser } from \"@/lib/types\";\n\nimport { AddSeatModal } from \"@/components/billing/add-seat-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport Folder from \"@/components/shared/icons/folder\";\nimport MoreVertical from \"@/components/shared/icons/more-vertical\";\nimport { AddTeamMembers } from \"@/components/teams/add-team-member-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { UpgradeButton } from \"@/components/ui/upgrade-button\";\n\nexport default function Billing() {\n  const [isTeamMemberInviteModalOpen, setTeamMemberInviteModalOpen] =\n    useState<boolean>(false);\n  const [isAddSeatModalOpen, setAddSeatModalOpen] = useState<boolean>(false);\n  const [leavingUserId, setLeavingUserId] = useState<string>(\"\");\n\n  const { data: session } = useSession();\n  const { team, loading } = useGetTeam()!;\n  const teamInfo = useTeam();\n  const { isTrial } = usePlan();\n  const { canAddUsers, showUpgradePlanModal } = useLimits();\n  const { teams } = useTeams();\n  const analytics = useAnalytics();\n\n  const { invitations } = useInvitations();\n\n  const router = useRouter();\n\n  const documentCountsByUser = useMemo(() => {\n    if (!team?.documents) return {};\n\n    const counts: Record<string, number> = {};\n    team.documents.forEach((document) => {\n      const ownerId = document.owner?.id;\n      if (ownerId) {\n        counts[ownerId] = (counts[ownerId] || 0) + 1;\n      }\n    });\n    return counts;\n  }, [team]);\n\n  const getUserDocumentCount = (userId: string) => {\n    return documentCountsByUser[userId] || 0;\n  };\n\n  const isCurrentUser = (userId: string) => {\n    if ((session?.user as CustomUser)?.id === userId) {\n      return true;\n    }\n    return false;\n  };\n\n  const isCurrentUserAdmin = () => {\n    return team?.users.some(\n      (user) =>\n        user.role === \"ADMIN\" &&\n        user.userId === (session?.user as CustomUser)?.id,\n    );\n  };\n\n  const changeRole = async (\n    teamId: string,\n    userId: string,\n    role: \"ADMIN\" | \"MANAGER\" | \"MEMBER\",\n  ) => {\n    const response = await fetch(`/api/teams/${teamId}/change-role`, {\n      method: \"PUT\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        userToBeChanged: userId,\n        role: role,\n      }),\n    });\n\n    if (response.status !== 204) {\n      const error = await response.json();\n      toast.error(error);\n      return;\n    }\n\n    await mutate(`/api/teams/${teamId}`);\n    await mutate(\"/api/teams\");\n\n    analytics.capture(\"Team Member Role Changed\", {\n      userId: userId,\n      teamId: teamId,\n      role: role,\n    });\n\n    toast.success(\"Role changed successfully!\");\n  };\n\n  const removeTeammate = async (teamId: string, userId: string) => {\n    setLeavingUserId(userId);\n    const response = await fetch(`/api/teams/${teamId}/remove-teammate`, {\n      method: \"DELETE\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        userToBeDeleted: userId,\n      }),\n    });\n\n    if (response.status !== 204) {\n      const error = await response.json();\n      toast.error(error);\n      setLeavingUserId(\"\");\n      return;\n    }\n\n    await mutate(`/api/teams/${teamInfo?.currentTeam?.id}`);\n    await mutate(\"/api/teams\");\n    mutate(`/api/teams/${teamInfo?.currentTeam?.id}/invitations`);\n    mutate(`/api/teams/${teamInfo?.currentTeam?.id}/limits`);\n\n    setLeavingUserId(\"\");\n    if (isCurrentUser(userId)) {\n      toast.success(`Successfully leaved team ${teamInfo?.currentTeam?.name}`);\n      teamInfo?.setCurrentTeam({ id: teams![0].id });\n      router.push(\"/documents\");\n      return;\n    }\n\n    analytics.capture(\"Team Member Removed\", {\n      userId: userId,\n      teamId: teamInfo?.currentTeam?.id,\n    });\n\n    toast.success(\"Teammate removed successfully!\");\n  };\n\n  // resend invitation function\n  const resendInvitation = async (invitation: { email: string } & any) => {\n    const response = await fetch(\n      `/api/teams/${teamInfo?.currentTeam?.id}/invitations/resend`,\n      {\n        method: \"PUT\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          email: invitation.email as string,\n        }),\n      },\n    );\n\n    if (response.status !== 200) {\n      const error = await response.json();\n      toast.error(error);\n      return;\n    }\n\n    analytics.capture(\"Team Member Invitation Resent\", {\n      email: invitation.email as string,\n      teamId: teamInfo?.currentTeam?.id,\n    });\n    mutate(`/api/teams/${teamInfo?.currentTeam?.id}/invitations`);\n    mutate(`/api/teams/${teamInfo?.currentTeam?.id}/limits`);\n\n    toast.success(\"Invitation resent successfully!\");\n  };\n\n  // revoke invitation function\n  const revokeInvitation = async (invitation: { email: string } & any) => {\n    const response = await fetch(\n      `/api/teams/${teamInfo?.currentTeam?.id}/invitations`,\n      {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          email: invitation.email as string,\n        }),\n      },\n    );\n\n    if (!response.ok) {\n      const error = await response.json();\n      toast.error(error);\n      return;\n    }\n\n    analytics.capture(\"Team Member Invitation Revoked\", {\n      email: invitation.email as string,\n      teamId: teamInfo?.currentTeam?.id,\n    });\n\n    mutate(`/api/teams/${teamInfo?.currentTeam?.id}/invitations`);\n    mutate(`/api/teams/${teamInfo?.currentTeam?.id}/limits`);\n\n    toast.success(\"Invitation revoked successfully!\");\n  };\n  const showInvite = canAddUsers;\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n                Team Members\n              </h3>\n              <p className=\"text-sm text-muted-foreground\">\n                Manage your team members\n              </p>\n            </div>\n          </div>\n          <div>\n            <div className=\"flex items-center justify-between gap-x-1 rounded-lg border border-border bg-secondary p-4 sm:p-10\">\n              <div className=\"flex flex-col space-y-1 sm:space-y-3\">\n                <h2 className=\"text-xl font-medium\">Team</h2>\n                <p className=\"text-sm text-secondary-foreground\">\n                  Teammates that have access to this project.\n                </p>\n              </div>\n              {showUpgradePlanModal ? (\n                <UpgradeButton\n                  text=\"Invite Members\"\n                  clickedPlan={isTrial ? PlanEnum.Business : PlanEnum.Pro}\n                  trigger=\"invite_team_members\"\n                />\n              ) : (\n                <div className=\"flex items-center gap-2\">\n                  <AddSeatModal\n                    open={isAddSeatModalOpen}\n                    setOpen={setAddSeatModalOpen}\n                  >\n                    <Button variant=\"outline\" className=\"whitespace-nowrap\">\n                      Add Seat\n                    </Button>\n                  </AddSeatModal>\n                  {showInvite ? (\n                    <AddTeamMembers\n                      open={isTeamMemberInviteModalOpen}\n                      setOpen={setTeamMemberInviteModalOpen}\n                    >\n                      <Button>Invite</Button>\n                    </AddTeamMembers>\n                  ) : (\n                    <Button disabled title=\"Add a seat to invite more members\">\n                      Invite\n                    </Button>\n                  )}\n                </div>\n              )}\n            </div>\n          </div>\n\n          <ul className=\"mt-6 divide-y rounded-lg border\">\n            {loading && (\n              <div className=\"flex items-center justify-between px-10 py-4\">\n                <div className=\"flex items-center gap-12\">\n                  <div className=\"space-y-2\">\n                    <Skeleton className=\"h-6 w-36\" />\n                    <Skeleton className=\"h-4 w-36\" />\n                  </div>\n                  <Skeleton className=\"h-4 w-20\" />\n                </div>\n                <div className=\"flex gap-12\">\n                  <Skeleton className=\"h-6 w-14\" />\n                  <Skeleton className=\"h-6 w-4\" />\n                </div>\n              </div>\n            )}\n            {team?.users.map((member, index) => (\n              <li\n                className=\"flex items-center justify-between gap-12 overflow-auto px-10 py-4\"\n                key={index}\n              >\n                <div className=\"flex items-center gap-12\">\n                  <div className=\"space-y-1\">\n                    <h4 className=\"text-sm font-semibold\">\n                      {member.user.name}\n                    </h4>\n                    <p className=\"text-xs text-muted-foreground\">\n                      {member.user.email}\n                    </p>\n                  </div>\n                  <div className=\"text-sm\">\n                    <div className=\"flex items-center gap-2\">\n                      <Folder />\n                      <span className=\"text-nowrap text-xs text-foreground\">\n                        {getUserDocumentCount(member.userId)}{\" \"}\n                        {getUserDocumentCount(member.userId) === 1\n                          ? \"document\"\n                          : \"documents\"}\n                      </span>\n                    </div>\n                  </div>\n                </div>\n                <div className=\"flex items-center gap-12\">\n                  <div className=\"flex flex-col items-end gap-1\">\n                    <span className=\"text-sm capitalize text-foreground\">\n                      {member.role.toLowerCase()}\n                    </span>\n                    {member.status === \"BLOCKED_TRIAL_EXPIRED\" && (\n                      <span className=\"text-xs font-medium text-red-500\">\n                        Blocked (Trial Expired)\n                      </span>\n                    )}\n                  </div>\n                  {leavingUserId === member.userId ? (\n                    <span className=\"text-xs\">leaving...</span>\n                  ) : (\n                    <DropdownMenu>\n                      <DropdownMenuTrigger asChild>\n                        <Button variant=\"ghost\" className=\"h-8 w-8 p-0\">\n                          <span className=\"sr-only\">Open menu</span>\n                          <MoreVertical className=\"h-4 w-4\" />\n                        </Button>\n                      </DropdownMenuTrigger>\n                      <DropdownMenuContent align=\"end\">\n                        <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                        {isCurrentUser(member.userId) && (\n                          <DropdownMenuItem\n                            onClick={() =>\n                              removeTeammate(member.teamId, member.userId)\n                            }\n                            className=\"text-red-500 hover:cursor-pointer focus:bg-destructive focus:text-destructive-foreground\"\n                          >\n                            Leave team\n                          </DropdownMenuItem>\n                        )}\n                        {isCurrentUserAdmin() &&\n                        !isCurrentUser(member.userId) ? (\n                          <>\n                            {member.role !== \"ADMIN\" && (\n                              <DropdownMenuItem\n                                onClick={() =>\n                                  changeRole(\n                                    member.teamId,\n                                    member.userId,\n                                    \"ADMIN\",\n                                  )\n                                }\n                                className=\"hover:cursor-pointer\"\n                              >\n                                Change role to ADMIN\n                              </DropdownMenuItem>\n                            )}\n                            {member.role !== \"MANAGER\" && (\n                              <DropdownMenuItem\n                                onClick={() =>\n                                  changeRole(\n                                    member.teamId,\n                                    member.userId,\n                                    \"MANAGER\",\n                                  )\n                                }\n                                className=\"hover:cursor-pointer\"\n                              >\n                                Change role to MANAGER\n                              </DropdownMenuItem>\n                            )}\n                            {member.role !== \"MEMBER\" && (\n                              <DropdownMenuItem\n                                onClick={() =>\n                                  changeRole(\n                                    member.teamId,\n                                    member.userId,\n                                    \"MEMBER\",\n                                  )\n                                }\n                                className=\"hover:cursor-pointer\"\n                              >\n                                Change role to MEMBER\n                              </DropdownMenuItem>\n                            )}\n                            <DropdownMenuItem\n                              onClick={() =>\n                                removeTeammate(member.teamId, member.userId)\n                              }\n                              className=\"text-red-500 hover:cursor-pointer focus:bg-destructive focus:text-destructive-foreground\"\n                            >\n                              Remove teammate\n                            </DropdownMenuItem>\n                          </>\n                        ) : (\n                          <DropdownMenuItem\n                            disabled\n                            className=\"text-red-500 focus:bg-destructive focus:text-destructive-foreground\"\n                          >\n                            Remove teammate\n                          </DropdownMenuItem>\n                        )}\n                      </DropdownMenuContent>\n                    </DropdownMenu>\n                  )}\n                </div>\n              </li>\n            ))}\n            {invitations &&\n              invitations.map((invitation, index) => (\n                <li\n                  className=\"flex items-center justify-between px-10 py-4\"\n                  key={index}\n                >\n                  <div className=\"flex items-center gap-12\">\n                    <div className=\"space-y-1\">\n                      <h4 className=\"text-sm font-semibold\">\n                        {invitation.email}\n                      </h4>\n                      <p className=\"text-xs text-muted-foreground\">\n                        {invitation.email}\n                      </p>\n                    </div>\n                  </div>\n                  <div className=\"flex items-center gap-12\">\n                    <span\n                      className=\"text-sm text-foreground\"\n                      title={`Expires on ${new Date(\n                        invitation.expires,\n                      ).toLocaleString()}`}\n                    >\n                      {new Date(invitation.expires) >= new Date(Date.now())\n                        ? \"Pending\"\n                        : \"Expired\"}\n                    </span>\n\n                    <DropdownMenu>\n                      <DropdownMenuTrigger asChild>\n                        <Button variant=\"ghost\" className=\"h-8 w-8 p-0\">\n                          <span className=\"sr-only\">Open menu</span>\n                          <MoreVertical className=\"h-4 w-4\" />\n                        </Button>\n                      </DropdownMenuTrigger>\n                      <DropdownMenuContent align=\"end\">\n                        <DropdownMenuLabel>Actions</DropdownMenuLabel>\n                        <DropdownMenuItem\n                          onClick={() => resendInvitation(invitation)}\n                          className=\"text-red-500 hover:cursor-pointer focus:bg-destructive focus:text-destructive-foreground\"\n                        >\n                          Resend\n                        </DropdownMenuItem>\n                        <DropdownMenuItem\n                          onClick={() => revokeInvitation(invitation)}\n                          className=\"text-red-500 hover:cursor-pointer focus:bg-destructive focus:text-destructive-foreground\"\n                        >\n                          Revoke invitation\n                        </DropdownMenuItem>\n                      </DropdownMenuContent>\n                    </DropdownMenu>\n                  </div>\n                </li>\n              ))}\n          </ul>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/presets/[id].tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { FormEvent, useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LinkPreset } from \"@prisma/client\";\nimport { AlertCircle, ArrowLeft, Trash2, X } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport z from \"zod\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\nimport { WatermarkConfig } from \"@/lib/types\";\nimport { fetcher } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { DEFAULT_LINK_TYPE } from \"@/components/links/link-sheet\";\nimport AgreementSection from \"@/components/links/link-sheet/agreement-section\";\nimport AllowDownloadSection from \"@/components/links/link-sheet/allow-download-section\";\nimport AllowListSection from \"@/components/links/link-sheet/allow-list-section\";\nimport AllowNotificationSection from \"@/components/links/link-sheet/allow-notification-section\";\nimport { CustomFieldData } from \"@/components/links/link-sheet/custom-fields-panel\";\nimport CustomFieldsSection from \"@/components/links/link-sheet/custom-fields-section\";\nimport DenyListSection from \"@/components/links/link-sheet/deny-list-section\";\nimport EmailAuthenticationSection from \"@/components/links/link-sheet/email-authentication-section\";\nimport EmailProtectionSection from \"@/components/links/link-sheet/email-protection-section\";\nimport ExpirationInSection from \"@/components/links/link-sheet/expirationIn-section\";\nimport { LinkUpgradeOptions } from \"@/components/links/link-sheet/link-options\";\nimport OGSection from \"@/components/links/link-sheet/og-section\";\nimport PasswordSection from \"@/components/links/link-sheet/password-section\";\nimport { ProBannerSection } from \"@/components/links/link-sheet/pro-banner-section\";\nimport ScreenshotProtectionSection from \"@/components/links/link-sheet/screenshot-protection-section\";\nimport WatermarkSection from \"@/components/links/link-sheet/watermark-section\";\nimport Preview from \"@/components/settings/og-preview\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport {\n  Alert,\n  AlertClose,\n  AlertDescription,\n  AlertTitle,\n} from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Separator } from \"@/components/ui/separator\";\n\nexport type PRESET_DATA = Partial<DEFAULT_LINK_TYPE> & {\n  name: string;\n  enableAllowList?: boolean;\n  enableDenyList?: boolean;\n  expiresAt?: Date | null;\n  expiresIn?: number | null;\n  pId?: string | null;\n  enableCustomFields?: boolean;\n  customFields?: CustomFieldData[];\n};\n\nexport default function EditPreset() {\n  const router = useRouter();\n  const { id } = router.query;\n  const { currentTeamId: teamId } = useTeam();\n\n  const {\n    data: preset,\n    error,\n    isLoading: isLoadingPreset,\n  } = useSWR<LinkPreset>(\n    id && teamId ? `/api/teams/${teamId}/presets/${id}` : null,\n    fetcher,\n  );\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [isDeleting, setIsDeleting] = useState(false);\n  const [data, setData] = useState<PRESET_DATA | null>(null);\n  const [showDeleteAlert, setShowDeleteAlert] = useState(false);\n\n  const { isPro, isBusiness, isDatarooms, isDataroomsPlus, isTrial } =\n    usePlan();\n  const { limits } = useLimits();\n  const allowAdvancedLinkControls = limits\n    ? limits?.advancedLinkControlsOnPro\n    : false;\n  const allowWatermarkOnBusiness = limits?.watermarkOnBusiness ?? false;\n\n  const [openUpgradeModal, setOpenUpgradeModal] = useState<boolean>(false);\n  const [trigger, setTrigger] = useState<string>(\"\");\n  const [upgradePlan, setUpgradePlan] = useState<PlanEnum>(PlanEnum.Business);\n  const [highlightItem, setHighlightItem] = useState<string[]>([]);\n\n  const handleUpgradeStateChange = ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => {\n    setOpenUpgradeModal(state);\n    setTrigger(trigger);\n    if (plan) {\n      setUpgradePlan(plan as PlanEnum);\n    }\n    setHighlightItem(highlightItem || []);\n  };\n\n  useEffect(() => {\n    if (preset) {\n      const watermarkConfig = preset.watermarkConfig\n        ? (JSON.parse(preset.watermarkConfig as string) as WatermarkConfig)\n        : null;\n\n      const customFields = preset.customFields\n        ? (preset.customFields as CustomFieldData[])\n        : [];\n\n      setData({\n        id: null,\n        name: preset.name,\n        expiresAt: preset.expiresAt,\n        expiresIn: preset.expiresIn,\n        password: preset.password,\n        emailProtected: preset.emailProtected ?? true,\n        emailAuthenticated: preset.emailAuthenticated ?? false,\n        allowDownload: preset.allowDownload ?? false,\n        allowList: preset.allowList || [],\n        denyList: preset.denyList || [],\n        enableCustomMetatag: preset.enableCustomMetaTag ?? false,\n        metaTitle: preset.metaTitle,\n        metaDescription: preset.metaDescription,\n        metaImage: preset.metaImage,\n        metaFavicon: preset.metaFavicon,\n        enableWatermark: preset.enableWatermark ?? false,\n        watermarkConfig: watermarkConfig,\n        pId: preset.pId,\n        enableScreenshotProtection: preset.enableScreenshotProtection ?? false,\n        enableAgreement: preset.enableAgreement ?? false,\n        agreementId: preset.agreementId,\n        enableCustomFields: customFields.length > 0,\n        customFields: customFields,\n        enableNotification: preset.enableNotification ?? false,\n        showBanner: preset.showBanner ?? false,\n      });\n    }\n  }, [preset]);\n\n  const handleSubmit = async (e: FormEvent) => {\n    e.preventDefault();\n    if (!data) return;\n\n    setIsLoading(true);\n\n    if (data.expiresAt && data.expiresAt < new Date()) {\n      toast.error(\"Expiration time must be in the future\");\n      setIsLoading(false);\n      return;\n    }\n\n    try {\n      const presetId = z.string().cuid().parse(id);\n      const response = await fetch(`/api/teams/${teamId}/presets/${presetId}`, {\n        method: \"PUT\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name: data.name,\n          emailProtected: data.emailProtected,\n          emailAuthenticated: data.emailAuthenticated,\n          allowList: data.allowList,\n          denyList: data.denyList,\n          enableAllowList: data.allowList ? data.allowList.length > 0 : false,\n          enableDenyList: data.denyList ? data.denyList.length > 0 : false,\n          password: data.password,\n          enablePassword: !!data.password,\n          enableCustomMetaTag: data.enableCustomMetatag,\n          metaTitle: data.metaTitle,\n          metaDescription: data.metaDescription,\n          metaImage: data.metaImage,\n          metaFavicon: data.metaFavicon,\n          enableWatermark: data.enableWatermark,\n          watermarkConfig: data.watermarkConfig,\n          allowDownload: data.allowDownload,\n          expiresAt: data.expiresAt,\n          expiresIn: data.expiresIn,\n          pId: data.pId,\n          enableScreenshotProtection: data.enableScreenshotProtection,\n          enableAgreement: data.enableAgreement,\n          agreementId: data.agreementId,\n          enableCustomFields: data.enableCustomFields,\n          customFields: data.customFields,\n          enableNotification: data.enableNotification,\n          showBanner: data.showBanner,\n        }),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to update preset\");\n      }\n\n      toast.success(\"Preset updated successfully\");\n    } catch (error) {\n      toast.error(\"Failed to update preset\");\n      console.error(error);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const handleDelete = async () => {\n    setIsDeleting(true);\n\n    try {\n      const presetId = z.string().cuid().parse(id);\n      const response = await fetch(`/api/teams/${teamId}/presets/${presetId}`, {\n        method: \"DELETE\",\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to delete preset\");\n      }\n\n      toast.success(\"Preset deleted successfully\");\n      router.push(\"/settings/presets\");\n    } catch (error) {\n      toast.error(\"Failed to delete preset\");\n      console.error(error);\n    } finally {\n      setIsDeleting(false);\n    }\n  };\n\n  if (isLoadingPreset) {\n    return (\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n          <SettingsHeader />\n          <div className=\"flex items-center justify-center py-12\">\n            <p className=\"text-muted-foreground\">Loading preset...</p>\n          </div>\n        </main>\n      </AppLayout>\n    );\n  }\n\n  if (error || !preset || !data) {\n    return (\n      <AppLayout>\n        <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n          <SettingsHeader />\n          <div className=\"flex items-center justify-center py-12\">\n            <p className=\"text-muted-foreground\">Preset not found</p>\n          </div>\n        </main>\n      </AppLayout>\n    );\n  }\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"mb-2 flex items-center gap-2 pl-0 text-muted-foreground\"\n          onClick={() => router.push(\"/settings/presets\")}\n        >\n          <ArrowLeft className=\"h-4 w-4\" />\n          Back to presets\n        </Button>\n\n        {showDeleteAlert && (\n          <Alert variant=\"destructive\" className=\"mb-4\">\n            <AlertCircle className=\"h-4 w-4\" />\n            <AlertTitle>Are you sure?</AlertTitle>\n            <AlertDescription>\n              This action cannot be undone. This will permanently delete this\n              preset.\n            </AlertDescription>\n            <div className=\"mt-4 flex justify-end space-x-2\">\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => setShowDeleteAlert(false)}\n              >\n                Cancel\n              </Button>\n              <Button\n                variant=\"destructive\"\n                size=\"sm\"\n                onClick={handleDelete}\n                disabled={isDeleting}\n              >\n                {isDeleting ? \"Deleting...\" : \"Delete\"}\n              </Button>\n            </div>\n            <AlertClose onClick={() => setShowDeleteAlert(false)} />\n          </Alert>\n        )}\n\n        <form onSubmit={handleSubmit} className=\"space-y-8\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"space-y-1\">\n              <h2 className=\"text-2xl font-semibold tracking-tight\">\n                Edit Preset\n              </h2>\n              <p className=\"text-sm text-muted-foreground\">\n                Modify this preset configuration\n              </p>\n            </div>\n\n            <Button\n              variant=\"destructive\"\n              size=\"sm\"\n              disabled={isDeleting}\n              onClick={() => setShowDeleteAlert(true)}\n              type=\"button\"\n            >\n              <Trash2 className=\"mr-1.5 h-4 w-4\" />\n              {isDeleting ? \"Deleting...\" : \"Delete\"}\n            </Button>\n          </div>\n\n          <div className=\"space-y-4\">\n            <div className=\"space-y-2\">\n              <div className=\"flex items-center justify-between\">\n                <Label htmlFor=\"name\">Preset Name</Label>\n                {preset.pId && (\n                  <div className=\"flex items-center justify-end gap-1 rounded-md bg-muted px-2 py-1 font-mono text-xs font-medium text-muted-foreground\">\n                    <span>ID:</span>\n                    <code className=\"font-mono\">{preset.pId}</code>\n                  </div>\n                )}\n              </div>\n              <Input\n                id=\"name\"\n                value={data.name || \"\"}\n                onChange={(e) =>\n                  setData((prev) =>\n                    prev ? { ...prev, name: e.target.value } : null,\n                  )\n                }\n                required\n              />\n            </div>\n          </div>\n\n          <div className=\"grid grid-cols-1 gap-6 md:grid-cols-2\">\n            <div className=\"space-y-6\">\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Link Preview Cards</h3>\n                <OGSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  editLink={true}\n                  presets={null}\n                />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Basic Settings</h3>\n                <EmailProtectionSection\n                  data={data as any}\n                  setData={setData as any}\n                />\n                <EmailAuthenticationSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n\n                <AllowNotificationSection\n                  data={data as any}\n                  setData={setData as any}\n                />\n\n                <AllowDownloadSection\n                  data={data as any}\n                  setData={setData as any}\n                />\n\n                <ExpirationInSection\n                  data={data as any}\n                  setData={setData as any}\n                />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Access Control</h3>\n                <PasswordSection data={data as any} setData={setData as any} />\n                <AllowListSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n                <DenyListSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Watermark</h3>\n                <WatermarkSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    isDatarooms ||\n                    isDataroomsPlus ||\n                    allowWatermarkOnBusiness\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n                <ScreenshotProtectionSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n                <AgreementSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={isTrial || isDatarooms || isDataroomsPlus}\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n                <CustomFieldsSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Branding</h3>\n                <ProBannerSection\n                  data={data as any}\n                  setData={setData as any}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n              </div>\n            </div>\n\n            <div className=\"sticky top-0 md:overflow-auto\">\n              <div className=\"rounded-lg border\">\n                {/* <div className=\"sticky top-0 flex h-14 items-center justify-center border-b bg-white px-5 dark:bg-gray-900\">\n                  <h2 className=\"text-lg font-medium\">Preview</h2>\n                </div> */}\n                <div className=\"p-4\">\n                  <Preview data={data as any} setData={setData as any} />\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <Separator />\n\n          <div className=\"flex justify-end space-x-2\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={() => router.push(\"/settings/presets\")}\n            >\n              Cancel\n            </Button>\n            <Button type=\"submit\" disabled={isLoading}>\n              {isLoading ? \"Saving...\" : \"Save Changes\"}\n            </Button>\n          </div>\n        </form>\n      </main>\n      <UpgradePlanModal\n        clickedPlan={upgradePlan}\n        open={openUpgradeModal}\n        setOpen={setOpenUpgradeModal}\n        trigger={trigger}\n        highlightItem={highlightItem}\n      />\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/presets/index.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LinkPreset } from \"@prisma/client\";\nimport { format } from \"date-fns\";\nimport { CircleHelpIcon, CrownIcon, PlusIcon } from \"lucide-react\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { fetcher, formatExpirationTime } from \"@/lib/utils\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nexport default function Presets() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n\n  const { isBusiness, isDatarooms, isDataroomsPlus, isTrial } = usePlan();\n\n  const {\n    data: presets,\n    error,\n    isLoading,\n  } = useSWR<LinkPreset[]>(\n    teamInfo?.currentTeam?.id\n      ? `/api/teams/${teamInfo.currentTeam.id}/presets`\n      : null,\n    fetcher,\n  );\n\n  const [showUpgradeModal, setShowUpgradeModal] = useState(false);\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h3 className=\"text-2xl font-semibold tracking-tight text-foreground\">\n                Link Presets\n              </h3>\n              <p className=\"flex flex-row items-center gap-2 text-sm text-muted-foreground\">\n                Configure and save presets for your links\n                <BadgeTooltip content=\"Create reusable link configurations that can be applied to new links\">\n                  <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                </BadgeTooltip>\n              </p>\n            </div>\n            {isTrial || isBusiness || isDatarooms || isDataroomsPlus ? (\n              <Button onClick={() => router.push(\"/settings/presets/new\")}>\n                <PlusIcon className=\"mr-1.5 h-4 w-4\" />\n                Create Preset\n              </Button>\n            ) : (\n              <Button onClick={() => setShowUpgradeModal(true)}>\n                <CrownIcon className=\"mr-1.5 h-4 w-4\" />\n                Upgrade to create presets\n              </Button>\n            )}\n          </div>\n\n          {/* Presets List */}\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-12\">\n              <p className=\"text-muted-foreground\">Loading presets...</p>\n            </div>\n          ) : !presets || presets.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center space-y-4 py-12\">\n              <div className=\"rounded-full bg-gray-100 p-3\">\n                <PlusIcon className=\"h-6 w-6 text-gray-600\" />\n              </div>\n              <div className=\"text-center\">\n                <h3 className=\"font-medium\">No presets configured</h3>\n                <p className=\"mt-1 max-w-sm text-sm text-gray-500\">\n                  Create link presets to quickly apply your preferred settings\n                  when creating links.\n                </p>\n              </div>\n              {isTrial || isBusiness || isDatarooms || isDataroomsPlus ? (\n                <Button\n                  variant=\"outline\"\n                  onClick={() => router.push(\"/settings/presets/new\")}\n                >\n                  Create your first preset\n                </Button>\n              ) : (\n                <Button\n                  variant=\"outline\"\n                  onClick={() => setShowUpgradeModal(true)}\n                >\n                  Upgrade to create presets\n                </Button>\n              )}\n            </div>\n          ) : (\n            <div className=\"grid grid-cols-1 gap-3\">\n              {presets.map((preset) => (\n                <Link\n                  key={preset.id}\n                  href={`/settings/presets/${preset.id}`}\n                  className=\"rounded-xl border border-gray-200 bg-white p-4 transition-[filter] hover:bg-gray-50 dark:border-gray-400 dark:bg-secondary dark:hover:bg-gray-800 sm:p-5\"\n                >\n                  <div className=\"flex items-center justify-between\">\n                    <div>\n                      <div className=\"flex items-center gap-2\">\n                        <span className=\"font-semibold\">{preset.name}</span>\n                      </div>\n                      <div className=\"mt-1 flex items-center gap-4 text-sm text-muted-foreground\">\n                        <div className=\"flex items-center gap-1\">\n                          <span>\n                            Created:{\" \"}\n                            {format(new Date(preset.createdAt), \"MMM d, yyyy\")}\n                          </span>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </Link>\n              ))}\n            </div>\n          )}\n        </div>\n      </main>\n      <UpgradePlanModal\n        clickedPlan={PlanEnum.Business}\n        trigger=\"presets_page\"\n        open={showUpgradeModal}\n        setOpen={setShowUpgradeModal}\n      />\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/presets/new.tsx",
    "content": "import { useRouter } from \"next/router\";\n\n\n\nimport { FormEvent, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { LinkType } from \"@prisma/client\";\nimport { ArrowLeft } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useLimits from \"@/lib/swr/use-limits\";\n\nimport { UpgradePlanModal } from \"@/components/billing/upgrade-plan-modal\";\nimport AppLayout from \"@/components/layouts/app\";\nimport {\n  DEFAULT_LINK_PROPS,\n  DEFAULT_LINK_TYPE,\n} from \"@/components/links/link-sheet\";\nimport AgreementSection from \"@/components/links/link-sheet/agreement-section\";\nimport AllowDownloadSection from \"@/components/links/link-sheet/allow-download-section\";\nimport AllowListSection from \"@/components/links/link-sheet/allow-list-section\";\nimport AllowNotificationSection from \"@/components/links/link-sheet/allow-notification-section\";\nimport { CustomFieldData } from \"@/components/links/link-sheet/custom-fields-panel\";\nimport CustomFieldsSection from \"@/components/links/link-sheet/custom-fields-section\";\nimport DenyListSection from \"@/components/links/link-sheet/deny-list-section\";\nimport EmailAuthenticationSection from \"@/components/links/link-sheet/email-authentication-section\";\nimport EmailProtectionSection from \"@/components/links/link-sheet/email-protection-section\";\nimport ExpirationInSection from \"@/components/links/link-sheet/expirationIn-section\";\nimport { LinkUpgradeOptions } from \"@/components/links/link-sheet/link-options\";\nimport OGSection from \"@/components/links/link-sheet/og-section\";\nimport PasswordSection from \"@/components/links/link-sheet/password-section\";\nimport { ProBannerSection } from \"@/components/links/link-sheet/pro-banner-section\";\nimport ScreenshotProtectionSection from \"@/components/links/link-sheet/screenshot-protection-section\";\nimport WatermarkSection from \"@/components/links/link-sheet/watermark-section\";\nimport Preview from \"@/components/settings/og-preview\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Separator } from \"@/components/ui/separator\";\n\nexport default function NewPreset() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [data, setData] = useState<\n    DEFAULT_LINK_TYPE & {\n      expiresIn?: number | null;\n      customFields?: CustomFieldData[];\n    }\n  >({\n    ...DEFAULT_LINK_PROPS(LinkType.DOCUMENT_LINK),\n    name: \"\",\n  });\n\n  const {\n    isPro,\n    isBusiness,\n    isDatarooms,\n    isDataroomsPlus,\n    isTrial,\n  } = usePlan();\n  const { limits } = useLimits();\n  const allowAdvancedLinkControls = limits\n    ? limits?.advancedLinkControlsOnPro\n    : false;\n  const allowWatermarkOnBusiness = limits?.watermarkOnBusiness ?? false;\n\n  const [openUpgradeModal, setOpenUpgradeModal] = useState<boolean>(false);\n  const [trigger, setTrigger] = useState<string>(\"\");\n  const [upgradePlan, setUpgradePlan] = useState<PlanEnum>(PlanEnum.Business);\n  const [highlightItem, setHighlightItem] = useState<string[]>([]);\n\n  const handleUpgradeStateChange = ({\n    state,\n    trigger,\n    plan,\n    highlightItem,\n  }: LinkUpgradeOptions) => {\n    setOpenUpgradeModal(state);\n    setTrigger(trigger);\n    if (plan) {\n      setUpgradePlan(plan as PlanEnum);\n    }\n    setHighlightItem(highlightItem || []);\n  };\n\n  const handleSubmit = async (e: FormEvent) => {\n    e.preventDefault();\n    if (!data.name) {\n      toast.error(\"Please provide a name for the preset\");\n      return;\n    }\n\n    if (data.expiresAt && data.expiresAt < new Date()) {\n      toast.error(\"Expiration time must be in the future\");\n      return;\n    }\n\n    setIsLoading(true);\n\n    try {\n      const response = await fetch(`/api/teams/${teamId}/presets`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name: data.name,\n          emailProtected: data.emailProtected,\n          emailAuthenticated: data.emailAuthenticated,\n          allowList: data.allowList,\n          denyList: data.denyList,\n          enableAllowList: data.allowList ? data.allowList.length > 0 : false,\n          enableDenyList: data.denyList ? data.denyList.length > 0 : false,\n          password: data.password,\n          enablePassword: !!data.password,\n          enableCustomMetaTag: data.enableCustomMetatag,\n          metaTitle: data.metaTitle,\n          metaDescription: data.metaDescription,\n          metaImage: data.metaImage,\n          metaFavicon: data.metaFavicon,\n          enableWatermark: data.enableWatermark,\n          watermarkConfig: data.watermarkConfig,\n          allowDownload: data.allowDownload,\n          expiresAt: data.expiresAt,\n          expiresIn: data.expiresIn || null,\n          enableScreenshotProtection: data.enableScreenshotProtection,\n          enableAgreement: data.enableAgreement,\n          agreementId: data.agreementId,\n          enableCustomFields: data.customFields\n            ? data.customFields.length > 0\n            : false,\n          customFields: data.customFields,\n          enableNotification: data.enableNotification,\n          showBanner: data.showBanner,\n        }),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to create preset\");\n      }\n\n      toast.success(\"Preset created successfully\");\n      router.push(\"/settings/presets\");\n    } catch (error) {\n      toast.error(\"Failed to create preset\");\n      console.error(error);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"mb-2 flex items-center gap-2 pl-0 text-muted-foreground\"\n          onClick={() => router.push(\"/settings/presets\")}\n        >\n          <ArrowLeft className=\"h-4 w-4\" />\n          Back to presets\n        </Button>\n\n        <form onSubmit={handleSubmit} className=\"space-y-8\">\n          <div className=\"space-y-4\">\n            <h2 className=\"text-2xl font-semibold tracking-tight\">\n              Create New Preset\n            </h2>\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"name\">Preset Name</Label>\n              <p className=\"text-sm text-muted-foreground\">\n                Choose a descriptive name for this preset\n              </p>\n              <Input\n                id=\"name\"\n                placeholder=\"My Link Preset\"\n                value={data.name || \"\"}\n                onChange={(e) =>\n                  setData((prev) => ({ ...prev, name: e.target.value }))\n                }\n                required\n                data-1p-ignore\n                autoComplete=\"off\"\n                autoFocus\n              />\n            </div>\n          </div>\n\n          <div className=\"grid grid-cols-1 gap-6 md:grid-cols-2\">\n            <div className=\"space-y-6\">\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Link Preview Cards</h3>\n                <OGSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  editLink={false}\n                  presets={null}\n                />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Basic Settings</h3>\n                <EmailProtectionSection data={data} setData={setData} />\n                <EmailAuthenticationSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n                <AllowNotificationSection data={data} setData={setData} />\n                <AllowDownloadSection data={data} setData={setData} />\n                <ExpirationInSection data={data} setData={setData} />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Access Control</h3>\n                <PasswordSection data={data} setData={setData} />\n                <AllowListSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n                <DenyListSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">\n                  Additional Security\n                </h3>\n                <WatermarkSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    isDatarooms ||\n                    isDataroomsPlus ||\n                    allowWatermarkOnBusiness\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n                <ScreenshotProtectionSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n                <AgreementSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={isTrial || isDatarooms || isDataroomsPlus}\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n                <CustomFieldsSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                  presets={null}\n                />\n              </div>\n\n              <div className=\"rounded-lg border p-6\">\n                <h3 className=\"mb-4 text-lg font-medium\">Branding</h3>\n                <ProBannerSection\n                  data={data}\n                  setData={setData}\n                  isAllowed={\n                    isTrial ||\n                    (isPro && allowAdvancedLinkControls) ||\n                    isBusiness ||\n                    isDatarooms ||\n                    isDataroomsPlus\n                  }\n                  handleUpgradeStateChange={handleUpgradeStateChange}\n                />\n              </div>\n            </div>\n\n            <div className=\"sticky top-0 md:overflow-auto\">\n              <div className=\"rounded-lg border\">\n                {/* <div className=\"sticky top-0 flex h-14 items-center justify-center border-b bg-white px-5 dark:bg-gray-900\">\n                  <h2 className=\"text-lg font-medium\">Preview</h2>\n                </div> */}\n                <div className=\"p-4\">\n                  <Preview data={data as any} setData={setData as any} />\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <Separator />\n\n          <div className=\"flex justify-end space-x-2\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              onClick={() => router.push(\"/settings/presets\")}\n            >\n              Cancel\n            </Button>\n            <Button type=\"submit\" disabled={isLoading}>\n              {isLoading ? \"Creating...\" : \"Create Preset\"}\n            </Button>\n          </div>\n        </form>\n      </main>\n      <UpgradePlanModal\n        clickedPlan={upgradePlan}\n        open={openUpgradeModal}\n        setOpen={setOpenUpgradeModal}\n        trigger={trigger}\n        highlightItem={highlightItem}\n      />\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/security.tsx",
    "content": "import Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  DirectorySyncConfigModal,\n  SAMLConfigModal,\n  SSOEnforcementToggle,\n} from \"@/ee/features/security/sso\";\nimport { FolderSync, Info, Shield } from \"lucide-react\";\n\nimport { useFeatureFlags } from \"@/lib/hooks/use-feature-flags\";\nimport { useIsAdmin } from \"@/lib/hooks/use-is-admin\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\n\nconst SSO_ELIGIBLE_PLANS = [\"datarooms-premium\", \"datarooms-premium+old\"];\n\nexport default function SecuritySettings() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const teamPlan = teamInfo?.currentTeam?.plan;\n  const { isFeatureEnabled } = useFeatureFlags();\n  const { isAdmin, loading: isAdminLoading } = useIsAdmin();\n\n  // Redirect non-admin users to general settings\n  useEffect(() => {\n    if (!isAdminLoading && !isAdmin) {\n      router.replace(\"/settings/general\");\n    }\n  }, [isAdmin, isAdminLoading, router]);\n\n  // Show nothing while checking admin status\n  if (isAdminLoading || !isAdmin) {\n    return (\n      <AppLayout>\n        <div />\n      </AppLayout>\n    );\n  }\n\n  const isSSOFeatureEnabled = isFeatureEnabled(\"sso\");\n  const isPlanEligible = teamPlan\n    ? SSO_ELIGIBLE_PLANS.includes(teamPlan)\n    : false;\n  const canAccessSSO = isSSOFeatureEnabled && isPlanEligible;\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        {!canAccessSSO ? (\n          <div className=\"rounded-lg border border-muted p-6 sm:p-10\">\n            <div className=\"flex items-start space-x-3\">\n              <Shield className=\"mt-0.5 h-5 w-5 text-muted-foreground\" />\n              <div className=\"space-y-1\">\n                <h2 className=\"text-xl font-medium\">\n                  SAML SSO &amp; SCIM Directory Sync\n                </h2>\n                <p className=\"text-sm text-muted-foreground\">\n                  Enterprise security features including SAML Single Sign-On and\n                  SCIM directory sync are available as an add-on on the\n                  Datarooms Premium plan.\n                </p>\n                {!isPlanEligible && (\n                  <p className=\"mt-2 text-sm text-muted-foreground\">\n                    <Link\n                      href=\"/settings/billing\"\n                      className=\"font-medium underline\"\n                    >\n                      Upgrade to Datarooms Premium\n                    </Link>{\" \"}\n                    to add SSO for your team.\n                  </p>\n                )}\n                {isPlanEligible && !isSSOFeatureEnabled && (\n                  <p className=\"mt-2 text-sm text-muted-foreground\">\n                    SSO is not yet enabled for your team. Please contact support\n                    to enable it.\n                  </p>\n                )}\n              </div>\n            </div>\n          </div>\n        ) : (\n          <>\n            {/* SAML SSO Section */}\n            <div className=\"rounded-lg border border-muted p-6 sm:p-10\">\n              <div className=\"space-y-6\">\n                <div className=\"flex items-start space-x-3\">\n                  <Shield className=\"mt-0.5 h-5 w-5 text-muted-foreground\" />\n                  <div className=\"space-y-1\">\n                    <h2 className=\"text-xl font-medium\">\n                      SAML Single Sign-On (SSO)\n                    </h2>\n                    <p className=\"text-sm text-muted-foreground\">\n                      Allow team members to sign in using your\n                      organization&apos;s Identity Provider (IdP) such as\n                      Microsoft Entra ID, Okta, or Google Workspace.\n                    </p>\n                  </div>\n                </div>\n\n                {teamId ? (\n                  <SAMLConfigModal teamId={teamId} />\n                ) : (\n                  <div className=\"text-sm text-muted-foreground\">\n                    Select a team to manage SAML SSO settings.\n                  </div>\n                )}\n\n                {/* SSO Enforcement Toggle */}\n                {teamId && <SSOEnforcementToggle teamId={teamId} />}\n\n                <div className=\"flex items-start space-x-2 rounded-md border bg-muted/30 p-3\">\n                  <Info className=\"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground\" />\n                  <div className=\"text-xs text-muted-foreground\">\n                    <p className=\"font-medium\">\n                      Setting up SAML SSO with Microsoft Entra ID:\n                    </p>\n                    <ol className=\"mt-1 list-inside list-decimal space-y-1\">\n                      <li>Create an Enterprise Application in Azure Portal</li>\n                      <li>\n                        Configure Single Sign-On &rarr; SAML with the Entity ID\n                        and ACS URL shown above\n                      </li>\n                      <li>\n                        Copy the App Federation Metadata URL from SAML\n                        Certificates\n                      </li>\n                      <li>Paste it in the configuration dialog above</li>\n                      <li>Assign users and groups to the application</li>\n                    </ol>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            {/* Directory Sync Section */}\n            <div className=\"rounded-lg border border-muted p-6 sm:p-10\">\n              <div className=\"space-y-6\">\n                <div className=\"flex items-start space-x-3\">\n                  <FolderSync className=\"mt-0.5 h-5 w-5 text-muted-foreground\" />\n                  <div className=\"space-y-1\">\n                    <h2 className=\"text-xl font-medium\">SCIM Directory Sync</h2>\n                    <p className=\"text-sm text-muted-foreground\">\n                      Automatically provision and deprovision team members from\n                      your Identity Provider. When users are added or removed in\n                      your IdP, they&apos;ll be automatically synced to this\n                      team.\n                    </p>\n                  </div>\n                </div>\n\n                {teamId ? (\n                  <DirectorySyncConfigModal teamId={teamId} />\n                ) : (\n                  <div className=\"text-sm text-muted-foreground\">\n                    Select a team to manage Directory Sync settings.\n                  </div>\n                )}\n\n                <div className=\"flex items-start space-x-2 rounded-md border bg-muted/30 p-3\">\n                  <Info className=\"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground\" />\n                  <div className=\"text-xs text-muted-foreground\">\n                    <p className=\"font-medium\">\n                      Setting up SCIM with Microsoft Entra ID:\n                    </p>\n                    <ol className=\"mt-1 list-inside list-decimal space-y-1\">\n                      <li>\n                        Create a directory sync connection above to get\n                        credentials\n                      </li>\n                      <li>\n                        In Azure Portal &rarr; Enterprise Application &rarr;\n                        Provisioning\n                      </li>\n                      <li>Set Provisioning Mode to &quot;Automatic&quot;</li>\n                      <li>\n                        Paste the SCIM Base URL as Tenant URL and Bearer Token\n                        as Secret Token\n                      </li>\n                      <li>\n                        Click &quot;Test Connection&quot; to verify, then Save\n                      </li>\n                      <li>\n                        Turn provisioning Status to &quot;On&quot; and assign\n                        users\n                      </li>\n                    </ol>\n                    <p className=\"mt-2 italic\">\n                      Note: Azure SCIM provisioning can take 20-40 minutes for\n                      the initial sync cycle.\n                    </p>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </>\n        )}\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/slack.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useCallback, useEffect, useMemo, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  CircleHelpIcon,\n  Hash,\n  RefreshCwIcon,\n  Settings,\n  XCircleIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport {\n  SlackChannelConfig,\n  SlackIntegration,\n} from \"@/lib/integrations/slack/types\";\nimport { useSlackChannels } from \"@/lib/swr/use-slack-channels\";\nimport { useSlackIntegration } from \"@/lib/swr/use-slack-integration\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport SlackSettingsSkeleton from \"@/components/settings/slack-settings-skeleton\";\nimport { SlackIcon } from \"@/components/shared/icons/slack-icon\";\nimport { CommonAlertDialog } from \"@/components/ui/alert-dialog\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\";\nimport { Label } from \"@/components/ui/label\";\nimport { MultiSelect } from \"@/components/ui/multi-select-v2\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nexport default function SlackSettings() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const [connecting, setConnecting] = useState(false);\n  const [isChannelPopoverOpen, setIsChannelPopoverOpen] = useState(false);\n  const [pendingChannelUpdate, setPendingChannelUpdate] = useState(false);\n  const [refreshingChannels, setRefreshingChannels] = useState(false);\n  const analytics = useAnalytics();\n\n  // Use SWR hook for integration data\n  const {\n    integration,\n    error: integrationError,\n    loading: loadingIntegration,\n    mutate: mutateIntegration,\n  } = useSlackIntegration({\n    enabled: !!teamId,\n  });\n\n  const {\n    channels,\n    loading: loadingChannels,\n    error: channelsError,\n    mutate: mutateChannels,\n  } = useSlackChannels({\n    enabled: !!integration,\n  });\n\n  const ChannelIcon = useMemo(\n    () => <Hash className=\"h-4 w-4 text-muted-foreground\" />,\n    [],\n  );\n\n  const filteredChannels = useMemo(\n    () => channels.filter((channel) => !channel.is_archived),\n    [channels],\n  );\n\n  const channelOptions = useMemo(\n    () =>\n      filteredChannels.map((channel) => ({\n        value: channel.id,\n        label: channel.name,\n        icon: ChannelIcon,\n        meta: {\n          color: \"slate\",\n          description: channel.is_private\n            ? \"Private channel\"\n            : \"Public channel\",\n        },\n      })),\n    [filteredChannels, ChannelIcon],\n  );\n\n  useEffect(() => {\n    let timeoutId: NodeJS.Timeout | null = null;\n\n    if (router.query.success) {\n      toast.success(\"Slack integration connected successfully!\");\n      mutateIntegration();\n\n      // Track successful connection on client side\n      analytics.capture(\"Slack Connected\", {\n        source: \"settings_page\",\n        team_id: teamId,\n      });\n\n      if (router.query.warning) {\n        toast.warning(`Warning: ${router.query.warning}`);\n      }\n\n      timeoutId = setTimeout(() => {\n        router.replace(\"/settings/slack\", undefined, { shallow: true });\n      }, 100);\n    } else if (router.query.error) {\n      toast.error(`Failed to connect Slack: ${router.query.error}`);\n\n      // Track failed connection on client side\n      analytics.capture(\"Slack Connection Failed\", {\n        source: \"settings_page\",\n        team_id: teamId,\n        error: router.query.error,\n      });\n\n      timeoutId = setTimeout(() => {\n        router.replace(\"/settings/slack\", undefined, { shallow: true });\n      }, 100);\n    }\n\n    return () => {\n      if (timeoutId) clearTimeout(timeoutId);\n    };\n  }, [router.query, mutateIntegration, analytics, teamId]);\n\n  const handleConnect = async () => {\n    if (!teamId) return;\n\n    setConnecting(true);\n    analytics.capture(\"Slack Connect Button Clicked\", {\n      source: \"settings_page\",\n      team_id: teamId,\n    });\n\n    try {\n      const response = await fetch(\n        `/api/integrations/slack/oauth/authorize?teamId=${teamId}`,\n      );\n      const data = await response.json();\n\n      if (response.ok) {\n        // Redirect to Slack OAuth\n        window.location.href = data.oauthUrl;\n      } else {\n        toast.error(data.error || \"Failed to start OAuth process\");\n      }\n    } catch (error) {\n      console.error(\"Error starting OAuth:\", error);\n      toast.error(\"Failed to start OAuth process\");\n    } finally {\n      setConnecting(false);\n    }\n  };\n\n  const handleDisconnect = async () => {\n    const disconnectPromise = async () => {\n      const response = await fetch(`/api/teams/${teamId}/integrations/slack`, {\n        method: \"DELETE\",\n      });\n\n      if (response.ok) {\n        mutateIntegration(undefined, false);\n      } else {\n        const data = await response.json();\n        throw new Error(data.error || \"Failed to disconnect Slack\");\n      }\n    };\n\n    toast.promise(disconnectPromise(), {\n      loading: \"Disconnecting Slack integration...\",\n      success: \"Slack integration disconnected successfully\",\n      error: \"Failed to disconnect Slack integration. Please try again.\",\n    });\n  };\n\n  const handleChannelsUpdate = useCallback(\n    async (selectedChannelIds: string[]) => {\n      if (!teamId || !integration) return;\n      setIsChannelPopoverOpen(false);\n\n      const updatePromise = async () => {\n        const validChannelIds = selectedChannelIds.filter((id) =>\n          channels.some((channel) => channel.id === id),\n        );\n\n        if (validChannelIds.length !== selectedChannelIds.length) {\n          throw new Error(\"Some selected channels are no longer available\");\n        }\n\n        const updatedChannels = validChannelIds.reduce(\n          (acc, channelId) => {\n            const channel = channels.find((c) => c.id === channelId);\n            if (channel) {\n              acc[channelId] = {\n                id: channelId,\n                name: channel.name,\n                enabled: true,\n                notificationTypes: [\n                  \"document_view\",\n                  \"dataroom_access\",\n                  \"document_download\",\n                ],\n              } as SlackChannelConfig;\n            }\n            return acc;\n          },\n          {} as Record<string, SlackChannelConfig>,\n        );\n        const previousIntegration = integration;\n        mutateIntegration(\n          {\n            ...integration,\n            configuration: {\n              enabledChannels: updatedChannels,\n            },\n          },\n          false,\n        );\n\n        try {\n          const requestBody = {\n            enabledChannels: updatedChannels,\n          };\n\n          const startTime = performance.now();\n          const response = await fetch(\n            `/api/teams/${teamId}/integrations/slack/channels`,\n            {\n              method: \"PUT\",\n              headers: { \"Content-Type\": \"application/json\" },\n              body: JSON.stringify(requestBody),\n            },\n          );\n          const endTime = performance.now();\n\n          if (!response.ok) {\n            const errorData = await response.json();\n            throw new Error(\n              errorData.error || \"Failed to update channel settings\",\n            );\n          }\n\n          const result = await response.json();\n\n          // Handle the simplified response\n          if (result.success) {\n            mutateIntegration(\n              {\n                ...integration,\n                configuration: {\n                  enabledChannels: result.enabledChannels,\n                },\n                updatedAt: result.updatedAt,\n              },\n              false,\n            );\n          } else {\n            // Fallback for full integration response\n            mutateIntegration(result, false);\n          }\n\n          mutateChannels();\n\n          return \"Channel settings updated successfully\";\n        } catch (error) {\n          // Rollback on error\n          mutateIntegration(previousIntegration, false);\n          throw error;\n        }\n      };\n\n      toast.promise(updatePromise(), {\n        loading: \"Updating channel settings...\",\n        success: (message) => message,\n        error: (error) => error.message || \"Failed to update channel settings\",\n      });\n    },\n    [\n      teamId,\n      integration,\n      channels,\n      mutateChannels,\n      mutateIntegration,\n      setIsChannelPopoverOpen,\n    ],\n  );\n\n  const debouncedChannelsUpdate = (selectedChannelIds: string[]) => {\n    setPendingChannelUpdate(true);\n    handleChannelsUpdate(selectedChannelIds).finally(() => {\n      setPendingChannelUpdate(false);\n    });\n  };\n\n  const handleIntegrationToggle = useCallback(\n    async (checked: boolean) => {\n      if (!teamId || !integration) return;\n\n      const togglePromise = async () => {\n        const previousState = integration.enabled;\n\n        // Optimistic update\n        mutateIntegration(\n          {\n            ...integration,\n            enabled: checked,\n          },\n          false,\n        );\n\n        const response = await fetch(\n          `/api/teams/${teamId}/integrations/slack`,\n          {\n            method: \"PUT\",\n            headers: {\n              \"Content-Type\": \"application/json\",\n            },\n            body: JSON.stringify({ enabled: checked }),\n          },\n        );\n\n        if (!response.ok) {\n          // Rollback on error\n          mutateIntegration(\n            {\n              ...integration,\n              enabled: previousState,\n            },\n            false,\n          );\n          throw new Error(\"Failed to update notification settings\");\n        }\n\n        const updatedIntegration: SlackIntegration = await response.json();\n        mutateIntegration(updatedIntegration, false);\n\n        return checked\n          ? \"Slack notifications enabled\"\n          : \"Slack notifications disabled\";\n      };\n\n      toast.promise(togglePromise(), {\n        loading: \"Updating notification settings...\",\n        success: (message) => message,\n        error: \"Failed to update notification settings\",\n      });\n    },\n    [teamId, integration, mutateIntegration],\n  );\n\n  const handleRefreshChannels = useCallback(async () => {\n    setRefreshingChannels(true);\n    try {\n      await mutateChannels();\n      toast.success(\"Channel list refreshed\");\n    } catch (error) {\n      toast.error(\"Failed to refresh channel list\");\n    } finally {\n      setRefreshingChannels(false);\n    }\n  }, [mutateChannels]);\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <div>\n          {loadingIntegration ? (\n            <SlackSettingsSkeleton />\n          ) : (\n            <>\n              <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n                <div className=\"space-y-1\">\n                  <h3 className=\"flex items-center gap-2 text-2xl font-semibold tracking-tight text-foreground\">\n                    <SlackIcon className=\"h-6 w-6\" />\n                    Slack Integration\n                  </h3>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Receive notifications in your Slack channels when documents\n                    are viewed or accessed\n                  </p>\n                </div>\n                {!integration ? (\n                  <Button onClick={handleConnect} disabled={connecting}>\n                    {connecting ? (\n                      <>\n                        <div className=\"mr-2 h-4 w-4 animate-spin rounded-full border-b-2 border-white\"></div>\n                        Connecting...\n                      </>\n                    ) : (\n                      <>\n                        <SlackIcon className=\"mr-2 h-4 w-4\" />\n                        Connect to Slack\n                      </>\n                    )}\n                  </Button>\n                ) : (\n                  <CommonAlertDialog\n                    title=\"Disconnect Slack Integration\"\n                    description=\"Are you sure you want to disconnect Slack? This will remove all notification settings and stop sending notifications to your Slack channels.\"\n                    action=\"Disconnect\"\n                    actionUpdate=\"Disconnecting\"\n                    onAction={handleDisconnect}\n                  />\n                )}\n              </div>\n              {!integration ? (\n                // Not connected state\n                <Card>\n                  <CardHeader>\n                    <CardTitle className=\"flex items-center gap-2\">\n                      <SlackIcon className=\"h-5 w-5\" />\n                      Connect Slack\n                    </CardTitle>\n                    <CardDescription>\n                      Connect your Slack workspace to receive real-time\n                      notifications about document activity.\n                    </CardDescription>\n                  </CardHeader>\n                  <CardContent>\n                    <div className=\"space-y-4\">\n                      <div className=\"flex items-center space-x-2\">\n                        <Switch disabled={true} />\n                        <span className=\"text-sm font-medium\">\n                          Slack notifications\n                        </span>\n                        <Badge variant=\"secondary\">Not connected</Badge>\n                      </div>\n                    </div>\n                  </CardContent>\n                </Card>\n              ) : (\n                // Connected state\n                <div className=\"space-y-6\">\n                  {/* General Settings */}\n                  <Card>\n                    <CardHeader>\n                      <CardTitle className=\"flex items-center gap-2\">\n                        <Settings className=\"h-5 w-5\" />\n                        General Settings\n                      </CardTitle>\n                      <CardDescription>\n                        Connected to {integration.credentials.team.name}\n                      </CardDescription>\n                    </CardHeader>\n                    <CardContent>\n                      <div className=\"space-y-6\">\n                        {/* Slack Notification Toggle */}\n                        <div className=\"flex items-center justify-between\">\n                          <div className=\"space-y-1\">\n                            <div className=\"flex items-center gap-2\">\n                              <SlackIcon className=\"h-5 w-5\" />\n                              <h4 className=\"font-medium\">\n                                Slack notification\n                              </h4>\n                            </div>\n                            <p className=\"text-sm text-muted-foreground\">\n                              Receive notifications in your Slack channels\n                            </p>\n                          </div>\n                          <div className=\"flex items-center gap-2\">\n                            <Switch\n                              checked={integration.enabled}\n                              disabled={false}\n                              onCheckedChange={handleIntegrationToggle}\n                            />\n                          </div>\n                        </div>\n\n                        <Separator />\n                        <div className=\"space-y-3\">\n                          <div className=\"flex items-start justify-between\">\n                            <div>\n                              <Label className=\"flex items-center gap-2 text-sm font-medium\">\n                                Slack channel(s) *\n                                <BadgeTooltip\n                                  content=\"Get instant notifications in Slack when someone views, downloads, or interacts with your documents and datarooms\"\n                                  key=\"channel_tooltip\"\n                                >\n                                  <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                                </BadgeTooltip>\n                              </Label>\n                              <p className=\"text-sm text-muted-foreground\">\n                                Select the Slack channel(s) where you want to\n                                receive notifications.\n                              </p>\n                            </div>\n                            <Button\n                              variant=\"ghost\"\n                              size=\"sm\"\n                              onClick={handleRefreshChannels}\n                              disabled={refreshingChannels || loadingChannels}\n                              className=\"shrink-0\"\n                            >\n                              <RefreshCwIcon\n                                className={`mr-1 h-4 w-4 ${refreshingChannels ? \"animate-spin\" : \"\"}`}\n                              />\n                              Refresh\n                            </Button>\n                          </div>\n                          <p className=\"text-xs text-muted-foreground\">\n                            To add Papermark to a private channel, type{\" \"}\n                            <code className=\"rounded bg-muted px-1 py-0.5 font-mono text-xs\">\n                              /invite @Papermark\n                            </code>{\" \"}\n                            in that channel first, then refresh the list.\n                          </p>\n\n                          {!integration ? (\n                            <div className=\"flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400\">\n                              <div className=\"h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600 dark:border-gray-600 dark:border-t-gray-400\"></div>\n                              <span>Loading integration...</span>\n                            </div>\n                          ) : channelsError ? (\n                            <div className=\"flex items-center justify-between rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400\">\n                              <div className=\"flex items-center gap-2\">\n                                <XCircleIcon className=\"h-4 w-4\" />\n                                <div>\n                                  <p className=\"font-medium\">\n                                    Failed to load channels\n                                  </p>\n                                </div>\n                              </div>\n                            </div>\n                          ) : loadingChannels ? (\n                            <div className=\"flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400\">\n                              <div className=\"h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600 dark:border-gray-600 dark:border-t-gray-400\"></div>\n                              <span>Loading channels...</span>\n                            </div>\n                          ) : channels.length === 0 ? (\n                            <div className=\"flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400\">\n                              <Hash className=\"h-4 w-4\" />\n                              <div>\n                                <p className=\"font-medium\">\n                                  No channels available\n                                </p>\n                              </div>\n                            </div>\n                          ) : (\n                            <MultiSelect\n                              loading={false}\n                              options={channelOptions}\n                              value={Object.keys(\n                                integration.configuration?.enabledChannels ||\n                                  {},\n                              )}\n                              setIsPopoverOpen={setIsChannelPopoverOpen}\n                              isPopoverOpen={isChannelPopoverOpen}\n                              onValueChange={debouncedChannelsUpdate}\n                              placeholder={\n                                pendingChannelUpdate\n                                  ? \"Saving changes...\"\n                                  : \"Select channels...\"\n                              }\n                              maxCount={5}\n                              searchPlaceholder=\"Search channels...\"\n                              triggerIcon={\n                                <Hash className=\"h-4 w-4 text-muted-foreground\" />\n                              }\n                            />\n                          )}\n                        </div>\n                      </div>\n                    </CardContent>\n                  </Card>\n                </div>\n              )}\n            </>\n          )}\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/tags.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { Fragment, useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport {\n  InfoIcon,\n  MoreHorizontalIcon,\n  Settings2Icon,\n  Tag,\n  TagIcon,\n  TrashIcon,\n} from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\n\nimport { useTags } from \"@/lib/swr/use-tags\";\nimport { TagColorProps, tagColors } from \"@/lib/types\";\n\nimport { Pagination } from \"@/components/documents/pagination\";\nimport AppLayout from \"@/components/layouts/app\";\nimport {\n  COLORS_LIST,\n  randomBadgeColor,\n} from \"@/components/links/link-sheet/tags/tag-badge\";\nimport { SearchBoxPersisted } from \"@/components/search-box\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { AddTagsModal } from \"@/components/tags/add-tag-modal\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\nconst schema = z.object({\n  name: z\n    .string()\n    .trim()\n    .min(3)\n    .max(50)\n    .describe(\"The name of the tag to create.\"),\n  description: z\n    .string()\n    .trim()\n    .max(120)\n    .nullish()\n    .describe(\"The description of the tag to create.\"),\n  color: z.enum(tagColors, {\n    required_error: \"Please select a color for the tag\",\n  }),\n});\n\nconst defaultValue = {\n  name: \"\",\n  description: \"\",\n  color: randomBadgeColor(),\n  loading: false,\n};\nexport default function TagSetting() {\n  const [open, setOpen] = useState(false);\n  const teamInfo = useTeam();\n  const router = useRouter();\n  const [currentPage, setCurrentPage] = useState(1);\n  const [pageSize, setPageSize] = useState(10);\n  const queryParams = router.query;\n  const searchQuery = queryParams[\"search\"];\n  const teamId = teamInfo?.currentTeam?.id;\n  const [tagForm, setTagForm] = useState<{\n    color: TagColorProps;\n    name: string;\n    description: string | null;\n    loading: boolean;\n    id?: string;\n  }>(defaultValue);\n\n  useEffect(() => {\n    if (open && !tagForm.id) {\n      setTagForm((prev) => ({ ...prev, color: randomBadgeColor() }));\n    }\n  }, [open]);\n\n  const {\n    tagCount,\n    tags: availableTags,\n    loading: loadingTags,\n    isValidating,\n    mutate: mutateTags,\n  } = useTags({\n    query: {\n      sortBy: \"createdAt\",\n      sortOrder: \"desc\",\n      page: currentPage,\n      pageSize: pageSize,\n      ...(searchQuery ? { search: String(searchQuery) } : {}),\n    },\n    includeLinksCount: true,\n  });\n\n  const handleDeleteTag = async (tagId: string) => {\n    toast.promise(\n      fetch(`/api/teams/${teamId}/tags/${tagId}`, {\n        method: \"DELETE\",\n      }).then(() => {\n        mutateTags();\n      }),\n      {\n        loading: \"Deleting tag...\",\n        success: \"Tag deleted successfully!\",\n        error: \"Failed to delete Tag. Try again.\",\n      },\n    );\n  };\n\n  const handleSubmit = async (event: React.FormEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    const validation = schema.safeParse({\n      name: tagForm.name,\n      description: tagForm.description,\n      color: tagForm.color,\n    });\n\n    console.log(validation);\n\n    if (!validation.success) {\n      return toast.error(validation.error.errors[0].message);\n    }\n\n    setTagForm((prev) => ({\n      ...prev,\n      loading: true,\n    }));\n\n    const url = tagForm.id\n      ? `/api/teams/${teamId}/tags/${tagForm.id}`\n      : `/api/teams/${teamId}/tags`;\n\n    const method = tagForm.id ? \"PUT\" : \"POST\";\n\n    const response = await fetch(url, {\n      method,\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        name: tagForm.name,\n        color: tagForm.color,\n        description: tagForm.description,\n      }),\n    });\n\n    if (!response.ok) {\n      const { error } = await response.json();\n      toast.error(error);\n      setTagForm((prev) => ({\n        ...prev,\n        name: \"\",\n        loading: false,\n      }));\n      return;\n    }\n\n    mutateTags();\n\n    setOpen(false);\n    toast.success(\n      tagForm.id ? \"Tag updated successfully!\" : \"Tag created successfully!\",\n    );\n  };\n\n  const setMenuOpen = (open: boolean) => {\n    setOpen(open);\n    setTagForm(defaultValue);\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"mx-2 mb-10 mt-4 space-y-8 px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n        <div>\n          <div className=\"space-y-1\">\n            <h3 className=\"text-2xl font-semibold\">Tags</h3>\n\n            <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n              Manage and categorize your tags here.\n            </p>\n          </div>\n          <div className=\"my-4 flex items-center justify-between\">\n            <SearchBoxPersisted loading={isValidating} inputClassName=\"h-10\" />\n            <AddTagsModal\n              open={open}\n              setMenuOpen={setMenuOpen}\n              tagForm={tagForm}\n              setTagForm={setTagForm}\n              handleSubmit={handleSubmit}\n              tagCount={tagCount}\n            >\n              <Button>Create Tag</Button>\n            </AddTagsModal>\n          </div>\n          <div className=\"rounded-md border\">\n            <Table>\n              <TableHeader>\n                <TableRow className=\"*:whitespace-nowrap *:font-medium hover:bg-transparent\">\n                  <TableHead>Tag Name</TableHead>\n                  <TableHead></TableHead>\n                  <TableHead className=\"text-center sm:text-right\">\n                    Action\n                  </TableHead>\n                </TableRow>\n              </TableHeader>\n              <TableBody>\n                {tagCount === 0 && !loadingTags && (\n                  <TableRow>\n                    <TableCell colSpan={5}>\n                      <div className=\"flex h-40 w-full items-center justify-center\">\n                        <p>No Tags Available</p>\n                      </div>\n                    </TableCell>\n                  </TableRow>\n                )}\n                {availableTags && !loadingTags ? (\n                  availableTags.map((tag) => {\n                    return (\n                      <Fragment key={tag.id}>\n                        <TableRow className=\"group/row\">\n                          {/* Name */}\n                          <TableCell>\n                            <div className=\"flex items-center overflow-visible sm:space-x-3\">\n                              <div className=\"min-w-0 flex-1\">\n                                <div className=\"flex items-center gap-2\">\n                                  <TagIcon\n                                    size={24}\n                                    className={`rounded-sm border p-1 ${COLORS_LIST.find((c) => c.color === tag.color)?.css ?? \"\"}`}\n                                  />\n                                  <p>{tag.name}</p>\n                                  {!!tag.description && (\n                                    <BadgeTooltip\n                                      content={tag.description}\n                                      key=\"tag_tooltip\"\n                                    >\n                                      <InfoIcon className=\"h-4 w-4 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground\" />\n                                    </BadgeTooltip>\n                                  )}\n                                </div>\n                              </div>\n                            </div>\n                          </TableCell>\n                          {/* Link Count */}\n                          <TableCell className=\"text-center text-sm text-muted-foreground sm:text-right\">\n                            <Button variant=\"outline\" size=\"sm\">\n                              {tag._count?.items || 0} links\n                            </Button>\n                          </TableCell>\n\n                          {/* Actions */}\n                          <TableCell className=\"text-center sm:text-right\">\n                            <DropdownMenu>\n                              <DropdownMenuTrigger asChild>\n                                <Button\n                                  variant=\"ghost\"\n                                  className=\"h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700\"\n                                >\n                                  <span className=\"sr-only\">Open menu</span>\n                                  <MoreHorizontalIcon className=\"h-4 w-4\" />\n                                </Button>\n                              </DropdownMenuTrigger>\n                              <DropdownMenuContent align=\"end\">\n                                <DropdownMenuLabel>Actions</DropdownMenuLabel>\n\n                                <DropdownMenuSeparator />\n                                <DropdownMenuItem\n                                  onClick={(e) => {\n                                    e.stopPropagation();\n                                    setTagForm({\n                                      color: tag.color as TagColorProps,\n                                      name: tag.name,\n                                      description: tag.description,\n                                      id: tag.id,\n                                      loading: false,\n                                    });\n                                    setOpen(true);\n                                  }}\n                                >\n                                  <Settings2Icon className=\"mr-2 h-4 w-4\" />\n                                  Edit tag\n                                </DropdownMenuItem>\n                                <DropdownMenuItem\n                                  className=\"text-destructive transition-colors duration-200 focus:bg-destructive focus:text-destructive-foreground\"\n                                  onClick={(e) => {\n                                    e.stopPropagation();\n                                    handleDeleteTag(tag.id);\n                                  }}\n                                >\n                                  <TrashIcon className=\"mr-2 h-4 w-4\" />\n                                  Delete tag\n                                </DropdownMenuItem>\n                              </DropdownMenuContent>\n                            </DropdownMenu>\n                          </TableCell>\n                        </TableRow>\n                      </Fragment>\n                    );\n                  })\n                ) : (\n                  <TableRow>\n                    <TableCell className=\"min-w-[100px]\">\n                      <Skeleton className=\"h-6 w-full\" />\n                    </TableCell>\n                    <TableCell className=\"min-w-[450px]\">\n                      <Skeleton className=\"h-6 w-full\" />\n                    </TableCell>\n                    <TableCell>\n                      <Skeleton className=\"h-6 w-24\" />\n                    </TableCell>\n                    <TableCell>\n                      <Skeleton className=\"h-6 w-24\" />\n                    </TableCell>\n                  </TableRow>\n                )}\n              </TableBody>\n            </Table>\n          </div>\n          {/* Pagination Controls */}\n          {tagCount !== undefined && (\n            <Pagination\n              currentPage={currentPage}\n              pageSize={pageSize}\n              totalItems={tagCount}\n              totalShownItems={availableTags?.length || 0}\n              totalPages={Math.ceil(tagCount / pageSize)}\n              onPageChange={setCurrentPage}\n              onPageSizeChange={(size: number) => {\n                setPageSize(size);\n                setCurrentPage(1);\n              }}\n              itemName=\"tags\"\n            />\n          )}\n        </div>\n      </main>\n    </AppLayout>\n  );\n}"
  },
  {
    "path": "pages/settings/tokens.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { format } from \"date-fns\";\nimport { CircleHelpIcon, CopyIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\n\nimport { copyToClipboard, fetcher } from \"@/lib/utils\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\ninterface Token {\n  id: string;\n  name: string;\n  partialKey: string;\n  createdAt: string;\n  user: {\n    name: string;\n    email: string;\n  };\n}\n\nexport default function TokenSettings() {\n  const teamInfo = useTeam();\n  const teamId = teamInfo?.currentTeam?.id;\n  const router = useRouter();\n  const [name, setName] = useState(\"\");\n  const [token, setToken] = useState<string | null>(null);\n  const [isLoading, setIsLoading] = useState(false);\n\n  // Replace the useEffect with useSWR for feature flags\n  const { data: features } = useSWR<{ tokens: boolean }>(\n    teamId ? `/api/feature-flags?teamId=${teamId}` : null,\n    fetcher,\n  );\n\n  // Redirect if feature is not enabled\n  useEffect(() => {\n    if (features && !features.tokens) {\n      router.push(\"/settings/general\");\n      toast.error(\"This feature is not available for your team\");\n    }\n  }, [features, router]);\n\n  const { data: tokens, mutate } = useSWR<Token[]>(\n    teamId ? `/api/teams/${teamId}/tokens` : null,\n    fetcher,\n  );\n\n  const generateToken = async () => {\n    try {\n      setIsLoading(true);\n      const response = await fetch(`/api/teams/${teamId}/tokens`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          name,\n        }),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error);\n      }\n\n      const data = await response.json();\n      setToken(data.token);\n      toast.success(\"API token generated successfully\");\n\n      // After successful token generation, refresh the tokens list\n      mutate();\n    } catch (error) {\n      console.error(error);\n      toast.error((error as Error).message || \"Failed to generate token\");\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  const deleteToken = async (tokenId: string) => {\n    try {\n      const response = await fetch(`/api/teams/${teamId}/tokens`, {\n        method: \"DELETE\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({ tokenId }),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.error);\n      }\n\n      // Refresh the tokens list\n      mutate();\n      toast.success(\"Token revoked successfully\");\n    } catch (error) {\n      console.error(error);\n      toast.error((error as Error).message || \"Failed to revoke token\");\n    }\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <div className=\"rounded-lg border border-gray-200 bg-white\">\n          <div className=\"flex flex-col items-center justify-between gap-4 space-y-3 border-b border-gray-200 p-5 sm:flex-row sm:space-y-0 sm:p-10\">\n            <div className=\"flex max-w-screen-sm flex-col space-y-3\">\n              <div className=\"flex items-center gap-2\">\n                <h2 className=\"text-xl font-medium text-gray-900\">\n                  API Tokens\n                </h2>\n                <BadgeTooltip content=\"Use these tokens to authenticate your API requests\">\n                  <CircleHelpIcon className=\"h-4 w-4 text-gray-500\" />\n                </BadgeTooltip>\n              </div>\n              <p className=\"text-sm text-gray-500\">\n                Create API tokens to integrate Papermark with your applications.\n                Keep your tokens secure and never share them publicly.\n              </p>\n            </div>\n          </div>\n\n          <div className=\"p-5 sm:p-10\">\n            <div className=\"flex flex-col space-y-4\">\n              <div>\n                <Label htmlFor=\"token-name\" className=\"text-gray-900\">\n                  Token Name\n                </Label>\n                <Input\n                  id=\"token-name\"\n                  placeholder=\"Enter a name for your token\"\n                  value={name}\n                  onChange={(e) => setName(e.target.value)}\n                  className=\"text-gray-900 dark:bg-white\"\n                />\n              </div>\n\n              {token && (\n                <div className=\"rounded-lg bg-gray-50 p-4 text-gray-900\">\n                  <div className=\"flex items-center gap-2\">\n                    <Label>\n                      Your API Token (copy it now, it won&apos;t be shown again)\n                    </Label>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      className=\"h-6 w-6\"\n                      onClick={() =>\n                        copyToClipboard(`${token}`, \"Token copied to clipboard\")\n                      }\n                    >\n                      <CopyIcon />\n                    </Button>\n                  </div>\n                  <code className=\"mt-2 block break-all rounded bg-gray-100 p-2 font-mono text-sm\">\n                    {token}\n                  </code>\n                </div>\n              )}\n\n              <Button\n                onClick={generateToken}\n                disabled={!name || isLoading}\n                className=\"w-fit bg-gray-900 text-gray-50 hover:bg-gray-900/90\"\n              >\n                {isLoading ? \"Generating...\" : \"Generate Token\"}\n              </Button>\n\n              {/* Tokens List */}\n              <div className=\"mt-8\">\n                <h3 className=\"mb-4 text-lg font-medium text-gray-900\">\n                  Existing Tokens\n                </h3>\n                <div className=\"rounded-lg border border-gray-200\">\n                  {tokens?.length === 0 ? (\n                    <div className=\"p-4 text-center text-sm text-gray-500\">\n                      No tokens generated yet\n                    </div>\n                  ) : (\n                    <div className=\"divide-y divide-gray-200\">\n                      {tokens?.map((token) => (\n                        <div\n                          key={token.id}\n                          className=\"flex items-center justify-between p-4\"\n                        >\n                          <div className=\"space-y-1\">\n                            <p className=\"font-medium text-gray-900\">\n                              {token.name}\n                            </p>\n                            <div className=\"flex items-center space-x-2 text-sm text-gray-500\">\n                              <span className=\"font-mono\">\n                                {token.partialKey}\n                              </span>\n                              <span>•</span>\n                              <span>Created by {token.user.name}</span>\n                              <span>•</span>\n                              <span>\n                                {format(\n                                  new Date(token.createdAt),\n                                  \"MMM d, yyyy\",\n                                )}\n                              </span>\n                            </div>\n                          </div>\n                          <Button\n                            variant=\"destructive\"\n                            size=\"sm\"\n                            onClick={() => deleteToken(token.id)}\n                          >\n                            Revoke\n                          </Button>\n                        </div>\n                      ))}\n                    </div>\n                  )}\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/upgrade-holiday-offer.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\nimport React from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { getStripe } from \"@/ee/stripe/client\";\nimport { Feature, PlanEnum, getPlanFeatures } from \"@/ee/stripe/constants\";\nimport { PLANS } from \"@/ee/stripe/utils\";\nimport { CheckIcon, Users2Icon, XIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { capitalize } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\n// Calculate 30% discounted yearly price\nconst getDiscountedYearlyPrice = (yearlyPrice: number) => {\n  return Math.round(yearlyPrice * 0.7); // Round to whole number\n};\n\n// Calculate savings: monthly*12 vs yearly*12*0.7\nconst calculateSavings = (monthlyPrice: number, yearlyPrice: number) => {\n  const monthlyForYear = monthlyPrice * 12;\n  const yearlyWithDiscount = Math.round(yearlyPrice * 12 * 0.7);\n  return monthlyForYear - yearlyWithDiscount;\n};\n\n// Feature rendering component\nconst FeatureItem = ({\n  feature,\n  period,\n}: {\n  feature: Feature;\n  period: \"monthly\" | \"yearly\";\n}) => {\n  const baseClasses = `flex items-center ${feature.isHighlighted ? \"bg-orange-50 -mx-6 px-6 py-2 rounded-md dark:bg-orange-900/20\" : \"\"}`;\n\n  if (feature.isUsers) {\n    return (\n      <div className={`justify-between gap-x-8 ${baseClasses}`}>\n        <div className=\"flex items-center gap-x-3\">\n          {feature.isNotIncluded ? (\n            <XIcon className=\"h-6 w-5 flex-none text-gray-500\" />\n          ) : (\n            <CheckIcon className=\"h-6 w-5 flex-none text-[#fb7a00]\" />\n          )}\n          <span>{feature.text}</span>\n        </div>\n        {feature.tooltip && (\n          <TooltipProvider>\n            <Tooltip delayDuration={0}>\n              <TooltipTrigger asChild>\n                <div className=\"cursor-help\">\n                  <Users2Icon className=\"h-4 w-4 text-gray-500\" />\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{feature.tooltip}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        )}\n      </div>\n    );\n  }\n\n  if (feature.isCustomDomain) {\n    return (\n      <span className={`gap-x-3 ${baseClasses}`}>\n        {feature.isNotIncluded ? (\n          <XIcon className=\"h-6 w-5 flex-none text-gray-500\" />\n        ) : (\n          <CheckIcon className=\"h-6 w-5 flex-none text-[#fb7a00]\" />\n        )}\n        <span>{feature.text}</span>\n      </span>\n    );\n  }\n\n  return (\n    <div className={`gap-x-3 ${baseClasses}`}>\n      {feature.isNotIncluded ? (\n        <XIcon className=\"h-6 w-5 flex-none text-gray-500\" />\n      ) : (\n        <CheckIcon className=\"h-6 w-5 flex-none text-[#fb7a00]\" />\n      )}\n      <span>{feature.text}</span>\n    </div>\n  );\n};\n\n// Toggle component for Document Sharing vs Data Rooms\nconst PlanTypeSelector = ({\n  value,\n  onChange,\n}: {\n  value: \"documents\" | \"datarooms\" | \"business-datarooms\";\n  onChange: (value: \"documents\" | \"datarooms\") => void;\n}) => {\n  const isDocuments = value === \"documents\";\n  const isDatarooms = value === \"datarooms\";\n\n  return (\n    <div className=\"mb-8 flex w-full max-w-md items-center justify-center rounded-lg border border-gray-200 p-1\">\n      <button\n        className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${\n          isDocuments\n            ? \"bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\"\n        }`}\n        onClick={() => onChange(\"documents\")}\n      >\n        Document Sharing\n      </button>\n      <button\n        className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${\n          isDatarooms\n            ? \"bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\"\n        }`}\n        onClick={() => onChange(\"datarooms\")}\n      >\n        Data Rooms\n      </button>\n    </div>\n  );\n};\n\nexport default function UpgradeHolidayOfferPage() {\n  const router = useRouter();\n  const [period, setPeriod] = useState<\"yearly\" | \"monthly\">(\"yearly\");\n  const [selectedPlan, setSelectedPlan] = useState<string | null>(null);\n  const teamInfo = useTeam();\n  const { plan: teamPlan, trial, isCustomer, isOldAccount } = usePlan();\n  const analytics = useAnalytics();\n\n  // Determine initial view based on query params or default to datarooms\n  const getInitialView = () => {\n    if (router.query.view === \"documents\") return \"documents\";\n    if (router.query.view === \"business-datarooms\") return \"business-datarooms\";\n    return \"datarooms\";\n  };\n  const [planType, setPlanType] = useState<\n    \"documents\" | \"datarooms\" | \"business-datarooms\"\n  >(getInitialView());\n\n  // Update planType when query param changes\n  useEffect(() => {\n    if (router.query.view === \"documents\") {\n      setPlanType(\"documents\");\n    } else if (router.query.view === \"business-datarooms\") {\n      setPlanType(\"business-datarooms\");\n    } else if (router.query.view === \"datarooms\" || !router.query.view) {\n      setPlanType(\"datarooms\");\n    }\n  }, [router.query.view]);\n\n  // Document sharing plans (first 3)\n  const documentSharingPlans = [\n    PlanEnum.Pro,\n    PlanEnum.Business,\n    PlanEnum.DataRooms,\n  ];\n\n  return (\n    <div className=\"min-h-screen bg-gray-50 p-8 dark:bg-gray-900\">\n      <h1 className=\"mb-8 text-center text-3xl font-bold\">\n        Select best plan for your business.\n      </h1>\n      <p className=\"mb-8 text-center text-sm text-gray-500 dark:text-gray-400\">\n        Additional 30% off on annual plans. New Year&apos;s offer. Ends Jan 15 2026\n      </p>\n\n      <div className=\"mb-8 flex items-center justify-center\">\n        <span className=\"mr-2 text-sm\">\n          Monthly{\" \"}\n          {period === \"monthly\" && <span className=\"text-[#fb7a00]\"></span>}\n        </span>\n        <Switch\n          checked={period === \"yearly\"}\n          onCheckedChange={() =>\n            setPeriod(period === \"monthly\" ? \"yearly\" : \"monthly\")\n          }\n        />\n        <span className=\"ml-2 text-sm\">\n          Annually <span className=\"text-[#fb7a00]\">(Save 50%)</span>\n        </span>\n      </div>\n\n      {/* Plan Type Selector */}\n      <div className=\"mb-8 flex justify-center\">\n        <PlanTypeSelector\n          value={planType}\n          onChange={(value) => setPlanType(value)}\n        />\n      </div>\n\n      {/* Document Sharing Plans (3 in a row) */}\n      {planType === \"documents\" && (\n        <div className=\"mb-8 grid grid-cols-1 gap-2 md:grid-cols-3\">\n          {documentSharingPlans.map((planOption) => {\n            const planFeatures = getPlanFeatures(planOption, { period });\n            const planData = PLANS.find((p) => p.name === planOption);\n            if (!planData) return null;\n\n            const monthlyPrice = planData.price.monthly.amount;\n            const yearlyPrice = planData.price.yearly.amount;\n            const discountedYearlyPrice =\n              period === \"yearly\"\n                ? getDiscountedYearlyPrice(yearlyPrice)\n                : yearlyPrice;\n            const savings =\n              period === \"yearly\"\n                ? calculateSavings(monthlyPrice, yearlyPrice)\n                : 0;\n\n            return (\n              <div\n                key={planOption}\n                className={`relative flex flex-col rounded-lg border ${\n                  planOption === PlanEnum.Business ||\n                  planOption === PlanEnum.DataRoomsPlus\n                    ? \"border-[#fb7a00]\"\n                    : planOption === PlanEnum.DataRoomsPremium\n                      ? \"border-gray-900 dark:border-gray-200\"\n                      : \"border-gray-400\"\n                } bg-white p-6 shadow-sm dark:bg-gray-900`}\n              >\n                <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                  <h3 className=\"text-balance text-xl font-medium text-foreground text-gray-900 dark:text-white\">\n                    {planOption}\n                  </h3>\n                  {period === \"yearly\" && savings > 0 && (\n                    <span className=\"absolute right-2 top-2 rounded bg-[#fb7a00]/10 px-2 py-1 text-xs font-medium text-[#fb7a00]\">\n                      Save €{savings}/year\n                    </span>\n                  )}\n                  {period === \"monthly\" &&\n                    (planOption === PlanEnum.Business ||\n                      planOption === PlanEnum.DataRoomsPlus) && (\n                      <span\n                        className={`absolute -top-3 right-4 rounded px-2 py-1 text-xs text-white ${\n                          planOption === PlanEnum.DataRoomsPlus\n                            ? \"bg-gray-800 dark:bg-gray-200 dark:text-black\"\n                            : \"bg-[#fb7a00]\"\n                        }`}\n                      >\n                        {planOption === PlanEnum.DataRoomsPlus\n                          ? \"Best offer\"\n                          : \"Most popular\"}\n                      </span>\n                    )}\n                </div>\n\n                <div className=\"mb-2 text-balance text-4xl font-medium tabular-nums text-foreground\">\n                  €{period === \"yearly\" ? discountedYearlyPrice : monthlyPrice}\n                  <span className=\"text-base font-normal dark:text-white/75\">\n                    /month{period === \"yearly\" && \", billed annually\"}\n                  </span>\n                </div>\n                {period === \"yearly\" && (\n                  <>\n                    <div className=\"mb-2 flex items-center gap-2\">\n                      <span className=\"text-sm text-gray-500 line-through\">\n                        €{yearlyPrice}/month\n                      </span>\n                      <span className=\"rounded bg-[#fb7a00]/10 px-2 py-0.5 text-xs font-medium text-[#fb7a00]\">\n                        30% OFF\n                      </span>\n                    </div>\n                  </>\n                )}\n                <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                  {planFeatures.featureIntro}\n                </p>\n\n                <ul\n                  role=\"list\"\n                  className=\"mb-4 mt-4 space-y-3 text-sm leading-6 text-gray-600\"\n                >\n                  {planFeatures.features.map((feature, i) => (\n                    <li key={i}>\n                      <FeatureItem feature={feature} period={period} />\n                    </li>\n                  ))}\n                </ul>\n                <div className=\"mt-auto\">\n                  <Button\n                    variant={\n                      planOption === PlanEnum.Business ? \"default\" : \"default\"\n                    }\n                    className={`w-full py-2 text-sm ${\n                      planOption === PlanEnum.Business\n                        ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                        : planOption === PlanEnum.DataRoomsPremium\n                          ? \"bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200\"\n                          : \"bg-gray-800 text-white hover:bg-gray-900 dark:hover:bg-gray-700/80\"\n                    }`}\n                    loading={selectedPlan === planOption}\n                    disabled={selectedPlan !== null}\n                    onClick={() => {\n                      setSelectedPlan(planOption);\n                      const envKey =\n                        process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n                          ? \"production\"\n                          : \"test\";\n                      const plan = PLANS.find((p) => p.name === planOption);\n                      if (!plan) return;\n                      const priceId =\n                        plan.price[period].priceIds[envKey][\n                          isOldAccount ? \"old\" : \"new\"\n                        ];\n\n                      if (isCustomer && teamPlan !== \"free\") {\n                        fetch(\n                          `/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                            body: JSON.stringify({\n                              priceId,\n                              upgradePlan: true,\n                              applyYearlyDiscount: period === \"yearly\",\n                            }),\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const url = await res.json();\n                            router.push(url);\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      } else {\n                        fetch(\n                          `/api/teams/${\n                            teamInfo?.currentTeam?.id\n                          }/billing/upgrade?priceId=${priceId}&applyYearlyDiscount=${period === \"yearly\"}`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const data = await res.json();\n                            const { id: sessionId } = data;\n                            const stripe = await getStripe(isOldAccount);\n                            stripe?.redirectToCheckout({ sessionId });\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      }\n                    }}\n                  >\n                    {selectedPlan === planOption\n                      ? \"Redirecting to Stripe...\"\n                      : `Upgrade to ${planOption} ${capitalize(period)}`}\n                  </Button>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      )}\n\n      {/* Business + Data Rooms Plans (4 in a row) */}\n      {planType === \"business-datarooms\" && (\n        <div className=\"grid grid-cols-1 gap-2 md:grid-cols-4\">\n          {[\n            PlanEnum.Business,\n            PlanEnum.DataRooms,\n            PlanEnum.DataRoomsPlus,\n            PlanEnum.DataRoomsPremium,\n          ].map((planOption) => {\n            const planFeatures = getPlanFeatures(planOption, { period });\n            const planData = PLANS.find((p) => p.name === planOption);\n            if (!planData) return null;\n\n            const monthlyPrice = planData.price.monthly.amount;\n            const yearlyPrice = planData.price.yearly.amount;\n            const discountedYearlyPrice =\n              period === \"yearly\"\n                ? getDiscountedYearlyPrice(yearlyPrice)\n                : yearlyPrice;\n            const savings =\n              period === \"yearly\"\n                ? calculateSavings(monthlyPrice, yearlyPrice)\n                : 0;\n\n            return (\n              <div\n                key={planOption}\n                className={`relative flex flex-col rounded-lg border ${\n                  planOption === PlanEnum.Business\n                    ? \"border-[#fb7a00]\"\n                    : planOption === PlanEnum.DataRoomsPremium\n                      ? \"border-gray-900 dark:border-gray-200\"\n                      : planOption === PlanEnum.DataRoomsPlus\n                        ? \"border-gray-800 dark:border-gray-300\"\n                        : \"border-gray-400\"\n                } bg-white p-6 shadow-sm dark:bg-gray-900`}\n              >\n                <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                  <h3 className=\"text-balance text-xl font-medium text-foreground text-gray-900 dark:text-white\">\n                    {planOption}\n                  </h3>\n                  {period === \"monthly\" &&\n                    (planOption === PlanEnum.Business ||\n                      planOption === PlanEnum.DataRoomsPlus) && (\n                      <span className=\"absolute -top-3 right-4 rounded bg-[#fb7a00] px-2 py-1 text-xs text-white\">\n                        {planOption === PlanEnum.Business\n                          ? \"Most popular\"\n                          : \"Best offer\"}\n                      </span>\n                    )}\n                  {period === \"yearly\" && savings > 0 && (\n                    <span className=\"absolute right-2 top-2 rounded bg-[#fb7a00]/10 px-2 py-1 text-xs font-medium text-[#fb7a00]\">\n                      Save €{savings}/year\n                    </span>\n                  )}\n                </div>\n\n                <div className=\"mb-2 text-balance text-4xl font-medium tabular-nums text-foreground\">\n                  €{period === \"yearly\" ? discountedYearlyPrice : monthlyPrice}\n                  <span className=\"text-base font-normal dark:text-white/75\">\n                    /month{period === \"yearly\" && \", billed annually\"}\n                  </span>\n                </div>\n                {period === \"yearly\" && (\n                  <>\n                    <div className=\"mb-2 flex items-center gap-2\">\n                      <span className=\"text-sm text-gray-500 line-through\">\n                        €{yearlyPrice}/month\n                      </span>\n                      <span className=\"rounded bg-[#fb7a00]/10 px-2 py-0.5 text-xs font-medium text-[#fb7a00]\">\n                        30% OFF\n                      </span>\n                    </div>\n                  </>\n                )}\n                <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                  {planFeatures.featureIntro}\n                </p>\n\n                <ul\n                  role=\"list\"\n                  className=\"mb-4 mt-4 space-y-3 text-sm leading-6 text-gray-600\"\n                >\n                  {planFeatures.features.map((feature, i) => (\n                    <li key={i}>\n                      <FeatureItem feature={feature} period={period} />\n                    </li>\n                  ))}\n                </ul>\n                <div className=\"mt-auto\">\n                  <Button\n                    variant={\n                      planOption === PlanEnum.Business ? \"default\" : \"default\"\n                    }\n                    className={`w-full py-2 text-sm ${\n                      planOption === PlanEnum.Business\n                        ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                        : planOption === PlanEnum.DataRoomsPremium\n                          ? \"bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200\"\n                          : \"bg-gray-800 text-white hover:bg-gray-900 dark:hover:bg-gray-700/80\"\n                    }`}\n                    loading={selectedPlan === planOption}\n                    disabled={selectedPlan !== null}\n                    onClick={() => {\n                      setSelectedPlan(planOption);\n                      const envKey =\n                        process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n                          ? \"production\"\n                          : \"test\";\n                      const plan = PLANS.find((p) => p.name === planOption);\n                      if (!plan) return;\n                      const priceId =\n                        plan.price[period].priceIds[envKey][\n                          isOldAccount ? \"old\" : \"new\"\n                        ];\n\n                      if (isCustomer && teamPlan !== \"free\") {\n                        fetch(\n                          `/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                            body: JSON.stringify({\n                              priceId,\n                              upgradePlan: true,\n                              applyYearlyDiscount: period === \"yearly\",\n                            }),\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const url = await res.json();\n                            router.push(url);\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      } else {\n                        fetch(\n                          `/api/teams/${\n                            teamInfo?.currentTeam?.id\n                          }/billing/upgrade?priceId=${priceId}&applyYearlyDiscount=${period === \"yearly\"}`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const data = await res.json();\n                            const { id: sessionId } = data;\n                            const stripe = await getStripe(isOldAccount);\n                            stripe?.redirectToCheckout({ sessionId });\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      }\n                    }}\n                  >\n                    {selectedPlan === planOption\n                      ? \"Redirecting to Stripe...\"\n                      : `Upgrade to ${planOption} ${capitalize(period)}`}\n                  </Button>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      )}\n\n      {/* Data Rooms Plans (3 in a row) */}\n      {planType === \"datarooms\" && (\n        <div className=\"grid grid-cols-1 gap-2 md:grid-cols-3\">\n          {[\n            PlanEnum.DataRooms,\n            PlanEnum.DataRoomsPlus,\n            PlanEnum.DataRoomsPremium,\n          ].map((planOption) => {\n            const planFeatures = getPlanFeatures(planOption, { period });\n            const planData = PLANS.find((p) => p.name === planOption);\n            if (!planData) return null;\n\n            const monthlyPrice = planData.price.monthly.amount;\n            const yearlyPrice = planData.price.yearly.amount;\n            const discountedYearlyPrice =\n              period === \"yearly\"\n                ? getDiscountedYearlyPrice(yearlyPrice)\n                : yearlyPrice;\n            const savings =\n              period === \"yearly\"\n                ? calculateSavings(monthlyPrice, yearlyPrice)\n                : 0;\n\n            return (\n              <div\n                key={planOption}\n                className={`relative flex flex-col rounded-lg border ${\n                  planOption === PlanEnum.Business\n                    ? \"border-[#fb7a00]\"\n                    : planOption === PlanEnum.DataRoomsPremium\n                      ? \"border-gray-900 dark:border-gray-200\"\n                      : planOption === PlanEnum.DataRoomsPlus\n                        ? \"border-gray-800 dark:border-gray-300\"\n                        : \"border-gray-400\"\n                } bg-white p-6 shadow-sm dark:bg-gray-900`}\n              >\n                <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                  <h3 className=\"text-balance text-xl font-medium text-foreground text-gray-900 dark:text-white\">\n                    {planOption}\n                  </h3>\n                  {period === \"yearly\" && savings > 0 && (\n                    <span className=\"absolute right-2 top-2 rounded bg-[#fb7a00]/10 px-2 py-1 text-xs font-medium text-[#fb7a00]\">\n                      Save €{savings}/year\n                    </span>\n                  )}\n                  {period === \"monthly\" &&\n                    (planOption === PlanEnum.Business ||\n                      planOption === PlanEnum.DataRoomsPlus) && (\n                      <span className=\"absolute -top-3 right-4 rounded bg-[#fb7a00] px-2 py-1 text-xs text-white\">\n                        {planOption === PlanEnum.Business\n                          ? \"Most popular\"\n                          : \"Best offer\"}\n                      </span>\n                    )}\n                </div>\n\n                <div className=\"mb-2 text-balance text-4xl font-medium tabular-nums text-foreground\">\n                  €{period === \"yearly\" ? discountedYearlyPrice : monthlyPrice}\n                  <span className=\"text-base font-normal dark:text-white/75\">\n                    /month{period === \"yearly\" && \", billed annually\"}\n                  </span>\n                </div>\n                {period === \"yearly\" && (\n                  <>\n                    <div className=\"mb-2 flex items-center gap-2\">\n                      <span className=\"text-sm text-gray-500 line-through\">\n                        €{yearlyPrice}/month\n                      </span>\n                      <span className=\"rounded bg-[#fb7a00]/10 px-2 py-0.5 text-xs font-medium text-[#fb7a00]\">\n                        30% OFF\n                      </span>\n                    </div>\n                  </>\n                )}\n                <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                  {planFeatures.featureIntro}\n                </p>\n\n                <ul\n                  role=\"list\"\n                  className=\"mb-4 mt-4 space-y-3 text-sm leading-6 text-gray-600\"\n                >\n                  {planFeatures.features.map((feature, i) => (\n                    <li key={i}>\n                      <FeatureItem feature={feature} period={period} />\n                    </li>\n                  ))}\n                </ul>\n                <div className=\"mt-auto\">\n                  <Button\n                    variant={\n                      planOption === PlanEnum.Business ? \"default\" : \"default\"\n                    }\n                    className={`w-full py-2 text-sm ${\n                      planOption === PlanEnum.Business ||\n                      planOption === PlanEnum.DataRoomsPlus\n                        ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                        : planOption === PlanEnum.DataRoomsPremium\n                          ? \"bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200\"\n                          : \"bg-gray-800 text-white hover:bg-gray-900 dark:hover:bg-gray-700/80\"\n                    }`}\n                    loading={selectedPlan === planOption}\n                    disabled={selectedPlan !== null}\n                    onClick={() => {\n                      setSelectedPlan(planOption);\n                      const envKey =\n                        process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n                          ? \"production\"\n                          : \"test\";\n                      const plan = PLANS.find((p) => p.name === planOption);\n                      if (!plan) return;\n                      const priceId =\n                        plan.price[period].priceIds[envKey][\n                          isOldAccount ? \"old\" : \"new\"\n                        ];\n\n                      if (isCustomer && teamPlan !== \"free\") {\n                        fetch(\n                          `/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                            body: JSON.stringify({\n                              priceId,\n                              upgradePlan: true,\n                              applyYearlyDiscount: period === \"yearly\",\n                            }),\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const url = await res.json();\n                            router.push(url);\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      } else {\n                        fetch(\n                          `/api/teams/${\n                            teamInfo?.currentTeam?.id\n                          }/billing/upgrade?priceId=${priceId}&applyYearlyDiscount=${period === \"yearly\"}`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const data = await res.json();\n                            const { id: sessionId } = data;\n                            const stripe = await getStripe(isOldAccount);\n                            stripe?.redirectToCheckout({ sessionId });\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      }\n                    }}\n                  >\n                    {selectedPlan === planOption\n                      ? \"Redirecting to Stripe...\"\n                      : `Upgrade to ${planOption} ${capitalize(period)}`}\n                  </Button>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      )}\n\n      <div className=\"mt-8 flex flex-col items-center space-y-2\">\n        <p className=\"text-sm text-muted-foreground\">\n          All plans include unlimited viewers and page by page document\n          analytics.\n        </p>\n        <a\n          href=\"https://cal.com/marcseitz/papermark\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline\"\n        >\n          Looking for Papermark Enterprise?\n        </a>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "pages/settings/upgrade.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport React from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { getStripe } from \"@/ee/stripe/client\";\nimport { Feature, PlanEnum, getPlanFeatures } from \"@/ee/stripe/constants\";\nimport { PLANS } from \"@/ee/stripe/utils\";\nimport { CheckIcon, Users2Icon, XIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { capitalize } from \"@/lib/utils\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n\n// Feature rendering component\nconst FeatureItem = ({\n  feature,\n  period,\n}: {\n  feature: Feature;\n  period: \"monthly\" | \"yearly\";\n}) => {\n  const baseClasses = `flex items-center ${feature.isHighlighted ? \"bg-orange-50 -mx-6 px-6 py-2 rounded-md dark:bg-orange-900/20\" : \"\"}`;\n\n  if (feature.isUsers) {\n    return (\n      <div className={`justify-between gap-x-8 ${baseClasses}`}>\n        <div className=\"flex items-center gap-x-3\">\n          {feature.isNotIncluded ? (\n            <XIcon className=\"h-6 w-5 flex-none text-gray-500\" />\n          ) : (\n            <CheckIcon className=\"h-6 w-5 flex-none text-[#fb7a00]\" />\n          )}\n          <span>{feature.text}</span>\n        </div>\n        {feature.tooltip && (\n          <TooltipProvider>\n            <Tooltip delayDuration={0}>\n              <TooltipTrigger asChild>\n                <div className=\"cursor-help\">\n                  <Users2Icon className=\"h-4 w-4 text-gray-500\" />\n                </div>\n              </TooltipTrigger>\n              <TooltipContent>\n                <p>{feature.tooltip}</p>\n              </TooltipContent>\n            </Tooltip>\n          </TooltipProvider>\n        )}\n      </div>\n    );\n  }\n\n  if (feature.isCustomDomain) {\n    return (\n      <span className={`gap-x-3 ${baseClasses}`}>\n        {feature.isNotIncluded ? (\n          <XIcon className=\"h-6 w-5 flex-none text-gray-500\" />\n        ) : (\n          <CheckIcon className=\"h-6 w-5 flex-none text-[#fb7a00]\" />\n        )}\n        <span>{feature.text}</span>\n      </span>\n    );\n  }\n\n  return (\n    <div className={`gap-x-3 ${baseClasses}`}>\n      {feature.isNotIncluded ? (\n        <XIcon className=\"h-6 w-5 flex-none text-gray-500\" />\n      ) : (\n        <CheckIcon className=\"h-6 w-5 flex-none text-[#fb7a00]\" />\n      )}\n      <span>{feature.text}</span>\n    </div>\n  );\n};\n\n// Toggle component for Document Sharing vs Data Rooms\nconst PlanTypeSelector = ({\n  value,\n  onChange,\n  showBusinessDatarooms,\n}: {\n  value: \"documents\" | \"datarooms\" | \"business-datarooms\";\n  onChange: (value: \"documents\" | \"datarooms\") => void;\n  showBusinessDatarooms?: boolean;\n}) => {\n  const isDocuments = value === \"documents\";\n  const isDatarooms = value === \"datarooms\";\n  const isBusinessDatarooms = value === \"business-datarooms\";\n\n  return (\n    <div className=\"mb-8 flex w-full max-w-md items-center justify-center rounded-lg border border-gray-200 p-1\">\n      <button\n        className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${\n          isDocuments\n            ? \"bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\"\n        }`}\n        onClick={() => onChange(\"documents\")}\n      >\n        Document Sharing\n      </button>\n      <button\n        className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${\n          isDatarooms\n            ? \"bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900\"\n            : \"text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white\"\n        }`}\n        onClick={() => onChange(\"datarooms\")}\n      >\n        Data Rooms\n      </button>\n    </div>\n  );\n};\n\n\nexport default function UpgradePage() {\n  const router = useRouter();\n  const [period, setPeriod] = useState<\"yearly\" | \"monthly\">(\"yearly\");\n  const [selectedPlan, setSelectedPlan] = useState<string | null>(null);\n  const teamInfo = useTeam();\n  const { plan: teamPlan, trial, isCustomer, isOldAccount } = usePlan();\n  const analytics = useAnalytics();\n\n  // Determine initial view based on query params or default to datarooms\n  const getInitialView = () => {\n    if (router.query.view === \"documents\") return \"documents\";\n    if (router.query.view === \"business-datarooms\") return \"business-datarooms\";\n    return \"datarooms\";\n  };\n  const [planType, setPlanType] = useState<\"documents\" | \"datarooms\" | \"business-datarooms\">(getInitialView());\n\n  // Update planType when query param changes\n  useEffect(() => {\n    if (router.query.view === \"documents\") {\n      setPlanType(\"documents\");\n    } else if (router.query.view === \"business-datarooms\") {\n      setPlanType(\"business-datarooms\");\n    } else if (router.query.view === \"datarooms\" || !router.query.view) {\n      setPlanType(\"datarooms\");\n    }\n  }, [router.query.view]);\n\n  // Document sharing plans (first 3)\n  const documentSharingPlans = [\n    PlanEnum.Pro,\n    PlanEnum.Business,\n    PlanEnum.DataRooms,\n  ];\n\n  return (\n    <div className=\"min-h-screen bg-gray-50 p-8 dark:bg-gray-900\">\n      <h1 className=\"mb-8 text-center text-3xl font-bold\">\n        Select best plan for your business\n      </h1>\n\n      <div className=\"mb-8 flex items-center justify-center\">\n        <span className=\"mr-2 text-sm\">Monthly</span>\n        <Switch\n          checked={period === \"yearly\"}\n          onCheckedChange={() =>\n            setPeriod(period === \"monthly\" ? \"yearly\" : \"monthly\")\n          }\n        />\n        <span className=\"ml-2 text-sm\">\n          Annually <span className=\"text-[#fb7a00]\">(Save up to 35%)</span>\n        </span>\n      </div>\n\n      {/* Plan Type Selector */}\n      <div className=\"mb-8 flex justify-center\">\n        <PlanTypeSelector \n          value={planType} \n          onChange={(value) => setPlanType(value)} \n        />\n      </div>\n\n      {/* Document Sharing Plans (3 in a row) */}\n      {planType === \"documents\" && (\n        <div className=\"grid grid-cols-1 gap-2 md:grid-cols-3 mb-8\">\n          {documentSharingPlans.map((planOption) => {\n          const planFeatures = getPlanFeatures(planOption, { period });\n\n          return (\n            <div\n              key={planOption}\n              className={`relative flex flex-col rounded-lg border ${\n                planOption === PlanEnum.Business\n                  ? \"border-[#fb7a00]\"\n                  : planOption === PlanEnum.DataRoomsPremium\n                    ? \"border-gray-900 dark:border-gray-200\"\n                    : planOption === PlanEnum.DataRoomsPlus\n                      ? \"border-gray-800 dark:border-gray-300\"\n                      : \"border-gray-400\"\n              } bg-white p-6 shadow-sm dark:bg-gray-900`}\n            >\n              <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                <h3 className=\"text-balance text-xl font-medium text-foreground text-gray-900 dark:text-white\">\n                  {planOption}\n                </h3>\n                {(planOption === PlanEnum.Business ||\n                  planOption === PlanEnum.DataRoomsPlus) && (\n                  <span\n                    className={`absolute -top-3 right-4 rounded px-2 py-1 text-xs text-white ${\n                      planOption === PlanEnum.DataRoomsPlus\n                        ? \"bg-gray-800 dark:bg-gray-200 dark:text-black\"\n                        : \"bg-[#fb7a00]\"\n                    }`}\n                  >\n                    {planOption === PlanEnum.DataRoomsPlus\n                      ? \"Best offer\"\n                      : \"Most popular\"}\n                  </span>\n                )}\n              </div>\n\n              <div className=\"mb-2 text-balance text-4xl font-medium tabular-nums text-foreground\">\n                €\n                {PLANS.find((p) => p.name === planOption)!.price[period].amount}\n                <span className=\"text-base font-normal dark:text-white/75\">\n                  /month{period === \"yearly\" && \", billed annually\"}\n                </span>\n              </div>\n              <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                {planFeatures.featureIntro}\n              </p>\n\n              <ul\n                role=\"list\"\n                className=\"mb-4 mt-4 space-y-3 text-sm leading-6 text-gray-600\"\n              >\n                {planFeatures.features.map((feature, i) => (\n                  <li key={i}>\n                    <FeatureItem feature={feature} period={period} />\n                  </li>\n                ))}\n              </ul>\n              <div className=\"mt-auto\">\n                <Button\n                  variant={\n                    planOption === PlanEnum.Business ? \"default\" : \"default\"\n                  }\n                  className={`w-full py-2 text-sm ${\n                    planOption === PlanEnum.Business\n                      ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                      : planOption === PlanEnum.DataRoomsPremium\n                        ? \"bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200\"\n                        : \"bg-gray-800 text-white hover:bg-gray-900 dark:hover:bg-gray-700/80\"\n                  }`}\n                  loading={selectedPlan === planOption}\n                  disabled={selectedPlan !== null}\n                  onClick={() => {\n                    setSelectedPlan(planOption);\n                    if (isCustomer && teamPlan !== \"free\") {\n                      fetch(\n                        `/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`,\n                        {\n                          method: \"POST\",\n                        },\n                      )\n                        .then(async (res) => {\n                          if (res.status === 429) {\n                            toast.error(\n                              \"Rate limit exceeded. Please try again later.\",\n                            );\n                            setSelectedPlan(null);\n                            return;\n                          }\n\n                          const url = await res.json();\n                          router.push(url);\n                        })\n                        .catch((err) => {\n                          alert(err);\n                          setSelectedPlan(null);\n                        });\n                    } else {\n                      fetch(\n                        `/api/teams/${\n                          teamInfo?.currentTeam?.id\n                        }/billing/upgrade?priceId=${\n                          PLANS.find((p) => p.name === planOption)!.price[\n                            period\n                          ].priceIds[\n                            process.env.NEXT_PUBLIC_VERCEL_ENV === \"production\"\n                              ? \"production\"\n                              : \"test\"\n                          ][isOldAccount ? \"old\" : \"new\"]\n                        }`,\n                        {\n                          method: \"POST\",\n                          headers: {\n                            \"Content-Type\": \"application/json\",\n                          },\n                        },\n                      )\n                        .then(async (res) => {\n                          if (res.status === 429) {\n                            toast.error(\n                              \"Rate limit exceeded. Please try again later.\",\n                            );\n                            setSelectedPlan(null);\n                            return;\n                          }\n\n                          const data = await res.json();\n                          const { id: sessionId } = data;\n                          const stripe = await getStripe(isOldAccount);\n                          stripe?.redirectToCheckout({ sessionId });\n                        })\n                        .catch((err) => {\n                          alert(err);\n                          setSelectedPlan(null);\n                        });\n                    }\n                  }}\n                >\n                  {selectedPlan === planOption\n                    ? \"Redirecting to Stripe...\"\n                    : `Upgrade to ${planOption} ${capitalize(period)}`}\n                </Button>\n              </div>\n            </div>\n          );\n          })}\n        </div>\n      )}\n\n      {/* Business + Data Rooms Plans (4 in a row) */}\n      {planType === \"business-datarooms\" && (\n        <div className=\"grid grid-cols-1 gap-2 md:grid-cols-4\">\n          {[\n            PlanEnum.Business,\n            PlanEnum.DataRooms,\n            PlanEnum.DataRoomsPlus,\n            PlanEnum.DataRoomsPremium,\n          ].map((planOption) => {\n            const planFeatures = getPlanFeatures(planOption, { period });\n\n            return (\n              <div\n                key={planOption}\n                className={`relative flex flex-col rounded-lg border ${\n                  planOption === PlanEnum.Business\n                    ? \"border-[#fb7a00]\"\n                    : planOption === PlanEnum.DataRoomsPremium\n                      ? \"border-gray-900 dark:border-gray-200\"\n                      : planOption === PlanEnum.DataRoomsPlus\n                        ? \"border-gray-800 dark:border-gray-300\"\n                        : \"border-gray-400\"\n                } bg-white p-6 shadow-sm dark:bg-gray-900`}\n              >\n                <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                  <h3 className=\"text-balance text-xl font-medium text-foreground text-gray-900 dark:text-white\">\n                    {planOption}\n                  </h3>\n                  {(planOption === PlanEnum.Business ||\n                    planOption === PlanEnum.DataRoomsPlus) && (\n                    <span\n                      className={`absolute -top-3 right-4 rounded px-2 py-1 text-xs text-white ${\n                        planOption === PlanEnum.DataRoomsPlus\n                          ? \"bg-gray-800 dark:bg-gray-200 dark:text-black\"\n                          : \"bg-[#fb7a00]\"\n                      }`}\n                    >\n                      {planOption === PlanEnum.DataRoomsPlus\n                        ? \"Best offer\"\n                        : \"Most popular\"}\n                    </span>\n                  )}\n                </div>\n\n                <div className=\"mb-2 text-balance text-4xl font-medium tabular-nums text-foreground\">\n                  €\n                  {PLANS.find((p) => p.name === planOption)!.price[period]\n                    .amount}\n                  <span className=\"text-base font-normal dark:text-white/75\">\n                    /month{period === \"yearly\" && \", billed annually\"}\n                  </span>\n                </div>\n                <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                  {planFeatures.featureIntro}\n                </p>\n\n                <ul\n                  role=\"list\"\n                  className=\"mb-4 mt-4 space-y-3 text-sm leading-6 text-gray-600\"\n                >\n                  {planFeatures.features.map((feature, i) => (\n                    <li key={i}>\n                      <FeatureItem feature={feature} period={period} />\n                    </li>\n                  ))}\n                </ul>\n                <div className=\"mt-auto\">\n                  <Button\n                    variant={\n                      planOption === PlanEnum.Business ? \"default\" : \"default\"\n                    }\n                    className={`w-full py-2 text-sm ${\n                      planOption === PlanEnum.Business\n                        ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                        : planOption === PlanEnum.DataRoomsPremium\n                          ? \"bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200\"\n                          : \"bg-gray-800 text-white hover:bg-gray-900 dark:hover:bg-gray-700/80\"\n                    }`}\n                    loading={selectedPlan === planOption}\n                    disabled={selectedPlan !== null}\n                    onClick={() => {\n                      setSelectedPlan(planOption);\n                      if (isCustomer && teamPlan !== \"free\") {\n                        fetch(\n                          `/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`,\n                          {\n                            method: \"POST\",\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const url = await res.json();\n                            router.push(url);\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      } else {\n                        fetch(\n                          `/api/teams/${\n                            teamInfo?.currentTeam?.id\n                          }/billing/upgrade?priceId=${\n                            PLANS.find((p) => p.name === planOption)!.price[\n                              period\n                            ].priceIds[\n                              process.env.NEXT_PUBLIC_VERCEL_ENV ===\n                                \"production\"\n                                ? \"production\"\n                                : \"test\"\n                            ][isOldAccount ? \"old\" : \"new\"]\n                          }`,\n                          {\n                            method: \"POST\",\n                            headers: {\n                              \"Content-Type\": \"application/json\",\n                            },\n                          },\n                        )\n                          .then(async (res) => {\n                            if (res.status === 429) {\n                              toast.error(\n                                \"Rate limit exceeded. Please try again later.\",\n                              );\n                              setSelectedPlan(null);\n                              return;\n                            }\n\n                            const data = await res.json();\n                            const { id: sessionId } = data;\n                            const stripe = await getStripe(isOldAccount);\n                            stripe?.redirectToCheckout({ sessionId });\n                          })\n                          .catch((err) => {\n                            alert(err);\n                            setSelectedPlan(null);\n                          });\n                      }\n                    }}\n                  >\n                    {selectedPlan === planOption\n                      ? \"Redirecting to Stripe...\"\n                      : `Upgrade to ${planOption} ${capitalize(period)}`}\n                  </Button>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      )}\n\n      {/* Data Rooms Plans (3 in a row) */}\n      {planType === \"datarooms\" && (\n        <div className=\"grid grid-cols-1 gap-2 md:grid-cols-3\">\n          {[\n            PlanEnum.DataRooms,\n            PlanEnum.DataRoomsPlus,\n            PlanEnum.DataRoomsPremium,\n          ].map((planOption) => {\n            const planFeatures = getPlanFeatures(planOption, { period });\n\n              return (\n                <div\n                  key={planOption}\n                  className={`relative flex flex-col rounded-lg border ${\n                    planOption === PlanEnum.Business\n                      ? \"border-[#fb7a00]\"\n                      : planOption === PlanEnum.DataRoomsPremium\n                        ? \"border-gray-900 dark:border-gray-200\"\n                        : planOption === PlanEnum.DataRoomsPlus\n                          ? \"border-gray-800 dark:border-gray-300\"\n                          : \"border-gray-400\"\n                  } bg-white p-6 shadow-sm dark:bg-gray-900`}\n                >\n                  <div className=\"mb-4 border-b border-gray-200 pb-2\">\n                    <h3 className=\"text-balance text-xl font-medium text-foreground text-gray-900 dark:text-white\">\n                      {planOption}\n                    </h3>\n                    {(planOption === PlanEnum.Business ||\n                      planOption === PlanEnum.DataRoomsPlus) && (\n                      <span\n                        className={`absolute -top-3 right-4 rounded px-2 py-1 text-xs text-white ${\n                          planOption === PlanEnum.DataRoomsPlus\n                            ? \"bg-gray-800 dark:bg-gray-200 dark:text-black\"\n                            : \"bg-[#fb7a00]\"\n                        }`}\n                      >\n                        {planOption === PlanEnum.DataRoomsPlus\n                          ? \"Best offer\"\n                          : \"Most popular\"}\n                      </span>\n                    )}\n                  </div>\n\n                  <div className=\"mb-2 text-balance text-4xl font-medium tabular-nums text-foreground\">\n                    €\n                    {PLANS.find((p) => p.name === planOption)!.price[period]\n                      .amount}\n                    <span className=\"text-base font-normal dark:text-white/75\">\n                      /month{period === \"yearly\" && \", billed annually\"}\n                    </span>\n                  </div>\n                  <p className=\"mt-4 text-sm text-gray-600 dark:text-white\">\n                    {planFeatures.featureIntro}\n                  </p>\n\n                  <ul\n                    role=\"list\"\n                    className=\"mb-4 mt-4 space-y-3 text-sm leading-6 text-gray-600\"\n                  >\n                    {planFeatures.features.map((feature, i) => (\n                      <li key={i}>\n                        <FeatureItem feature={feature} period={period} />\n                      </li>\n                    ))}\n                  </ul>\n                  <div className=\"mt-auto\">\n                    <Button\n                      variant={\n                        planOption === PlanEnum.Business ? \"default\" : \"default\"\n                      }\n                      className={`w-full py-2 text-sm ${\n                        planOption === PlanEnum.Business\n                          ? \"bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]\"\n                          : planOption === PlanEnum.DataRoomsPremium\n                            ? \"bg-gray-900 text-white hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-200\"\n                            : \"bg-gray-800 text-white hover:bg-gray-900 dark:hover:bg-gray-700/80\"\n                      }`}\n                      loading={selectedPlan === planOption}\n                      disabled={selectedPlan !== null}\n                      onClick={() => {\n                        setSelectedPlan(planOption);\n                        if (isCustomer && teamPlan !== \"free\") {\n                          fetch(\n                            `/api/teams/${teamInfo?.currentTeam?.id}/billing/manage`,\n                            {\n                              method: \"POST\",\n                            },\n                          )\n                            .then(async (res) => {\n                              if (res.status === 429) {\n                                toast.error(\n                                  \"Rate limit exceeded. Please try again later.\",\n                                );\n                                setSelectedPlan(null);\n                                return;\n                              }\n\n                              const url = await res.json();\n                              router.push(url);\n                            })\n                            .catch((err) => {\n                              alert(err);\n                              setSelectedPlan(null);\n                            });\n                        } else {\n                          fetch(\n                            `/api/teams/${\n                              teamInfo?.currentTeam?.id\n                            }/billing/upgrade?priceId=${\n                              PLANS.find((p) => p.name === planOption)!.price[\n                                period\n                              ].priceIds[\n                                process.env.NEXT_PUBLIC_VERCEL_ENV ===\n                                  \"production\"\n                                  ? \"production\"\n                                  : \"test\"\n                              ][isOldAccount ? \"old\" : \"new\"]\n                            }`,\n                            {\n                              method: \"POST\",\n                              headers: {\n                                \"Content-Type\": \"application/json\",\n                              },\n                            },\n                          )\n                            .then(async (res) => {\n                              if (res.status === 429) {\n                                toast.error(\n                                  \"Rate limit exceeded. Please try again later.\",\n                                );\n                                setSelectedPlan(null);\n                                return;\n                              }\n\n                              const data = await res.json();\n                              const { id: sessionId } = data;\n                              const stripe = await getStripe(isOldAccount);\n                              stripe?.redirectToCheckout({ sessionId });\n                            })\n                            .catch((err) => {\n                              alert(err);\n                              setSelectedPlan(null);\n                            });\n                        }\n                      }}\n                    >\n                      {selectedPlan === planOption\n                        ? \"Redirecting to Stripe...\"\n                        : `Upgrade to ${planOption} ${capitalize(period)}`}\n                    </Button>\n                  </div>\n                </div>\n              );\n            })}\n        </div>\n      )}\n\n      <div className=\"mt-8 flex flex-col items-center space-y-2\">\n        <a\n          target=\"_blank\"\n          className=\"text-sm text-muted-foreground underline-offset-4\"\n        >\n          All plans include unlimited viewers and page by page document\n          analytics.\n        </a>\n        <a\n          href=\"https://cal.com/marcseitz/papermark\"\n          target=\"_blank\"\n          className=\"text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline\"\n        >\n          Looking for Papermark Enterprise?\n        </a>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "pages/settings/webhooks/[id]/index.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { Webhook } from \"@prisma/client\";\nimport { ArrowLeft, Check, Copy, WebhookIcon } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport useSWR from \"swr\";\nimport z from \"zod\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { cn, fetcher } from \"@/lib/utils\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { WebhookEventList } from \"@/components/webhooks/webhook-events\";\n\nimport { documentEvents, linkEvents, teamEvents } from \"../new\";\n\ntype WebhookFormData = {\n  name: string;\n  triggers: string[];\n};\n\nexport default function WebhookDetail() {\n  const router = useRouter();\n  const { id } = router.query;\n  const { currentTeamId: teamId } = useTeam();\n  const { isFree, isPro, isTrial } = usePlan();\n  const [isEditing, setIsEditing] = useState(false);\n  const [isCopied, setIsCopied] = useState(false);\n  const [formData, setFormData] = useState<WebhookFormData>({\n    name: \"\",\n    triggers: [],\n  });\n\n  const { data: webhook, mutate } = useSWR<Webhook>(\n    teamId && id ? `/api/teams/${teamId}/webhooks/${id}` : null,\n    fetcher,\n  );\n\n  const { data: webhookEvents } = useSWR<any[]>(\n    teamId && id ? `/api/teams/${teamId}/webhooks/${id}/events` : null,\n    fetcher,\n    {\n      keepPreviousData: true,\n    },\n  );\n\n  useEffect(() => {\n    if (webhook) {\n      setFormData({\n        name: webhook.name,\n        triggers: webhook.triggers as string[],\n      });\n    }\n  }, [webhook]);\n\n  const handleUpdate = async () => {\n    if ((isFree || isPro) && !isTrial) {\n      toast.error(\"This feature is not available on your plan\");\n      return;\n    }\n\n    try {\n      const webhookId = z.string().cuid().parse(id);\n      const response = await fetch(\n        `/api/teams/${teamId}/webhooks/${webhookId}`,\n        {\n          method: \"PATCH\",\n          headers: { \"Content-Type\": \"application/json\" },\n          body: JSON.stringify(formData),\n        },\n      );\n\n      if (!response.ok) throw new Error(\"Failed to update webhook\");\n\n      await mutate();\n      setIsEditing(false);\n      toast.success(\"Webhook updated successfully\");\n    } catch (error) {\n      toast.error(\"Failed to update webhook\");\n    }\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"mb-2 flex items-center gap-2 pl-0 text-muted-foreground\"\n          onClick={() => router.push(\"/settings/webhooks\")}\n        >\n          <ArrowLeft className=\"h-4 w-4\" />\n          Back to webhooks\n        </Button>\n\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h3 className=\"text-lg font-semibold tracking-tight text-foreground\">\n                {webhook?.name}\n              </h3>\n              <p className=\"flex flex-row items-center gap-2 font-mono text-sm text-muted-foreground\">\n                {webhook?.url}\n              </p>\n            </div>\n          </div>\n\n          <Tabs defaultValue=\"events\" className=\"space-y-4\">\n            <TabsList>\n              <TabsTrigger value=\"events\">Events</TabsTrigger>\n              <TabsTrigger value=\"settings\">Settings</TabsTrigger>\n            </TabsList>\n\n            <TabsContent value=\"events\" className=\"space-y-4\">\n              {!webhookEvents || webhookEvents.length === 0 ? (\n                <div className=\"flex flex-col items-center justify-center space-y-4 rounded-lg border border-dashed py-12\">\n                  <div className=\"rounded-full bg-gray-100 p-3\">\n                    <WebhookIcon className=\"h-6 w-6 text-gray-600\" />\n                  </div>\n                  <div className=\"text-center\">\n                    <h3 className=\"font-medium\">No webhook events yet</h3>\n                    <p className=\"mt-1 text-sm text-gray-500\">\n                      Events will appear here when they are triggered\n                    </p>\n                  </div>\n                </div>\n              ) : (\n                <div className=\"space-y-4\">\n                  <WebhookEventList events={webhookEvents} />\n                </div>\n              )}\n            </TabsContent>\n\n            <TabsContent value=\"settings\" className=\"space-y-4\">\n              <Card className=\"p-6 dark:bg-transparent\">\n                <div className=\"space-y-6\">\n                  <div className=\"space-y-2\">\n                    <Label htmlFor=\"name\">Name</Label>\n                    <Input\n                      id=\"name\"\n                      value={isEditing ? formData.name : webhook?.name}\n                      onChange={(e) =>\n                        setFormData({ ...formData, name: e.target.value })\n                      }\n                      disabled={!isEditing}\n                      className={cn(\n                        \"disabled:cursor-not-allowed disabled:opacity-60\",\n                        isEditing && \"cursor-text\",\n                      )}\n                    />\n                  </div>\n\n                  <div className=\"space-y-4\">\n                    <div className=\"space-y-1\">\n                      <Label>Events</Label>\n                      <p className=\"text-sm text-muted-foreground\">\n                        Select the events the webhook will listen to. At least\n                        one must be selected.\n                      </p>\n                    </div>\n\n                    <div className=\"grid grid-cols-3 gap-8\">\n                      <div className=\"space-y-4\">\n                        <h4 className=\"text-sm font-light\">Team Events</h4>\n                        <div className=\"space-y-4\">\n                          {teamEvents.map((event) => (\n                            <div\n                              key={event.id}\n                              className=\"flex items-center space-x-2\"\n                            >\n                              <Checkbox\n                                id={event.id}\n                                checked={formData?.triggers?.includes(\n                                  event.value,\n                                )}\n                                onCheckedChange={(checked) => {\n                                  setFormData((prev) => ({\n                                    ...prev,\n                                    triggers: checked\n                                      ? [...prev.triggers, event.value]\n                                      : prev.triggers.filter(\n                                          (e) => e !== event.value,\n                                        ),\n                                  }));\n                                }}\n                                disabled={\n                                  !isEditing ||\n                                  event.value !== \"document.created\"\n                                }\n                              />\n                              <Label htmlFor={event.id}>{event.label}</Label>\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n\n                      <div className=\"space-y-4\">\n                        <h4 className=\"text-sm font-light\">Document Events</h4>\n                        <div className=\"space-y-4\">\n                          {documentEvents.map((event) => (\n                            <div\n                              key={event.id}\n                              className=\"flex items-center space-x-2\"\n                            >\n                              <Checkbox\n                                id={event.id}\n                                checked={formData?.triggers.includes(\n                                  event.value,\n                                )}\n                                onCheckedChange={(checked) => {\n                                  setFormData((prev) => ({\n                                    ...prev,\n                                    triggers: checked\n                                      ? [...prev.triggers, event.value]\n                                      : prev.triggers.filter(\n                                          (e) => e !== event.value,\n                                        ),\n                                  }));\n                                }}\n                                disabled={\n                                  !isEditing || event.value !== \"link.created\"\n                                }\n                              />\n                              <Label htmlFor={event.id}>{event.label}</Label>\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n\n                      <div className=\"space-y-4\">\n                        <h4 className=\"text-sm font-light\">Link Events</h4>\n                        <div className=\"space-y-4\">\n                          {linkEvents.map((event) => (\n                            <div\n                              key={event.id}\n                              className=\"flex items-center space-x-2\"\n                            >\n                              <Checkbox\n                                id={event.id}\n                                checked={formData?.triggers.includes(\n                                  event.value,\n                                )}\n                                disabled={\n                                  !isEditing ||\n                                  event.value === \"link.downloaded\"\n                                }\n                                onCheckedChange={(checked) => {\n                                  setFormData((prev) => ({\n                                    ...prev,\n                                    triggers: checked\n                                      ? [...prev.triggers, event.value]\n                                      : prev.triggers.filter(\n                                          (e) => e !== event.value,\n                                        ),\n                                  }));\n                                }}\n                              />\n                              <Label htmlFor={event.id}>{event.label}</Label>\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <div className=\"space-y-1\">\n                      <Label htmlFor=\"url\">Endpoint</Label>\n                      <p className=\"text-sm text-muted-foreground\">\n                        Webhooks events will be sent as{\" \"}\n                        <span className=\"rounded-sm bg-muted px-1 font-mono ring-1 ring-muted-foreground/30\">\n                          POST\n                        </span>{\" \"}\n                        request to this URL. This cannot be changed after\n                        creation.\n                      </p>\n                    </div>\n                    <div className=\"rounded-md border border-input bg-muted px-3 py-2 font-mono text-sm text-muted-foreground\">\n                      {webhook?.url}\n                    </div>\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <div className=\"space-y-1\">\n                      <Label htmlFor=\"secret\">Signing Secret</Label>\n                      <p className=\"text-sm text-muted-foreground\">\n                        This secret will be used to sign the webhook payload.\n                        This cannot be changed after creation.\n                      </p>\n                    </div>\n                    <div className=\"relative\">\n                      <div className=\"rounded-md border border-input bg-muted px-3 py-2 font-mono text-sm text-muted-foreground\">\n                        {webhook?.secret}\n                      </div>\n                      <Button\n                        type=\"button\"\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        className=\"absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2\"\n                        onClick={() => {\n                          navigator.clipboard.writeText(webhook?.secret || \"\");\n                          toast.success(\"Copied to clipboard!\");\n                          setIsCopied(true);\n                          setTimeout(() => setIsCopied(false), 2000);\n                        }}\n                      >\n                        {isCopied ? (\n                          <Check className=\"h-4 w-4\" />\n                        ) : (\n                          <Copy className=\"h-4 w-4\" />\n                        )}\n                      </Button>\n                    </div>\n                  </div>\n\n                  <div className=\"space-y-2\">\n                    <div className=\"space-y-1\">\n                      <Label>Webhook ID</Label>\n                      <p className=\"text-sm text-muted-foreground\">\n                        This ID can be used to identify the webhook or for\n                        debugging purposes.\n                      </p>\n                    </div>\n                    <p className=\"font-mono text-sm text-muted-foreground\">\n                      {webhook?.pId}\n                    </p>\n                  </div>\n\n                  <div className=\"pt-4\">\n                    {isEditing ? (\n                      <div className=\"flex gap-2\">\n                        <Button\n                          onClick={(e) => {\n                            e.preventDefault();\n                            e.stopPropagation();\n                            handleUpdate();\n                          }}\n                        >\n                          Save Changes\n                        </Button>\n                        <Button\n                          variant=\"outline\"\n                          className=\"dark:bg-transparent dark:hover:bg-muted\"\n                          onClick={() => setIsEditing(false)}\n                        >\n                          Cancel\n                        </Button>\n                      </div>\n                    ) : (\n                      <Button\n                        onClick={(e) => {\n                          e.preventDefault();\n                          e.stopPropagation();\n                          if ((isFree || isPro) && !isTrial) {\n                            toast.error(\n                              \"This feature is not available on your plan\",\n                            );\n                            return;\n                          }\n                          setIsEditing(true);\n                        }}\n                      >\n                        Click to edit webhook\n                      </Button>\n                    )}\n                  </div>\n                </div>\n              </Card>\n\n              <Card className=\"border-destructive p-6\">\n                <div className=\"space-y-4\">\n                  <div>\n                    <h4 className=\"text-lg font-medium text-destructive\">\n                      Delete Webhook\n                    </h4>\n                    <p className=\"text-sm text-muted-foreground\">\n                      Once you delete a webhook, there is no going back. This\n                      action cannot be undone.\n                    </p>\n                  </div>\n                  <Button\n                    variant=\"destructive\"\n                    onClick={async () => {\n                      if (\n                        confirm(\n                          \"Are you sure you want to delete this webhook? This action cannot be undone.\",\n                        )\n                      ) {\n                        try {\n                          const webhookId = z.string().cuid().parse(id);\n                          const response = await fetch(\n                            `/api/teams/${teamId}/webhooks/${webhookId}`,\n                            {\n                              method: \"DELETE\",\n                            },\n                          );\n\n                          if (!response.ok)\n                            throw new Error(\"Failed to delete webhook\");\n\n                          toast.success(\"Webhook deleted successfully\");\n                          router.push(\"/settings/webhooks\");\n                        } catch (error) {\n                          toast.error(\"Failed to delete webhook\");\n                        }\n                      }\n                    }}\n                  >\n                    Delete Webhook\n                  </Button>\n                </div>\n              </Card>\n            </TabsContent>\n          </Tabs>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/settings/webhooks/index.tsx",
    "content": "import Link from \"next/link\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { CircleHelpIcon, WebhookIcon } from \"lucide-react\";\nimport useSWR from \"swr\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport { fetcher } from \"@/lib/utils\";\n\nimport PlanBadge from \"@/components/billing/plan-badge\";\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport { Button } from \"@/components/ui/button\";\nimport { BadgeTooltip } from \"@/components/ui/tooltip\";\n\ninterface Webhook {\n  id: string;\n  name: string;\n  url: string;\n  createdAt: string;\n}\n\nexport default function WebhookSettings() {\n  const teamInfo = useTeam();\n  const { isFree, isPro, isTrial } = usePlan();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const { data: webhooks } = useSWR<Webhook[]>(\n    teamId ? `/api/teams/${teamId}/webhooks` : null,\n    fetcher,\n  );\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n        <div>\n          <div className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n            <div className=\"space-y-1\">\n              <h3 className=\"flex items-center gap-2 text-2xl font-semibold tracking-tight text-foreground\">\n                Webhooks{\" \"}\n                {(isFree || isPro) && !isTrial ? (\n                  <PlanBadge plan=\"Business\" />\n                ) : null}\n              </h3>\n              <p className=\"flex flex-row items-center gap-2 text-sm text-muted-foreground\">\n                Send data to external services when events happen in Papermark\n                <BadgeTooltip content=\"Send data to external services when events happen in Papermark\">\n                  <CircleHelpIcon className=\"h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground\" />\n                </BadgeTooltip>\n              </p>\n            </div>\n            <Link href=\"/settings/webhooks/new\">\n              <Button>Create Webhook</Button>\n            </Link>\n          </div>\n\n          {/* Webhooks List */}\n          {!webhooks || webhooks.length === 0 ? (\n            <div className=\"flex flex-col items-center justify-center space-y-4 py-12\">\n              <div className=\"rounded-full bg-gray-100 p-3\">\n                <WebhookIcon className=\"h-6 w-6 text-gray-600\" />\n              </div>\n              <div className=\"text-center\">\n                <h3 className=\"font-medium\">No webhooks configured</h3>\n                <p className=\"mt-1 max-w-sm text-sm text-gray-500\">\n                  Webhooks allow you to receive HTTP requests whenever specific\n                  events occur in your account.\n                </p>\n              </div>\n            </div>\n          ) : (\n            <div className=\"grid grid-cols-1 gap-3\">\n              {webhooks.map((webhook) => (\n                <Link\n                  key={webhook.id}\n                  href={`/settings/webhooks/${webhook.id}`}\n                  className=\"rounded-xl border border-gray-200 bg-white p-4 transition-[filter] dark:border-gray-400 dark:bg-secondary sm:p-5\"\n                >\n                  <div className=\"flex items-center gap-x-3\">\n                    {/* <div className=\"rounded-md border border-gray-200 bg-gradient-to-t from-gray-100 p-2.5\">\n                      <Avatar className=\"size-6\">\n                        <AvatarFallback>\n                          {webhook.name.slice(0, 2).toUpperCase()}\n                        </AvatarFallback>\n                      </Avatar>\n                    </div> */}\n                    <div>\n                      <div className=\"flex items-center gap-1\">\n                        <span className=\"font-semibold\">{webhook.name}</span>\n                      </div>\n                      <div className=\"flex items-center gap-1 text-sm text-muted-foreground\">\n                        {webhook.url}\n                      </div>\n                    </div>\n                  </div>\n                </Link>\n              ))}\n            </div>\n          )}\n        </div>\n      </main>\n    </AppLayout>\n  );\n}"
  },
  {
    "path": "pages/settings/webhooks/new.tsx",
    "content": "\"use client\";\n\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { useTeam } from \"@/context/team-context\";\nimport { PlanEnum } from \"@/ee/stripe/constants\";\nimport { ArrowLeft, Check } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { z } from \"zod\";\n\nimport { newId } from \"@/lib/id-helper\";\nimport { usePlan } from \"@/lib/swr/use-billing\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SettingsHeader } from \"@/components/settings/settings-header\";\nimport Copy from \"@/components/shared/icons/copy\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent } from \"@/components/ui/card\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { UpgradeButton } from \"@/components/ui/upgrade-button\";\n\ninterface WebhookEvent {\n  id: string;\n  label: string;\n  value: string;\n}\n\nexport const teamEvents: WebhookEvent[] = [\n  {\n    id: \"document-created\",\n    label: \"Document Created\",\n    value: \"document.created\",\n  },\n  {\n    id: \"document-updated\",\n    label: \"Document Updated\",\n    value: \"document.updated\",\n  },\n  {\n    id: \"document-deleted\",\n    label: \"Document Deleted\",\n    value: \"document.deleted\",\n  },\n  {\n    id: \"dataroom-created\",\n    label: \"Dataroom Created\",\n    value: \"dataroom.created\",\n  },\n];\n\nexport const documentEvents: WebhookEvent[] = [\n  { id: \"link-created\", label: \"Link Created\", value: \"link.created\" },\n  { id: \"link-updated\", label: \"Link Updated\", value: \"link.updated\" },\n];\n\nexport const linkEvents: WebhookEvent[] = [\n  { id: \"link-viewed\", label: \"Link Viewed\", value: \"link.viewed\" },\n  { id: \"link-downloaded\", label: \"Link Downloaded\", value: \"link.downloaded\" },\n];\n\nconst formSchema = z.object({\n  name: z\n    .string()\n    .min(3, \"Please provide a webhook name with at least 3 characters.\"),\n  url: z.string().url(\"Please enter a valid URL.\"),\n  secret: z.string(),\n  triggers: z.array(z.string()),\n});\n\nexport default function NewWebhook() {\n  const router = useRouter();\n  const teamInfo = useTeam();\n  const { isFree, isPro, isTrial } = usePlan();\n  const teamId = teamInfo?.currentTeam?.id;\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [isCopied, setIsCopied] = useState(false);\n  const [formData, setFormData] = useState({\n    name: \"\",\n    url: \"\",\n    secret: \"\",\n    triggers: [] as string[],\n  });\n\n  useEffect(() => {\n    const generatedSecret = newId(\"webhookSecret\");\n    setFormData((prev) => ({ ...prev, secret: generatedSecret }));\n  }, []);\n\n  const createWebhook = async () => {\n    if ((isFree || isPro) && !isTrial) {\n      return;\n    }\n\n    try {\n      setIsLoading(true);\n      const result = formSchema.safeParse(formData);\n      if (!result.success) {\n        const errors = result.error.flatten().fieldErrors;\n        Object.values(errors).forEach((errorMessages) => {\n          if (errorMessages) {\n            toast.error(errorMessages[0]);\n          }\n        });\n        return;\n      }\n      const response = await fetch(`/api/teams/${teamId}/webhooks`, {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify(formData),\n      });\n\n      if (!response.ok) {\n        throw new Error(\"Failed to create webhook\");\n      }\n\n      toast.success(\"Webhook created successfully\");\n      router.push(\"/settings/webhooks\");\n    } catch (error) {\n      toast.error(\"Failed to create webhook\");\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <AppLayout>\n      <main className=\"relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10\">\n        <SettingsHeader />\n\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"mb-2 flex items-center gap-2 pl-0 text-muted-foreground\"\n          onClick={() => router.push(\"/settings/webhooks\")}\n        >\n          <ArrowLeft className=\"h-4 w-4\" />\n          Back to webhooks\n        </Button>\n\n        <div className=\"w-full max-w-3xl\">\n          <form\n            onSubmit={(e) => {\n              e.preventDefault();\n              e.stopPropagation();\n              createWebhook();\n            }}\n            className=\"space-y-8\"\n          >\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"name\">Name</Label>\n              <p className=\"text-sm text-muted-foreground\">\n                This name will be used to identify the webhook in the dashboard.\n              </p>\n              <Input\n                id=\"name\"\n                placeholder=\"My Webhook\"\n                value={formData.name}\n                onChange={(e) =>\n                  setFormData((prev) => ({ ...prev, name: e.target.value }))\n                }\n                data-1p-ignore\n                autoComplete=\"off\"\n                autoFocus\n              />\n            </div>\n\n            <div className=\"space-y-4\">\n              <div className=\"space-y-1\">\n                <Label>Events</Label>\n                <p className=\"text-sm text-muted-foreground\">\n                  Select the events the webhook will listen to. At least one\n                  must be selected.\n                </p>\n              </div>\n\n              <Card className=\"dark:bg-muted\">\n                <CardContent className=\"py-6\">\n                  <div className=\"space-y-6\">\n                    <div className=\"grid grid-cols-3 gap-8\">\n                      <div className=\"space-y-4\">\n                        <h4 className=\"text-sm font-light\">Team Events</h4>\n                        <div className=\"space-y-4\">\n                          {teamEvents.map((event) => (\n                            <div\n                              key={event.id}\n                              className=\"flex items-center space-x-2\"\n                            >\n                              <Checkbox\n                                id={event.id}\n                                checked={formData.triggers.includes(\n                                  event.value,\n                                )}\n                                disabled={event.value !== \"document.created\"}\n                                onCheckedChange={(checked) => {\n                                  setFormData((prev) => ({\n                                    ...prev,\n                                    triggers: checked\n                                      ? [...prev.triggers, event.value]\n                                      : prev.triggers.filter(\n                                          (e) => e !== event.value,\n                                        ),\n                                  }));\n                                }}\n                              />\n                              <Label htmlFor={event.id}>{event.label}</Label>\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n\n                      <div className=\"space-y-4\">\n                        <h4 className=\"text-sm font-light\">Document Events</h4>\n                        <div className=\"space-y-4\">\n                          {documentEvents.map((event) => (\n                            <div\n                              key={event.id}\n                              className=\"flex items-center space-x-2\"\n                            >\n                              <Checkbox\n                                id={event.id}\n                                checked={formData.triggers.includes(\n                                  event.value,\n                                )}\n                                disabled={event.value !== \"link.created\"}\n                                onCheckedChange={(checked) => {\n                                  setFormData((prev) => ({\n                                    ...prev,\n                                    triggers: checked\n                                      ? [...prev.triggers, event.value]\n                                      : prev.triggers.filter(\n                                          (e) => e !== event.value,\n                                        ),\n                                  }));\n                                }}\n                              />\n                              <Label htmlFor={event.id}>{event.label}</Label>\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n\n                      <div className=\"space-y-4\">\n                        <h4 className=\"text-sm font-light\">Link Events</h4>\n                        <div className=\"space-y-4\">\n                          {linkEvents.map((event) => (\n                            <div\n                              key={event.id}\n                              className=\"flex items-center space-x-2\"\n                            >\n                              <Checkbox\n                                id={event.id}\n                                checked={formData.triggers.includes(\n                                  event.value,\n                                )}\n                                disabled={event.value === \"link.downloaded\"}\n                                onCheckedChange={(checked) => {\n                                  setFormData((prev) => ({\n                                    ...prev,\n                                    triggers: checked\n                                      ? [...prev.triggers, event.value]\n                                      : prev.triggers.filter(\n                                          (e) => e !== event.value,\n                                        ),\n                                  }));\n                                }}\n                              />\n                              <Label htmlFor={event.id}>{event.label}</Label>\n                            </div>\n                          ))}\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                </CardContent>\n              </Card>\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"url\">Endpoint</Label>\n              <p className=\"text-sm text-muted-foreground\">\n                Webhooks events will be sent as{\" \"}\n                <span className=\"rounded-sm bg-muted px-1 font-mono ring-1 ring-muted-foreground/30\">\n                  POST\n                </span>{\" \"}\n                request to this URL.\n              </p>\n              <Input\n                id=\"url\"\n                placeholder=\"https://your-domain.com/webhooks\"\n                value={formData.url}\n                onChange={(e) =>\n                  setFormData((prev) => ({ ...prev, url: e.target.value }))\n                }\n              />\n            </div>\n\n            <div className=\"space-y-2\">\n              <Label htmlFor=\"secret\">Signing Secret</Label>\n              <p className=\"text-sm text-muted-foreground\">\n                This secret will be used to sign the webhook payload.\n              </p>\n              <div className=\"relative\">\n                <Input\n                  id=\"secret\"\n                  placeholder=\"whsec_1234567890abcdef\"\n                  type=\"text\"\n                  value={formData.secret}\n                  readOnly\n                  className=\"pr-10 font-mono\"\n                />\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  className=\"absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2\"\n                  onClick={() => {\n                    navigator.clipboard.writeText(formData.secret);\n                    toast.success(\"Copied to clipboard!\");\n                    setIsCopied(true);\n                    setTimeout(() => setIsCopied(false), 2000);\n                  }}\n                >\n                  {isCopied ? (\n                    <Check className=\"h-4 w-4\" />\n                  ) : (\n                    <Copy className=\"h-4 w-4\" />\n                  )}\n                </Button>\n              </div>\n            </div>\n\n            <div className=\"flex space-x-4\">\n              {(isFree || isPro) && !isTrial ? (\n                <UpgradeButton\n                  text=\"Save Webhook\"\n                  clickedPlan={PlanEnum.Business}\n                  trigger=\"create_webhook\"\n                  highlightItem={[\"webhooks\"]}\n                  type=\"submit\"\n                  disabled={isLoading}\n                  key=\"create-webhook\"\n                />\n              ) : (\n                <Button type=\"submit\" disabled={isLoading}>\n                  {isLoading ? \"Creating...\" : \"Create Webhook\"}\n                </Button>\n              )}\n              <Button\n                type=\"button\"\n                variant=\"outline\"\n                className=\"dark:bg-transparent dark:hover:bg-muted\"\n                onClick={() => router.push(\"/settings/webhooks\")}\n              >\n                Cancel\n              </Button>\n            </div>\n          </form>\n        </div>\n      </main>\n    </AppLayout>\n  );\n}\n"
  },
  {
    "path": "pages/unsubscribe.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nexport default function UnsubscribePage() {\n  const router = useRouter();\n  const { type, token } = router.query as { type: string; token: string };\n  const [status, setStatus] = useState<\"idle\" | \"success\" | \"error\">(\"idle\");\n  const [loading, setLoading] = useState(false);\n  const [message, setMessage] = useState(\"\");\n\n  const handleUnsubscribe = async () => {\n    try {\n      if (!token) {\n        setStatus(\"error\");\n        setMessage(\"Token is required\");\n        return;\n      }\n      setLoading(true);\n      const response = await fetch(`/api/unsubscribe/${type}?token=${token}`, {\n        method: \"POST\",\n      });\n      const data = await response.json();\n\n      if (!response.ok) {\n        throw new Error(data.message || \"Failed to unsubscribe\");\n      }\n\n      setStatus(\"success\");\n      setMessage(data.message);\n    } catch (error) {\n      setStatus(\"error\");\n      setMessage((error as Error).message);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"flex min-h-screen flex-col items-center justify-center bg-gray-50 p-4\">\n      <div className=\"w-full max-w-md rounded-lg bg-white p-8 shadow-md\">\n        <h1 className=\"mb-6 text-center text-2xl font-bold\">\n          Unsubscribe from {type === \"yir\" ? \"Year in Review\" : \"Dataroom \"}\n          Notifications\n        </h1>\n\n        {status === \"error\" ? (\n          <div className=\"mb-4 text-center text-red-500\">{message}</div>\n        ) : status === \"success\" ? (\n          <div className=\"mb-4 text-center text-green-500\">{message}</div>\n        ) : (\n          <p className=\"mb-6 text-center text-gray-600\">\n            Click the button below to unsubscribe from notifications for this\n            {type === \"yir\" ? \"year in review\" : \" dataroom\"}.\n          </p>\n        )}\n\n        {status === \"idle\" && (\n          <Button\n            onClick={handleUnsubscribe}\n            variant=\"destructive\"\n            className=\"w-full\"\n            loading={loading}\n          >\n            {loading ? \"Unsubscribing...\" : \"Unsubscribe\"}\n          </Button>\n        )}\n\n        {status === \"success\" && (\n          <p className=\"mt-4 text-center text-sm text-gray-500\">\n            You can close this window now.\n          </p>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "pages/view/[linkId]/d/[documentId].tsx",
    "content": "import { GetStaticPropsContext } from \"next\";\nimport { useRouter } from \"next/router\";\n\nimport React, { useEffect, useState } from \"react\";\n\nimport NotFound from \"@/pages/404\";\nimport { DataroomBrand } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { useSession } from \"next-auth/react\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { parsePageId } from \"notion-utils\";\nimport z from \"zod\";\n\nimport { fetchLinkDataById } from \"@/lib/api/links/link-data\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport notion from \"@/lib/notion\";\nimport {\n  addSignedUrls,\n  fetchMissingPageReferences,\n  normalizeRecordMap,\n} from \"@/lib/notion/utils\";\nimport { CustomUser, LinkWithDataroomDocument, NotionTheme } from \"@/lib/types\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport CustomMetaTag from \"@/components/view/custom-metatag\";\nimport DataroomDocumentView from \"@/components/view/dataroom/dataroom-document-view\";\n\ntype DataroomDocumentLinkData = {\n  linkType: \"DATAROOM_LINK\";\n  link: LinkWithDataroomDocument;\n  brand: DataroomBrand | null;\n};\n\ntype DataroomDocumentProps = {\n  linkData: DataroomDocumentLinkData;\n  notionData: {\n    rootNotionPageId: string | null;\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null;\n  };\n  meta: {\n    enableCustomMetatag: boolean;\n    metaTitle: string | null;\n    metaDescription: string | null;\n    metaImage: string | null;\n    metaFavicon: string;\n    metaUrl: string;\n  };\n  showPoweredByBanner: boolean;\n  showAccountCreationSlide: boolean;\n  useAdvancedExcelViewer: boolean;\n  useCustomAccessForm: boolean;\n  logoOnAccessForm: boolean;\n  textSelectionEnabled?: boolean;\n  error?: boolean;\n};\n\nexport default function DataroomDocumentViewPage({\n  linkData,\n  notionData,\n  meta,\n  showPoweredByBanner,\n  showAccountCreationSlide,\n  useAdvancedExcelViewer,\n  useCustomAccessForm,\n  logoOnAccessForm,\n  textSelectionEnabled,\n  error,\n}: DataroomDocumentProps) {\n  const router = useRouter();\n  const { data: session, status } = useSession();\n  const [storedToken, setStoredToken] = useState<string | undefined>(undefined);\n  const [storedEmail, setStoredEmail] = useState<string | undefined>(undefined);\n\n  useEffect(() => {\n    // Retrieve token from cookie on component mount\n    const cookieToken = Cookies.get(`pm_drs_flag_${router.query.linkId}`);\n    const storedEmail = window.localStorage.getItem(\"papermark.email\");\n    if (cookieToken) {\n      setStoredToken(cookieToken);\n      if (storedEmail) {\n        setStoredEmail(storedEmail.toLowerCase());\n      }\n    }\n  }, [router.query.linkId]);\n\n  if (router.isFallback) {\n    return (\n      <div className=\"flex h-screen items-center justify-center bg-black\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <NotFound message=\"Sorry, we had trouble loading this link. Please try again in a moment.\" />\n    );\n  }\n\n  const {\n    email: verifiedEmail,\n    d: disableEditEmail,\n    previewToken,\n    preview,\n  } = router.query as {\n    email: string;\n    d: string;\n    previewToken?: string;\n    preview?: string;\n  };\n  const { link, brand } = linkData;\n\n  // Render the document view for DATAROOM_LINK\n  if (!linkData || status === \"loading\" || router.isFallback) {\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={meta.enableCustomMetatag ?? false}\n          title={\n            meta.metaTitle ??\n            `${link?.dataroomDocument?.document?.name} | Powered by Papermark`\n          }\n          description={meta.metaDescription ?? null}\n          imageUrl={meta.metaImage ?? null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <div className=\"flex h-screen items-center justify-center\">\n          <LoadingSpinner className=\"h-20 w-20\" />\n        </div>\n      </>\n    );\n  }\n\n  const {\n    expiresAt,\n    emailProtected,\n    emailAuthenticated,\n    password: linkPassword,\n    enableAgreement,\n    isArchived,\n  } = link;\n\n  const { email: userEmail, id: userId } = (session?.user as CustomUser) || {};\n\n  // Check if the link is expired\n  if (expiresAt && new Date(expiresAt) < new Date()) {\n    return (\n      <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n    );\n  }\n\n  // Check if the link is archived\n  if (isArchived) {\n    return (\n      <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n    );\n  }\n\n  return (\n    <>\n      <CustomMetaTag\n        favicon={meta.metaFavicon}\n        enableBranding={meta.enableCustomMetatag ?? false}\n        title={\n          meta.metaTitle ??\n          `${link?.dataroomDocument?.document?.name} | Powered by Papermark`\n        }\n        description={meta.metaDescription ?? null}\n        imageUrl={meta.metaImage ?? null}\n        url={meta.metaUrl ?? \"\"}\n      />\n      <DataroomDocumentView\n        link={link}\n        userEmail={verifiedEmail ?? storedEmail ?? userEmail}\n        userId={userId}\n        isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n        notionData={notionData}\n        brand={brand}\n        useAdvancedExcelViewer={useAdvancedExcelViewer}\n        previewToken={previewToken}\n        disableEditEmail={!!disableEditEmail}\n        useCustomAccessForm={useCustomAccessForm}\n        logoOnAccessForm={logoOnAccessForm}\n        token={storedToken}\n        verifiedEmail={verifiedEmail}\n        preview={!!preview}\n        textSelectionEnabled={textSelectionEnabled}\n      />\n    </>\n  );\n}\n\nexport async function getStaticProps(context: GetStaticPropsContext) {\n  const { linkId: linkIdParam, documentId: documentIdParam } =\n    context.params as {\n      linkId: string;\n      documentId: string;\n    };\n\n  try {\n    const linkId = z.string().cuid().parse(linkIdParam);\n    const documentId = z.string().cuid().parse(documentIdParam);\n\n    // Fetch link data directly from database to avoid internal HTTP fetch\n    // which can be blocked by Vercel's edge protection (403 errors)\n    const result = await fetchLinkDataById({\n      linkId,\n      dataroomDocumentId: documentId,\n    });\n\n    if (result.status !== \"ok\") {\n      return { notFound: true };\n    }\n\n    const { linkType, link, brand } = result;\n\n    if (!link || !linkType) {\n      return { notFound: true };\n    }\n\n    if (linkType !== \"DATAROOM_LINK\") {\n      return { notFound: true };\n    }\n\n    let pageId = null;\n    let recordMap = null;\n    let theme = null;\n\n    const { type, file, ...versionWithoutTypeAndFile } =\n      link.dataroomDocument.document.versions[0];\n\n    if (type === \"notion\") {\n      theme = new URL(file).searchParams.get(\"mode\");\n      const notionPageId = parsePageId(file, { uuid: false });\n      if (!notionPageId) {\n        return {\n          notFound: true,\n        };\n      }\n\n      pageId = notionPageId;\n      recordMap = await notion.getPage(pageId, { signFileUrls: false });\n      // Fetch missing page references that are embedded in rich text (e.g., table cells with multiple page links)\n      await fetchMissingPageReferences(recordMap);\n      // Normalize double-nested block structures from the Notion API\n      normalizeRecordMap(recordMap);\n      // TODO: separately sign the file urls until PR merged and published; ref: https://github.com/NotionX/react-notion-x/issues/580#issuecomment-2542823817\n      await addSignedUrls({ recordMap });\n    }\n\n    const { teamId, team, ...linkData } = link;\n\n    const { advancedExcelEnabled, ...linkDocument } =\n      linkData.dataroomDocument.document;\n\n    // Check feature flags\n    const featureFlags = await getFeatureFlags({ teamId: teamId || undefined });\n    const textSelectionEnabled = featureFlags.textSelection;\n\n    return {\n      props: {\n        linkData: {\n          linkType: \"DATAROOM_LINK\",\n          link: {\n            ...linkData,\n            teamId: teamId,\n            dataroomDocument: {\n              ...linkData.dataroomDocument,\n              document: {\n                ...linkDocument,\n                versions: [versionWithoutTypeAndFile],\n              },\n            },\n          },\n          brand,\n        },\n        notionData: {\n          rootNotionPageId: null, // do not pass rootNotionPageId to the client\n          recordMap,\n          theme,\n        },\n        meta: {\n          enableCustomMetatag: link.enableCustomMetatag || false,\n          metaTitle: link.metaTitle,\n          metaDescription: link.metaDescription,\n          metaImage: link.metaImage,\n          metaFavicon: link.metaFavicon ?? \"/favicon.ico\",\n          metaUrl: `https://www.papermark.com/view/${linkId}`,\n        },\n        showPoweredByBanner: false,\n        showAccountCreationSlide: false,\n        useAdvancedExcelViewer: advancedExcelEnabled,\n        useCustomAccessForm:\n          teamId === \"cm0154tiv0000lr2t6nr5c6kp\" ||\n          teamId === \"clup33by90000oewh4rfvp2eg\" ||\n          teamId === \"cm76hfyvy0002q623hmen99pf\" ||\n          teamId === \"cm9ztf0s70005js04i689gefn\" ||\n          teamId === \"cmk2hnmqh0000k304zcoezt6n\",\n        logoOnAccessForm: teamId === \"cm7nlkrhm0000qgh0nvyrrywr\",\n        textSelectionEnabled,\n      },\n      revalidate: brand || recordMap ? 10 : 60,\n    };\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.error(\"Fetching error:\", message);\n    return { props: { error: true }, revalidate: 30 };\n  }\n}\n\nexport async function getStaticPaths() {\n  return {\n    paths: [],\n    fallback: true,\n  };\n}\n"
  },
  {
    "path": "pages/view/[linkId]/downloads.tsx",
    "content": "import { GetServerSideProps } from \"next\";\nimport { useRouter } from \"next/router\";\n\nimport { Loader2 } from \"lucide-react\";\n\nimport { DownloadsPanel } from \"@/components/view/dataroom/downloads-panel\";\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n  const linkId = context.params?.linkId as string;\n  return { props: { linkId: linkId ?? null } };\n};\n\nexport default function ViewDownloadsPage({\n  linkId: linkIdProp,\n}: {\n  linkId: string | null;\n}) {\n  const router = useRouter();\n  const linkId = (router.query.linkId as string) ?? linkIdProp ?? undefined;\n\n  if (!linkId) {\n    return (\n      <div className=\"flex min-h-screen items-center justify-center\">\n        <Loader2 className=\"h-8 w-8 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  return <DownloadsPanel linkId={linkId} />;\n}\n"
  },
  {
    "path": "pages/view/[linkId]/embed.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport NotFound from \"@/pages/404\";\n\nimport { useAnalytics } from \"@/lib/analytics\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport DataroomView from \"@/components/view/dataroom/dataroom-view\";\nimport DocumentView from \"@/components/view/document-view\";\n\nimport { ViewPageProps } from \"./index\";\n\n// Reuse the same getStaticProps and getStaticPaths from the main view page\nexport { getStaticProps, getStaticPaths } from \"./index\";\n\nexport default function EmbedPage(props: ViewPageProps) {\n  const router = useRouter();\n  const [isEmbedded, setIsEmbedded] = useState<boolean | null>(null);\n  const analytics = useAnalytics();\n\n  useEffect(() => {\n    // Only run when router is ready and linkId is present\n    if (!router.isReady || !router.query.linkId) return;\n\n    // Check if the page is embedded in an iframe\n    const isInIframe = window !== window.parent;\n    setIsEmbedded(isInIframe);\n\n    if (isInIframe) {\n      document.body.classList.add(\"embed-view\");\n\n      // Track embed view with referrer information\n      const referrer = document.referrer;\n      const embedSource = referrer ? new URL(referrer).hostname : \"direct\";\n\n      analytics.capture(\"Embedded Link Loaded\", {\n        linkId: router.query.linkId as string,\n        embedSource,\n        url: referrer || \"unknown\",\n        userAgent: window.navigator.userAgent,\n      });\n\n      return () => document.body.classList.remove(\"embed-view\");\n    }\n  }, [router.isReady, router.query.linkId]);\n\n  // Show loading state while checking\n  if (isEmbedded === null || router.isFallback) {\n    return (\n      <div className=\"flex h-screen items-center justify-center\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n\n  // Block direct access\n  if (!isEmbedded) {\n    return (\n      <NotFound message=\"This page can only be accessed when embedded in another website.\" />\n    );\n  }\n\n  const {\n    email: verifiedEmail,\n    d: disableEditEmail,\n    previewToken,\n  } = router.query as {\n    email: string;\n    d: string;\n    previewToken?: string;\n  };\n  const { linkType, brand } = props.linkData;\n\n  // Render the document view for DOCUMENT_LINK\n  if (linkType === \"DOCUMENT_LINK\") {\n    const { link } = props.linkData;\n    if (!props.linkData || router.isFallback) {\n      return (\n        <div className=\"flex h-screen items-center justify-center\">\n          <LoadingSpinner className=\"h-20 w-20\" />\n        </div>\n      );\n    }\n\n    const {\n      expiresAt,\n      emailProtected,\n      emailAuthenticated,\n      password: linkPassword,\n      enableAgreement,\n      isArchived,\n    } = link;\n\n    // If the link is expired, show a 404 page\n    if (expiresAt && new Date(expiresAt) < new Date()) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n      );\n    }\n\n    if (isArchived) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n      );\n    }\n\n    return (\n      <div className=\"h-screen w-full overflow-hidden\">\n        <DocumentView\n          link={link}\n          userEmail={verifiedEmail}\n          userId={null}\n          isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n          notionData={props.notionData}\n          brand={brand}\n          showPoweredByBanner={props.showPoweredByBanner}\n          showAccountCreationSlide={props.showAccountCreationSlide}\n          useAdvancedExcelViewer={props.useAdvancedExcelViewer}\n          previewToken={previewToken}\n          disableEditEmail={!!disableEditEmail}\n          useCustomAccessForm={props.useCustomAccessForm}\n          verifiedEmail={verifiedEmail}\n          isEmbedded\n        />\n      </div>\n    );\n  }\n\n  // Render the dataroom view for DATAROOM_LINK\n  if (linkType === \"DATAROOM_LINK\") {\n    const { link } = props.linkData;\n    if (!link || router.isFallback) {\n      return (\n        <div className=\"flex h-screen items-center justify-center\">\n          <LoadingSpinner className=\"h-20 w-20\" />\n        </div>\n      );\n    }\n\n    const {\n      expiresAt,\n      emailProtected,\n      emailAuthenticated,\n      password: linkPassword,\n      enableAgreement,\n      isArchived,\n    } = link;\n\n    // If the link is expired, show a 404 page\n    if (expiresAt && new Date(expiresAt) < new Date()) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n      );\n    }\n\n    if (isArchived) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n      );\n    }\n\n    return (\n      <div className=\"h-screen w-full overflow-hidden\">\n        <DataroomView\n          link={link}\n          userEmail={verifiedEmail}\n          userId={null}\n          isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n          brand={brand}\n          previewToken={previewToken}\n          disableEditEmail={!!disableEditEmail}\n          useCustomAccessForm={props.useCustomAccessForm}\n          verifiedEmail={verifiedEmail}\n          isEmbedded\n        />\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "pages/view/[linkId]/index.tsx",
    "content": "import { GetStaticPropsContext } from \"next\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport WorkflowAccessView from \"@/ee/features/workflows/components/workflow-access-view\";\nimport NotFound from \"@/pages/404\";\nimport { Brand, DataroomBrand, DataroomDocument } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { useSession } from \"next-auth/react\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { parsePageId } from \"notion-utils\";\nimport z from \"zod\";\n\nimport { fetchLinkDataById } from \"@/lib/api/links/link-data\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport notion from \"@/lib/notion\";\nimport {\n  addSignedUrls,\n  fetchMissingPageReferences,\n  normalizeRecordMap,\n} from \"@/lib/notion/utils\";\nimport {\n  CustomUser,\n  LinkWithDataroom,\n  LinkWithDocument,\n  NotionTheme,\n} from \"@/lib/types\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport CustomMetaTag from \"@/components/view/custom-metatag\";\nimport DataroomView from \"@/components/view/dataroom/dataroom-view\";\nimport DocumentView from \"@/components/view/document-view\";\n\ntype DocumentLinkData = {\n  linkType: \"DOCUMENT_LINK\";\n  link: LinkWithDocument;\n  brand: Brand | null;\n};\n\ntype DataroomLinkData = {\n  linkType: \"DATAROOM_LINK\";\n  link: LinkWithDataroom;\n  brand: DataroomBrand | null;\n};\n\ntype WorkflowLinkData = {\n  linkType: \"WORKFLOW_LINK\";\n  entryLinkId: string;\n  brand: Brand | null;\n};\n\nexport interface ViewPageProps {\n  linkData: DocumentLinkData | DataroomLinkData | WorkflowLinkData;\n  notionData: {\n    rootNotionPageId: string | null;\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null;\n  };\n  meta: {\n    enableCustomMetatag: boolean;\n    metaTitle: string | null;\n    metaDescription: string | null;\n    metaImage: string | null;\n    metaUrl: string | null;\n    metaFavicon: string | null;\n  };\n  showPoweredByBanner: boolean;\n  showAccountCreationSlide: boolean;\n  useAdvancedExcelViewer: boolean;\n  useCustomAccessForm: boolean;\n  logoOnAccessForm: boolean;\n  dataroomIndexEnabled?: boolean;\n  annotationsEnabled?: boolean;\n  textSelectionEnabled?: boolean;\n}\n\nexport const getStaticProps = async (context: GetStaticPropsContext) => {\n  const { linkId: linkIdParam } = context.params as { linkId: string };\n\n  try {\n    const linkId = z.string().cuid().parse(linkIdParam);\n\n    // Fetch link data directly from database to avoid internal HTTP fetch\n    // which can be blocked by Vercel's edge protection (403 errors)\n    const result = await fetchLinkDataById({ linkId });\n\n    if (result.status !== \"ok\") {\n      return {\n        notFound: true,\n      };\n    }\n\n    const { linkType, link, brand } = result;\n\n    if (!linkType) {\n      return {\n        notFound: true,\n      };\n    }\n\n    // Handle workflow links - minimal props needed\n    if (linkType === \"WORKFLOW_LINK\") {\n      return {\n        props: {\n          linkData: {\n            linkType: \"WORKFLOW_LINK\",\n            entryLinkId: linkId,\n            brand: brand || null,\n          },\n          notionData: {\n            rootNotionPageId: null,\n            recordMap: null,\n            theme: null,\n          },\n          meta: {\n            enableCustomMetatag: false,\n            metaTitle: null,\n            metaDescription: null,\n            metaImage: null,\n            metaUrl: `https://www.papermark.com/view/${linkId}`,\n            metaFavicon: \"/favicon.ico\",\n          },\n          showPoweredByBanner: false,\n          showAccountCreationSlide: false,\n          useAdvancedExcelViewer: false,\n          useCustomAccessForm: false,\n          logoOnAccessForm: false,\n        },\n        revalidate: 60,\n      };\n    }\n\n    if (!link) {\n      return {\n        notFound: true,\n      };\n    }\n\n    // Manage the data for the document link\n    if (linkType === \"DOCUMENT_LINK\") {\n      let pageId = null;\n      let recordMap = null;\n      let theme = null;\n\n      const { type, file, ...versionWithoutTypeAndFile } =\n        link.document.versions[0];\n\n      if (type === \"notion\") {\n        try {\n          theme = new URL(file).searchParams.get(\"mode\");\n          const notionPageId = parsePageId(file, { uuid: false });\n          if (!notionPageId) {\n            return { notFound: true };\n          }\n\n          pageId = notionPageId;\n          recordMap = await notion.getPage(pageId, { signFileUrls: false });\n          // Fetch missing page references that are embedded in rich text (e.g., table cells with multiple page links)\n          await fetchMissingPageReferences(recordMap);\n          // Normalize double-nested block structures from the Notion API\n          normalizeRecordMap(recordMap);\n          await addSignedUrls({ recordMap });\n        } catch (notionError) {\n          const message =\n            notionError instanceof Error\n              ? notionError.message\n              : String(notionError);\n          console.error(\"Notion API error:\", message);\n          // Return a temporary error page instead of 404\n          return {\n            props: { notionError: true },\n            revalidate: 30,\n          };\n        }\n      }\n\n      const { team, teamId, advancedExcelEnabled, ...linkDocument } =\n        link.document;\n      const teamPlan = team?.plan || \"free\";\n\n      // Check feature flags for document links\n      const featureFlags = await getFeatureFlags({ teamId });\n      const annotationsEnabled = featureFlags.annotations;\n      const textSelectionEnabled = featureFlags.textSelection;\n\n      return {\n        props: {\n          linkData: {\n            linkType: \"DOCUMENT_LINK\",\n            link: {\n              ...link,\n              teamId: teamId,\n              document: {\n                ...linkDocument,\n                versions: [versionWithoutTypeAndFile],\n              },\n            },\n            brand,\n          },\n          notionData: {\n            rootNotionPageId: null, // do not pass rootNotionPageId to the client\n            recordMap,\n            theme,\n          },\n          meta: {\n            enableCustomMetatag: link.enableCustomMetatag || false,\n            metaTitle: link.metaTitle,\n            metaDescription: link.metaDescription,\n            metaImage: link.metaImage,\n            metaFavicon: link.metaFavicon ?? \"/favicon.ico\",\n            metaUrl: `https://www.papermark.com/view/${linkId}`,\n          },\n          showPoweredByBanner: link.showBanner || teamPlan === \"free\",\n          showAccountCreationSlide: link.showBanner || teamPlan === \"free\",\n          useAdvancedExcelViewer: advancedExcelEnabled,\n          useCustomAccessForm:\n            teamId === \"cm0154tiv0000lr2t6nr5c6kp\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\" ||\n            teamId === \"cm76hfyvy0002q623hmen99pf\" ||\n            teamId === \"cm9ztf0s70005js04i689gefn\" ||\n            teamId === \"cmk2hnmqh0000k304zcoezt6n\",\n          logoOnAccessForm:\n            teamId === \"cm7nlkrhm0000qgh0nvyrrywr\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\",\n          annotationsEnabled,\n          textSelectionEnabled,\n        },\n        revalidate: brand || recordMap ? 10 : 60,\n      };\n    }\n\n    // Manage the data for the dataroom link\n    if (linkType === \"DATAROOM_LINK\") {\n      // iterate the link.documents and extract type and file and rest of the props\n      let documents = [];\n      for (const document of link.dataroom.documents) {\n        const { file, updatedAt, ...versionWithoutTypeAndFile } =\n          document.document.versions[0];\n\n        const newDocument = {\n          ...document.document,\n          dataroomDocumentId: document.id,\n          folderId: document.folderId,\n          orderIndex: document.orderIndex,\n          hierarchicalIndex: document.hierarchicalIndex,\n          versions: [\n            {\n              ...versionWithoutTypeAndFile,\n              updatedAt:\n                document.updatedAt > updatedAt ? document.updatedAt : updatedAt, // use the latest updatedAt\n            },\n          ],\n        };\n\n        documents.push(newDocument);\n      }\n\n      const { teamId } = link.dataroom;\n\n      // Check feature flags\n      const featureFlags = await getFeatureFlags({ teamId });\n      const dataroomIndexEnabled = featureFlags.dataroomIndex;\n      const annotationsEnabled = featureFlags.annotations;\n      const textSelectionEnabled = featureFlags.textSelection;\n\n      const lastUpdatedAt = link.dataroom.documents.reduce(\n        (max: number, doc: any) => {\n          return Math.max(\n            max,\n            new Date(doc.document.versions[0].updatedAt).getTime(),\n          );\n        },\n        new Date(link.dataroom.createdAt).getTime(),\n      );\n\n      return {\n        props: {\n          linkData: {\n            linkType: \"DATAROOM_LINK\",\n            link: {\n              ...link,\n              teamId: teamId,\n              dataroom: {\n                ...link.dataroom,\n                documents,\n                lastUpdatedAt: lastUpdatedAt,\n              },\n            },\n            brand,\n          },\n          meta: {\n            enableCustomMetatag: link.enableCustomMetatag || false,\n            metaTitle: link.metaTitle,\n            metaDescription: link.metaDescription,\n            metaImage: link.metaImage,\n            metaFavicon: link.metaFavicon ?? \"/favicon.ico\",\n            metaUrl: `https://www.papermark.com/view/${linkId}`,\n          },\n          showPoweredByBanner: false,\n          showAccountCreationSlide: false,\n          useAdvancedExcelViewer: false, // INFO: this is managed in the API route\n          useCustomAccessForm:\n            teamId === \"cm0154tiv0000lr2t6nr5c6kp\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\" ||\n            teamId === \"cm76hfyvy0002q623hmen99pf\" ||\n            teamId === \"cm9ztf0s70005js04i689gefn\" ||\n            teamId === \"cmk2hnmqh0000k304zcoezt6n\",\n          logoOnAccessForm:\n            teamId === \"cm7nlkrhm0000qgh0nvyrrywr\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\",\n          dataroomIndexEnabled,\n          annotationsEnabled,\n          textSelectionEnabled,\n        },\n        revalidate: 10,\n      };\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.error(\"Fetching error:\", message);\n    return { props: { error: true }, revalidate: 30 };\n  }\n};\n\nexport async function getStaticPaths() {\n  return {\n    paths: [],\n    fallback: true,\n  };\n}\n\nexport default function ViewPage({\n  linkData,\n  notionData,\n  meta,\n  showPoweredByBanner,\n  showAccountCreationSlide,\n  useAdvancedExcelViewer,\n  useCustomAccessForm,\n  logoOnAccessForm,\n  dataroomIndexEnabled,\n  annotationsEnabled,\n  textSelectionEnabled,\n  error,\n  notionError,\n}: ViewPageProps & { error?: boolean; notionError?: boolean }) {\n  const router = useRouter();\n  const { data: session, status } = useSession();\n  const [storedToken, setStoredToken] = useState<string | undefined>(undefined);\n  const [storedEmail, setStoredEmail] = useState<string | undefined>(undefined);\n\n  useEffect(() => {\n    // Retrieve token from cookie on component mount\n    const cookieToken =\n      Cookies.get(\"pm_vft\") ||\n      Cookies.get(`pm_drs_flag_${router.query.linkId}`);\n    const storedEmail = window.localStorage.getItem(\"papermark.email\");\n    if (cookieToken) {\n      setStoredToken(cookieToken);\n      if (storedEmail) {\n        setStoredEmail(storedEmail.toLowerCase());\n      }\n    }\n  }, [router.query.linkId]);\n\n  if (router.isFallback) {\n    return (\n      <div className=\"flex h-screen items-center justify-center bg-black\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <NotFound message=\"Sorry, we had trouble loading this link. Please try refreshing.\" />\n    );\n  }\n\n  if (notionError) {\n    return (\n      <NotFound message=\"Sorry, we had trouble loading this link. Please try again in a moment.\" />\n    );\n  }\n\n  const {\n    email: verifiedEmail,\n    d: disableEditEmail,\n    previewToken,\n    preview,\n  } = router.query as {\n    email: string;\n    d: string;\n    previewToken?: string;\n    preview?: string;\n  };\n  const { linkType } = linkData;\n\n  // Render workflow access view for WORKFLOW_LINK\n  if (linkType === \"WORKFLOW_LINK\") {\n    const { entryLinkId, brand } = linkData as WorkflowLinkData;\n\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={false}\n          title=\"Access Workflow | Powered by Papermark\"\n          description={null}\n          imageUrl={null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <WorkflowAccessView entryLinkId={entryLinkId} brand={brand} />\n      </>\n    );\n  }\n\n  // Render the document view for DOCUMENT_LINK\n  if (linkType === \"DOCUMENT_LINK\") {\n    const { link, brand } = linkData as DocumentLinkData;\n\n    if (!linkData || status === \"loading\" || router.isFallback) {\n      return (\n        <>\n          <CustomMetaTag\n            favicon={meta.metaFavicon}\n            enableBranding={meta.enableCustomMetatag ?? false}\n            title={\n              meta.metaTitle ?? `${link?.document?.name} | Powered by Papermark`\n            }\n            description={meta.metaDescription ?? null}\n            imageUrl={meta.metaImage ?? null}\n            url={meta.metaUrl ?? \"\"}\n          />\n          <div className=\"flex h-screen items-center justify-center\">\n            <LoadingSpinner className=\"h-20 w-20\" />\n          </div>\n        </>\n      );\n    }\n\n    const {\n      expiresAt,\n      emailProtected,\n      emailAuthenticated,\n      password: linkPassword,\n      enableAgreement,\n      isArchived,\n    } = link;\n\n    const { email: userEmail, id: userId } =\n      (session?.user as CustomUser) || {};\n\n    // If the link is expired, show a 404 page\n    if (expiresAt && new Date(expiresAt) < new Date()) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n      );\n    }\n\n    if (isArchived) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n      );\n    }\n\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={meta.enableCustomMetatag ?? false}\n          title={\n            meta.metaTitle ?? `${link?.document?.name} | Powered by Papermark`\n          }\n          description={meta.metaDescription ?? null}\n          imageUrl={meta.metaImage ?? null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <DocumentView\n          link={link}\n          userEmail={verifiedEmail ?? storedEmail ?? userEmail}\n          userId={userId}\n          isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n          notionData={notionData}\n          brand={brand}\n          showPoweredByBanner={showPoweredByBanner}\n          showAccountCreationSlide={showAccountCreationSlide}\n          useAdvancedExcelViewer={useAdvancedExcelViewer}\n          previewToken={previewToken}\n          disableEditEmail={!!disableEditEmail}\n          useCustomAccessForm={useCustomAccessForm}\n          logoOnAccessForm={logoOnAccessForm}\n          token={storedToken}\n          verifiedEmail={verifiedEmail}\n          annotationsEnabled={annotationsEnabled}\n          textSelectionEnabled={textSelectionEnabled}\n        />\n      </>\n    );\n  }\n\n  // Render the dataroom view for DATAROOM_LINK\n  if (linkType === \"DATAROOM_LINK\") {\n    const { link, brand } = linkData as DataroomLinkData;\n\n    if (!link || status === \"loading\" || router.isFallback) {\n      return (\n        <>\n          <CustomMetaTag\n            favicon={meta.metaFavicon}\n            enableBranding={meta.enableCustomMetatag ?? false}\n            title={\n              meta.metaTitle ?? `${link?.dataroom?.name} | Powered by Papermark`\n            }\n            description={meta.metaDescription ?? null}\n            imageUrl={meta.metaImage ?? null}\n            url={meta.metaUrl ?? \"\"}\n          />\n          <div className=\"flex h-screen items-center justify-center\">\n            <LoadingSpinner className=\"h-20 w-20\" />\n          </div>\n        </>\n      );\n    }\n\n    const {\n      expiresAt,\n      emailProtected,\n      emailAuthenticated,\n      password: linkPassword,\n      enableAgreement,\n      isArchived,\n    } = link;\n\n    const { email: userEmail, id: userId } =\n      (session?.user as CustomUser) || {};\n\n    // If the link is expired, show a 404 page\n    if (expiresAt && new Date(expiresAt) < new Date()) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n      );\n    }\n\n    if (isArchived) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n      );\n    }\n\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={meta.enableCustomMetatag ?? false}\n          title={\n            meta.metaTitle ?? `${link?.dataroom?.name} | Powered by Papermark`\n          }\n          description={meta.metaDescription ?? null}\n          imageUrl={meta.metaImage ?? null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <DataroomView\n          link={link}\n          userEmail={verifiedEmail ?? storedEmail ?? userEmail}\n          verifiedEmail={verifiedEmail}\n          userId={userId}\n          isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n          brand={brand}\n          disableEditEmail={!!disableEditEmail}\n          useCustomAccessForm={useCustomAccessForm}\n          logoOnAccessForm={logoOnAccessForm}\n          token={storedToken}\n          previewToken={previewToken}\n          preview={!!preview}\n          dataroomIndexEnabled={dataroomIndexEnabled}\n          textSelectionEnabled={textSelectionEnabled}\n        />\n      </>\n    );\n  }\n}\n"
  },
  {
    "path": "pages/view/domains/[domain]/[slug]/d/[documentId].tsx",
    "content": "import { GetStaticPropsContext } from \"next\";\nimport { useRouter } from \"next/router\";\n\nimport React, { useEffect, useState } from \"react\";\n\nimport NotFound from \"@/pages/404\";\nimport { DataroomBrand } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { useSession } from \"next-auth/react\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { parsePageId } from \"notion-utils\";\nimport z from \"zod\";\n\nimport { fetchLinkDataByDomainSlug } from \"@/lib/api/links/link-data\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport notion from \"@/lib/notion\";\nimport {\n  addSignedUrls,\n  fetchMissingPageReferences,\n  normalizeRecordMap,\n} from \"@/lib/notion/utils\";\nimport { CustomUser, LinkWithDataroomDocument, NotionTheme } from \"@/lib/types\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport CustomMetaTag from \"@/components/view/custom-metatag\";\nimport DataroomDocumentView from \"@/components/view/dataroom/dataroom-document-view\";\n\ntype DataroomDocumentLinkData = {\n  linkType: \"DATAROOM_LINK\";\n  link: LinkWithDataroomDocument;\n  brand: DataroomBrand | null;\n};\n\ntype DataroomDocumentProps = {\n  linkData: DataroomDocumentLinkData;\n  notionData: {\n    rootNotionPageId: string | null;\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null;\n  };\n  meta: {\n    enableCustomMetatag: boolean;\n    metaTitle: string | null;\n    metaDescription: string | null;\n    metaImage: string | null;\n    metaFavicon: string;\n    metaUrl: string;\n  };\n  showPoweredByBanner: boolean;\n  showAccountCreationSlide: boolean;\n  useAdvancedExcelViewer: boolean;\n  useCustomAccessForm: boolean;\n  logoOnAccessForm: boolean;\n  textSelectionEnabled?: boolean;\n  error?: boolean;\n};\n\nexport default function DataroomDocumentViewPage({\n  linkData,\n  notionData,\n  meta,\n  showPoweredByBanner,\n  showAccountCreationSlide,\n  useAdvancedExcelViewer,\n  useCustomAccessForm,\n  logoOnAccessForm,\n  textSelectionEnabled,\n  error,\n}: DataroomDocumentProps) {\n  const router = useRouter();\n  const { data: session, status } = useSession();\n  const [storedToken, setStoredToken] = useState<string | undefined>(undefined);\n  const [storedEmail, setStoredEmail] = useState<string | undefined>(undefined);\n\n  useEffect(() => {\n    // Retrieve token from cookie on component mount\n    const cookieToken =\n      Cookies.get(\"pm_vft\") || Cookies.get(`pm_drs_flag_${router.query.slug}`);\n    const storedEmail = window.localStorage.getItem(\"papermark.email\");\n    if (cookieToken) {\n      setStoredToken(cookieToken);\n      if (storedEmail) {\n        setStoredEmail(storedEmail.toLowerCase());\n      }\n    }\n  }, [router.query.slug]);\n\n  if (router.isFallback) {\n    return (\n      <div className=\"flex h-screen items-center justify-center bg-black\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <NotFound message=\"Sorry, we had trouble loading this link. Please try again in a moment.\" />\n    );\n  }\n\n  const {\n    email: verifiedEmail,\n    d: disableEditEmail,\n    previewToken,\n    preview,\n  } = router.query as {\n    email: string;\n    d: string;\n    previewToken?: string;\n    preview?: string;\n  };\n  const { link, brand } = linkData;\n\n  // Render the document view for DATAROOM_LINK\n  if (!linkData || status === \"loading\" || router.isFallback) {\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={meta.enableCustomMetatag ?? false}\n          title={\n            meta.metaTitle ??\n            `${link?.dataroomDocument?.document?.name} | Powered by Papermark`\n          }\n          description={meta.metaDescription ?? null}\n          imageUrl={meta.metaImage ?? null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <div className=\"flex h-screen items-center justify-center\">\n          <LoadingSpinner className=\"h-20 w-20\" />\n        </div>\n      </>\n    );\n  }\n\n  const {\n    expiresAt,\n    emailProtected,\n    emailAuthenticated,\n    password: linkPassword,\n    enableAgreement,\n    isArchived,\n  } = link;\n\n  const { email: userEmail, id: userId } = (session?.user as CustomUser) || {};\n\n  // Check if the link is expired\n  if (expiresAt && new Date(expiresAt) < new Date()) {\n    return (\n      <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n    );\n  }\n\n  // Check if the link is archived\n  if (isArchived) {\n    return (\n      <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n    );\n  }\n\n  return (\n    <>\n      <CustomMetaTag\n        favicon={meta.metaFavicon}\n        enableBranding={meta.enableCustomMetatag ?? false}\n        title={\n          meta.metaTitle ??\n          `${link?.dataroomDocument?.document?.name} | Powered by Papermark`\n        }\n        description={meta.metaDescription ?? null}\n        imageUrl={meta.metaImage ?? null}\n        url={meta.metaUrl ?? \"\"}\n      />\n      <DataroomDocumentView\n        link={link}\n        userEmail={verifiedEmail ?? storedEmail ?? userEmail}\n        userId={userId}\n        isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n        notionData={notionData}\n        brand={brand}\n        useAdvancedExcelViewer={useAdvancedExcelViewer}\n        previewToken={previewToken}\n        disableEditEmail={!!disableEditEmail}\n        useCustomAccessForm={useCustomAccessForm}\n        token={storedToken}\n        verifiedEmail={verifiedEmail}\n        preview={!!preview}\n        logoOnAccessForm={logoOnAccessForm}\n        textSelectionEnabled={textSelectionEnabled}\n      />\n    </>\n  );\n}\n\nexport async function getStaticProps(context: GetStaticPropsContext) {\n  const {\n    domain: domainParam,\n    slug: slugParam,\n    documentId: documentIdParam,\n  } = context.params as {\n    domain: string;\n    slug: string;\n    documentId: string;\n  };\n\n  try {\n    const domain = z\n      .string()\n      .regex(/^([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$/)\n      .parse(domainParam);\n    const slug = z\n      .string()\n      .regex(/^[a-zA-Z0-9_-]+$/, \"Invalid path parameter\")\n      .parse(slugParam);\n    const documentId = z.string().cuid().parse(documentIdParam);\n\n    const result = await fetchLinkDataByDomainSlug({\n      domain,\n      slug,\n      dataroomDocumentId: documentId,\n    });\n    if (result.status !== \"ok\") {\n      return { notFound: true, revalidate: 10 };\n    }\n\n    const { linkType, link, brand } = result;\n\n    if (!link || !linkType) {\n      return { notFound: true, revalidate: 10 };\n    }\n\n    if (linkType !== \"DATAROOM_LINK\") {\n      return { notFound: true, revalidate: 10 };\n    }\n\n    let pageId = null;\n    let recordMap = null;\n    let theme = null;\n\n    const { type, file, ...versionWithoutTypeAndFile } =\n      link.dataroomDocument.document.versions[0];\n\n    if (type === \"notion\") {\n      theme = new URL(file).searchParams.get(\"mode\");\n      const notionPageId = parsePageId(file, { uuid: false });\n      if (!notionPageId) {\n        return {\n          notFound: true,\n          revalidate: 10,\n        };\n      }\n\n      pageId = notionPageId;\n      recordMap = await notion.getPage(pageId, { signFileUrls: false });\n      // Fetch missing page references that are embedded in rich text (e.g., table cells with multiple page links)\n      await fetchMissingPageReferences(recordMap);\n      // Normalize double-nested block structures from the Notion API\n      normalizeRecordMap(recordMap);\n      // TODO: separately sign the file urls until PR merged and published; ref: https://github.com/NotionX/react-notion-x/issues/580#issuecomment-2542823817\n      await addSignedUrls({ recordMap });\n    }\n\n    const { teamId, team, ...linkData } = link;\n\n    const { advancedExcelEnabled, ...linkDocument } =\n      linkData.dataroomDocument.document;\n\n    // Check feature flags\n    const featureFlags = await getFeatureFlags({ teamId: teamId || undefined });\n    const textSelectionEnabled = featureFlags.textSelection;\n\n    return {\n      props: {\n        linkData: {\n          linkType: \"DATAROOM_LINK\",\n          link: {\n            ...linkData,\n            teamId: teamId || null,\n            dataroomDocument: {\n              ...linkData.dataroomDocument,\n              document: {\n                ...linkDocument,\n                versions: [versionWithoutTypeAndFile],\n              },\n            },\n          },\n          brand,\n        },\n        notionData: {\n          rootNotionPageId: null, // do not pass rootNotionPageId to the client\n          recordMap,\n          theme,\n        },\n        meta: {\n          enableCustomMetatag: link.enableCustomMetatag || false,\n          metaTitle: link.metaTitle,\n          metaDescription: link.metaDescription,\n          metaImage: link.metaImage,\n          metaFavicon: link.metaFavicon ?? \"/favicon.ico\",\n          metaUrl: `https://${domain}/${slug}` || null,\n        },\n        showPoweredByBanner: false,\n        showAccountCreationSlide: false,\n        useAdvancedExcelViewer: advancedExcelEnabled,\n        useCustomAccessForm:\n          teamId === \"cm0154tiv0000lr2t6nr5c6kp\" ||\n          teamId === \"clup33by90000oewh4rfvp2eg\" ||\n          teamId === \"cm76hfyvy0002q623hmen99pf\" ||\n          teamId === \"cm9ztf0s70005js04i689gefn\" ||\n          teamId === \"cmk2hnmqh0000k304zcoezt6n\",\n        logoOnAccessForm: teamId === \"cm7nlkrhm0000qgh0nvyrrywr\",\n        textSelectionEnabled,\n      },\n      revalidate: brand || recordMap ? 10 : 60,\n    };\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.error(\"Fetching error:\", message);\n    return { props: { error: true }, revalidate: 30 };\n  }\n}\n\nexport async function getStaticPaths() {\n  return {\n    paths: [],\n    fallback: true,\n  };\n}\n"
  },
  {
    "path": "pages/view/domains/[domain]/[slug]/downloads.tsx",
    "content": "import { GetServerSideProps } from \"next\";\n\nimport prisma from \"@/lib/prisma\";\n\nimport { DownloadsPanel } from \"@/components/view/dataroom/downloads-panel\";\n\nexport const getServerSideProps: GetServerSideProps = async (context) => {\n  const domain = context.params?.domain as string;\n  const slug = context.params?.slug as string;\n  if (!domain || !slug) {\n    return { notFound: true };\n  }\n  const link = await prisma.link.findUnique({\n    where: {\n      domainSlug_slug: { slug, domainSlug: domain },\n    },\n    select: { id: true },\n  });\n  if (!link) {\n    return { notFound: true };\n  }\n  return { props: { linkId: link.id } };\n};\n\nexport default function DomainDownloadsPage({\n  linkId,\n}: {\n  linkId: string;\n}) {\n  return <DownloadsPanel linkId={linkId} />;\n}\n"
  },
  {
    "path": "pages/view/domains/[domain]/[slug]/index.tsx",
    "content": "import { GetStaticPropsContext } from \"next\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport WorkflowAccessView from \"@/ee/features/workflows/components/workflow-access-view\";\nimport NotFound from \"@/pages/404\";\nimport { Brand, DataroomBrand } from \"@prisma/client\";\nimport Cookies from \"js-cookie\";\nimport { useSession } from \"next-auth/react\";\nimport { ExtendedRecordMap } from \"notion-types\";\nimport { parsePageId } from \"notion-utils\";\nimport z from \"zod\";\n\nimport { fetchLinkDataByDomainSlug } from \"@/lib/api/links/link-data\";\nimport { getFeatureFlags } from \"@/lib/featureFlags\";\nimport notion from \"@/lib/notion\";\nimport {\n  addSignedUrls,\n  fetchMissingPageReferences,\n  normalizeRecordMap,\n} from \"@/lib/notion/utils\";\nimport {\n  CustomUser,\n  LinkWithDataroom,\n  LinkWithDocument,\n  NotionTheme,\n} from \"@/lib/types\";\n\nimport LoadingSpinner from \"@/components/ui/loading-spinner\";\nimport CustomMetaTag from \"@/components/view/custom-metatag\";\nimport DataroomView from \"@/components/view/dataroom/dataroom-view\";\nimport DocumentView from \"@/components/view/document-view\";\n\ntype DocumentLinkData = {\n  linkType: \"DOCUMENT_LINK\";\n  link: LinkWithDocument;\n  brand: Brand | null;\n};\n\ntype DataroomLinkData = {\n  linkType: \"DATAROOM_LINK\";\n  link: LinkWithDataroom;\n  brand: DataroomBrand | null;\n};\n\ntype WorkflowLinkData = {\n  linkType: \"WORKFLOW_LINK\";\n  entryLinkId: string;\n  domain: string;\n  slug: string;\n  brand: Brand | null;\n};\n\nexport const getStaticProps = async (context: GetStaticPropsContext) => {\n  const { domain: domainParam, slug: slugParam } = context.params as {\n    domain: string;\n    slug: string;\n  };\n\n  try {\n    const domain = z\n      .string()\n      .regex(/^([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}$/)\n      .parse(domainParam);\n    const slug = z\n      .string()\n      .regex(/^[a-zA-Z0-9_-]+$/, \"Invalid path parameter\")\n      .parse(slugParam);\n\n    const result = await fetchLinkDataByDomainSlug({ domain, slug });\n    if (result.status !== \"ok\") {\n      return {\n        notFound: true,\n        revalidate: 10,\n      };\n    }\n\n    const { linkType, link, brand, linkId } = result;\n\n    if (!linkType) {\n      return {\n        notFound: true,\n        revalidate: 10,\n      };\n    }\n\n    // Handle workflow links - minimal props needed\n    if (linkType === \"WORKFLOW_LINK\") {\n      return {\n        props: {\n          linkData: {\n            linkType: \"WORKFLOW_LINK\",\n            entryLinkId: linkId || \"\",\n            domain,\n            slug,\n            brand: brand || null,\n          },\n          notionData: {\n            rootNotionPageId: null,\n            recordMap: null,\n            theme: null,\n          },\n          meta: {\n            enableCustomMetatag: false,\n            metaTitle: null,\n            metaDescription: null,\n            metaImage: null,\n            metaUrl: `https://${domain}/${slug}`,\n            metaFavicon: \"/favicon.ico\",\n          },\n          showPoweredByBanner: false,\n          showAccountCreationSlide: false,\n          useAdvancedExcelViewer: false,\n          useCustomAccessForm: false,\n          logoOnAccessForm: false,\n        },\n        revalidate: 60,\n      };\n    }\n\n    if (!link) {\n      return {\n        notFound: true,\n        revalidate: 10,\n      };\n    }\n\n    // Manage the data for the document link\n    if (linkType === \"DOCUMENT_LINK\") {\n      let pageId = null;\n      let recordMap = null;\n      let theme = null;\n\n      const { type, file, ...versionWithoutTypeAndFile } =\n        link.document.versions[0];\n\n      if (type === \"notion\") {\n        theme = new URL(file).searchParams.get(\"mode\");\n        const notionPageId = parsePageId(file, { uuid: false });\n        if (!notionPageId) {\n          return {\n            notFound: true,\n            revalidate: 10,\n          };\n        }\n\n        pageId = notionPageId;\n        recordMap = await notion.getPage(pageId, { signFileUrls: false });\n        // Fetch missing page references that are embedded in rich text (e.g., table cells with multiple page links)\n        await fetchMissingPageReferences(recordMap);\n        // Normalize double-nested block structures from the Notion API\n        normalizeRecordMap(recordMap);\n        await addSignedUrls({ recordMap });\n      }\n\n      const { team, teamId, advancedExcelEnabled, ...linkDocument } =\n        link.document;\n      const teamPlan = team?.plan || \"free\";\n\n      // Check feature flags for document links\n      const docFeatureFlags = await getFeatureFlags({ teamId: teamId || undefined });\n      const textSelectionEnabled = docFeatureFlags.textSelection;\n\n      return {\n        props: {\n          linkData: {\n            linkType: \"DOCUMENT_LINK\",\n            link: {\n              ...link,\n              teamId: teamId || null,\n              document: {\n                ...linkDocument,\n                versions: [versionWithoutTypeAndFile],\n              },\n            },\n            brand,\n          },\n          notionData: {\n            rootNotionPageId: null, // do not pass rootNotionPageId to the client\n            recordMap,\n            theme,\n          },\n          meta: {\n            enableCustomMetatag: link.enableCustomMetatag || false,\n            metaTitle: link.metaTitle,\n            metaDescription: link.metaDescription,\n            metaImage: link.metaImage,\n            metaFavicon: link.metaFavicon || \"/favicon.ico\",\n            metaUrl: `https://${domain}/${slug}` || null,\n          },\n          showAccountCreationSlide: link.showBanner || teamPlan === \"free\",\n          useAdvancedExcelViewer: advancedExcelEnabled,\n          useCustomAccessForm:\n            teamId === \"cm0154tiv0000lr2t6nr5c6kp\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\" ||\n            teamId === \"cm76hfyvy0002q623hmen99pf\" ||\n            teamId === \"cm9ztf0s70005js04i689gefn\" ||\n            teamId === \"cmk2hnmqh0000k304zcoezt6n\",\n          logoOnAccessForm:\n            teamId === \"cm7nlkrhm0000qgh0nvyrrywr\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\",\n          textSelectionEnabled,\n        },\n        revalidate: 10,\n      };\n    }\n\n    // Manage the data for the dataroom link\n    if (linkType === \"DATAROOM_LINK\") {\n      // iterate the link.documents and extract type and file and rest of the props\n      let documents = [];\n      for (const document of link.dataroom.documents) {\n        const { file, updatedAt, ...versionWithoutTypeAndFile } =\n          document.document.versions[0];\n\n        const newDocument = {\n          ...document.document,\n          dataroomDocumentId: document.id,\n          folderId: document.folderId,\n          orderIndex: document.orderIndex,\n          hierarchicalIndex: document.hierarchicalIndex,\n          versions: [\n            {\n              ...versionWithoutTypeAndFile,\n              updatedAt:\n                document.updatedAt > updatedAt ? document.updatedAt : updatedAt, // use the latest updatedAt\n            },\n          ],\n        };\n\n        documents.push(newDocument);\n      }\n\n      const { teamId } = link.dataroom;\n\n      // Check feature flags\n      const featureFlags = await getFeatureFlags({ teamId });\n      const dataroomIndexEnabled = featureFlags.dataroomIndex;\n      const textSelectionEnabled = featureFlags.textSelection;\n\n      const lastUpdatedAt = link.dataroom.documents.reduce(\n        (max: number, doc: any) => {\n          return Math.max(\n            max,\n            new Date(doc.document.versions[0].updatedAt).getTime(),\n          );\n        },\n        new Date(link.dataroom.createdAt).getTime(),\n      );\n\n      return {\n        props: {\n          linkData: {\n            linkType: \"DATAROOM_LINK\",\n            link: {\n              ...link,\n              teamId: teamId || null,\n              dataroom: {\n                ...link.dataroom,\n                documents,\n                lastUpdatedAt: lastUpdatedAt,\n              },\n            },\n            brand,\n          },\n          meta: {\n            enableCustomMetatag: link.enableCustomMetatag || false,\n            metaTitle: link.metaTitle,\n            metaDescription: link.metaDescription,\n            metaImage: link.metaImage,\n            metaFavicon: link.metaFavicon || \"/favicon.ico\",\n            metaUrl: `https://${domain}/${slug}` || null,\n          },\n          showPoweredByBanner: false,\n          showAccountCreationSlide: false,\n          useAdvancedExcelViewer: false, // INFO: this is managed in the API route\n          useCustomAccessForm:\n            teamId === \"cm0154tiv0000lr2t6nr5c6kp\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\" ||\n            teamId === \"cm76hfyvy0002q623hmen99pf\" ||\n            teamId === \"cm9ztf0s70005js04i689gefn\" ||\n            teamId === \"cmk2hnmqh0000k304zcoezt6n\",\n          logoOnAccessForm:\n            teamId === \"cm7nlkrhm0000qgh0nvyrrywr\" ||\n            teamId === \"clup33by90000oewh4rfvp2eg\",\n          dataroomIndexEnabled,\n          textSelectionEnabled,\n        },\n        revalidate: 10,\n      };\n    }\n  } catch (error) {\n    const message = error instanceof Error ? error.message : String(error);\n    console.error(\"Fetching error:\", message);\n    return { props: { error: true }, revalidate: 30 };\n  }\n};\n\nexport async function getStaticPaths() {\n  return {\n    paths: [],\n    fallback: true,\n  };\n}\n\nexport default function ViewPage({\n  linkData,\n  notionData,\n  meta,\n  showAccountCreationSlide,\n  useAdvancedExcelViewer,\n  useCustomAccessForm,\n  logoOnAccessForm,\n  dataroomIndexEnabled,\n  textSelectionEnabled,\n  error,\n}: {\n  linkData: DocumentLinkData | DataroomLinkData | WorkflowLinkData;\n  notionData: {\n    rootNotionPageId: string | null;\n    recordMap: ExtendedRecordMap | null;\n    theme: NotionTheme | null;\n  };\n  meta: {\n    enableCustomMetatag: boolean;\n    metaTitle: string | null;\n    metaFavicon: string | null;\n    metaDescription: string | null;\n    metaImage: string | null;\n    metaUrl: string | null;\n  };\n  showAccountCreationSlide: boolean;\n  useAdvancedExcelViewer: boolean;\n  useCustomAccessForm: boolean;\n  logoOnAccessForm: boolean;\n  dataroomIndexEnabled?: boolean;\n  textSelectionEnabled?: boolean;\n  error?: boolean;\n}) {\n  const router = useRouter();\n  const { data: session, status } = useSession();\n  const [storedToken, setStoredToken] = useState<string | undefined>(undefined);\n  const [storedEmail, setStoredEmail] = useState<string | undefined>(undefined);\n\n  useEffect(() => {\n    // Retrieve token from cookie on component mount\n    const cookieToken =\n      Cookies.get(\"pm_vft\") || Cookies.get(`pm_drs_flag_${router.query.slug}`);\n    const storedEmail = window.localStorage.getItem(\"papermark.email\");\n    if (cookieToken) {\n      setStoredToken(cookieToken);\n      if (storedEmail) {\n        setStoredEmail(storedEmail.toLowerCase());\n      }\n    }\n  }, [router.query.slug]);\n\n  if (router.isFallback) {\n    return (\n      <div className=\"flex h-screen items-center justify-center bg-black\">\n        <LoadingSpinner className=\"h-20 w-20\" />\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <NotFound message=\"Sorry, we had trouble loading this link. Please try again in a moment.\" />\n    );\n  }\n\n  const {\n    email: verifiedEmail,\n    d: disableEditEmail,\n    previewToken,\n    preview,\n  } = router.query as {\n    email: string;\n    d: string;\n    previewToken?: string;\n    preview?: string;\n  };\n  const { linkType } = linkData;\n\n  // Render workflow access view for WORKFLOW_LINK\n  if (linkType === \"WORKFLOW_LINK\") {\n    const { entryLinkId, domain, slug, brand } = linkData as WorkflowLinkData;\n\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={false}\n          title=\"Access Workflow | Powered by Papermark\"\n          description={null}\n          imageUrl={null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <WorkflowAccessView\n          entryLinkId={entryLinkId}\n          domain={domain}\n          slug={slug}\n          brand={brand}\n        />\n      </>\n    );\n  }\n\n  // Render the document view for DOCUMENT_LINK\n  if (linkType === \"DOCUMENT_LINK\") {\n    const { link, brand } = linkData as DocumentLinkData;\n\n    if (!link || status === \"loading\") {\n      return (\n        <>\n          <CustomMetaTag\n            favicon={meta.metaFavicon}\n            enableBranding={meta.enableCustomMetatag ?? false}\n            title={\n              meta.metaTitle ?? `${link?.document?.name} | Powered by Papermark`\n            }\n            description={meta.metaDescription ?? null}\n            imageUrl={meta.metaImage ?? null}\n            url={meta.metaUrl ?? \"\"}\n          />\n          <div className=\"flex h-screen items-center justify-center\">\n            <LoadingSpinner className=\"h-20 w-20\" />\n          </div>\n        </>\n      );\n    }\n\n    const {\n      expiresAt,\n      emailProtected,\n      password: linkPassword,\n      enableAgreement,\n      isArchived,\n    } = link;\n\n    const { email: userEmail, id: userId } =\n      (session?.user as CustomUser) || {};\n\n    // If the link is expired, show a 404 page\n    if (expiresAt && new Date(expiresAt) < new Date()) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n      );\n    }\n\n    if (isArchived) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n      );\n    }\n\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={meta.enableCustomMetatag ?? false}\n          title={\n            meta.metaTitle ?? `${link?.document?.name} | Powered by Papermark`\n          }\n          description={meta.metaDescription ?? null}\n          imageUrl={meta.metaImage ?? null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <DocumentView\n          link={link}\n          userEmail={verifiedEmail ?? storedEmail ?? userEmail}\n          userId={userId}\n          isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n          notionData={notionData}\n          brand={brand}\n          showAccountCreationSlide={showAccountCreationSlide}\n          useAdvancedExcelViewer={useAdvancedExcelViewer}\n          previewToken={previewToken}\n          disableEditEmail={!!disableEditEmail}\n          useCustomAccessForm={useCustomAccessForm}\n          token={storedToken}\n          verifiedEmail={verifiedEmail}\n          logoOnAccessForm={logoOnAccessForm}\n          textSelectionEnabled={textSelectionEnabled}\n        />\n      </>\n    );\n  }\n\n  // Render the dataroom view for DATAROOM_LINK\n  if (linkType === \"DATAROOM_LINK\") {\n    const { link, brand } = linkData as DataroomLinkData;\n\n    if (!link || status === \"loading\" || router.isFallback) {\n      return (\n        <>\n          <CustomMetaTag\n            favicon={meta.metaFavicon}\n            enableBranding={meta.enableCustomMetatag ?? false}\n            title={\n              meta.metaTitle ?? `${link?.dataroom?.name} | Powered by Papermark`\n            }\n            description={meta.metaDescription ?? null}\n            imageUrl={meta.metaImage ?? null}\n            url={meta.metaUrl ?? \"\"}\n          />\n          <div className=\"flex h-screen items-center justify-center\">\n            <LoadingSpinner className=\"h-20 w-20\" />\n          </div>\n        </>\n      );\n    }\n\n    const {\n      expiresAt,\n      emailProtected,\n      emailAuthenticated,\n      password: linkPassword,\n      enableAgreement,\n      isArchived,\n    } = link;\n\n    const { email: userEmail, id: userId } =\n      (session?.user as CustomUser) || {};\n\n    // If the link is expired, show a 404 page\n    if (expiresAt && new Date(expiresAt) < new Date()) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is expired.\" />\n      );\n    }\n\n    if (isArchived) {\n      return (\n        <NotFound message=\"Sorry, the link you're looking for is archived.\" />\n      );\n    }\n\n    return (\n      <>\n        <CustomMetaTag\n          favicon={meta.metaFavicon}\n          enableBranding={meta.enableCustomMetatag ?? false}\n          title={\n            meta.metaTitle ?? `${link?.dataroom?.name} | Powered by Papermark`\n          }\n          description={meta.metaDescription ?? null}\n          imageUrl={meta.metaImage ?? null}\n          url={meta.metaUrl ?? \"\"}\n        />\n        <DataroomView\n          link={link}\n          userEmail={verifiedEmail ?? storedEmail ?? userEmail}\n          userId={userId}\n          isProtected={!!(emailProtected || linkPassword || enableAgreement)}\n          brand={brand}\n          disableEditEmail={!!disableEditEmail}\n          useCustomAccessForm={useCustomAccessForm}\n          token={storedToken}\n          verifiedEmail={verifiedEmail}\n          previewToken={previewToken}\n          preview={!!preview}\n          logoOnAccessForm={logoOnAccessForm}\n          dataroomIndexEnabled={dataroomIndexEnabled}\n          textSelectionEnabled={textSelectionEnabled}\n        />\n      </>\n    );\n  }\n}\n"
  },
  {
    "path": "pages/visitors/[id]/index.tsx",
    "content": "import ErrorPage from \"next/error\";\nimport Link from \"next/link\";\nimport { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useViewer from \"@/lib/swr/use-viewer\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport {\n  Breadcrumb,\n  BreadcrumbItem,\n  BreadcrumbLink,\n  BreadcrumbList,\n  BreadcrumbPage,\n  BreadcrumbSeparator,\n} from \"@/components/ui/breadcrumb\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { ContactsDocumentsTable } from \"@/components/visitors/contacts-document-table\";\nimport { VisitorAvatar } from \"@/components/visitors/visitor-avatar\";\n\nexport default function VisitorDetailPage() {\n  const router = useRouter();\n  const { isFree, isTrial } = usePlan();\n  const [currentPage, setCurrentPage] = useState(1);\n  const [pageSize, setPageSize] = useState(10);\n  const [sortBy, setSortBy] = useState(\"lastViewed\");\n  const [sortOrder, setSortOrder] = useState(\"desc\");\n\n  const { viewer, durations, loadingDurations, error } = useViewer(\n    currentPage,\n    pageSize,\n    sortBy,\n    sortOrder,\n  );\n  const views = viewer?.views;\n  const pagination = viewer?.pagination;\n  const sorting = viewer?.sorting;\n\n  const handlePageChange = (page: number) => {\n    setCurrentPage(page);\n  };\n\n  const handlePageSizeChange = (size: number) => {\n    setPageSize(size);\n    setCurrentPage(1);\n  };\n\n  const handleSortChange = (newSortBy: string, newSortOrder: string) => {\n    setSortBy(newSortBy);\n    setSortOrder(newSortOrder);\n    setCurrentPage(1);\n  };\n\n  useEffect(() => {\n    if (isFree && !isTrial) router.push(\"/documents\");\n  }, [isTrial, isFree]);\n\n  if (error) {\n    return <ErrorPage statusCode={404} />;\n  }\n\n  return (\n    <AppLayout>\n      <div className=\"p-4 pb-0 sm:m-4 sm:py-4\">\n        {viewer ? (\n          <section className=\"mb-4 flex flex-col justify-between md:mb-8 lg:mb-12\">\n            <div className=\"mt-2 flex items-center gap-x-2\">\n              <VisitorAvatar viewerEmail={viewer.email} />\n\n              <h2 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n                {viewer.email}\n              </h2>\n            </div>\n          </section>\n        ) : (\n          <VisitorDetailHeaderSkeleton />\n        )}\n\n        <Separator className=\"bg-gray-200 dark:bg-gray-800\" />\n      </div>\n\n      <div className=\"relative p-4 pt-0 sm:mx-4 sm:mt-4\">\n        <ContactsDocumentsTable\n          views={views}\n          durations={durations}\n          loadingDurations={loadingDurations}\n          pagination={pagination}\n          sorting={sorting}\n          onPageChange={handlePageChange}\n          onPageSizeChange={handlePageSizeChange}\n          onSortChange={handleSortChange}\n        />\n      </div>\n    </AppLayout>\n  );\n}\n\nconst VisitorDetailHeaderSkeleton = () => {\n  return (\n    <section className=\"mb-4 flex flex-col justify-between md:mb-8 lg:mb-12\">\n      <Breadcrumb className=\"hidden md:flex\">\n        <BreadcrumbList>\n          <BreadcrumbItem>\n            <BreadcrumbLink asChild>\n              <Link href=\"/visitors\">All Visitors</Link>\n            </BreadcrumbLink>\n          </BreadcrumbItem>\n          <BreadcrumbSeparator />\n          <BreadcrumbItem>\n            <Skeleton className=\"h-6 w-24 rounded-md\" />\n          </BreadcrumbItem>\n        </BreadcrumbList>\n      </Breadcrumb>\n      <div className=\"mt-2 flex items-center gap-x-2\">\n        <Skeleton className=\"hidden h-10 w-10 flex-shrink-0 rounded-full sm:inline-flex\" />\n        <Skeleton className=\"h-8 w-48 rounded-md sm:w-64\" />\n      </div>\n    </section>\n  );\n};\n"
  },
  {
    "path": "pages/visitors/index.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useState } from \"react\";\n\nimport { usePlan } from \"@/lib/swr/use-billing\";\nimport useViewers from \"@/lib/swr/use-viewers\";\n\nimport AppLayout from \"@/components/layouts/app\";\nimport { SearchBoxPersisted } from \"@/components/search-box\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { ContactsTable } from \"@/components/visitors/contacts-table\";\nimport { VisitorGroupsSection } from \"@/components/visitors/visitor-groups-section\";\n\nexport default function Visitors() {\n  const router = useRouter();\n  const { isFree, isTrial } = usePlan();\n  const [currentPage, setCurrentPage] = useState(1);\n  const [pageSize, setPageSize] = useState(10);\n  const [sortBy, setSortBy] = useState(\"lastViewed\");\n  const [sortOrder, setSortOrder] = useState(\"desc\");\n  const [activeTab, setActiveTab] = useState(\n    (router.query.tab as string) || \"visitors\",\n  );\n\n  const { viewers, pagination, isValidating } = useViewers(\n    currentPage,\n    pageSize,\n    sortBy,\n    sortOrder,\n  );\n\n  const handlePageChange = (page: number) => {\n    setCurrentPage(page);\n  };\n\n  const handlePageSizeChange = (size: number) => {\n    setPageSize(size);\n    setCurrentPage(1);\n  };\n\n  const handleSortChange = (newSortBy: string, newSortOrder: string) => {\n    setSortBy(newSortBy);\n    setSortOrder(newSortOrder);\n    setCurrentPage(1);\n  };\n\n  useEffect(() => {\n    setCurrentPage(1);\n  }, [router.query.search]);\n\n  useEffect(() => {\n    if (isFree && !isTrial) router.push(\"/documents\");\n  }, [isTrial, isFree]);\n\n  useEffect(() => {\n    if (router.query.tab) {\n      setActiveTab(router.query.tab as string);\n    }\n  }, [router.query.tab]);\n\n  const handleTabChange = (value: string) => {\n    setActiveTab(value);\n    router.push(\n      { pathname: router.pathname, query: { ...router.query, tab: value } },\n      undefined,\n      { shallow: true },\n    );\n  };\n\n  return (\n    <AppLayout>\n      <div className=\"p-4 pb-0 sm:m-4 sm:py-4\">\n        <section className=\"mb-4 flex items-center justify-between md:mb-8 lg:mb-12\">\n          <div className=\"space-y-1\">\n            <h2 className=\"text-xl font-semibold tracking-tight text-foreground sm:text-2xl\">\n              All visitors\n            </h2>\n            <p className=\"text-xs text-muted-foreground sm:text-sm\">\n              See all your visitors and manage visitor groups.\n            </p>\n          </div>\n        </section>\n\n        <Tabs value={activeTab} onValueChange={handleTabChange}>\n          <TabsList className=\"mb-4\">\n            <TabsTrigger value=\"visitors\">Visitors</TabsTrigger>\n            <TabsTrigger value=\"groups\">Visitor Groups</TabsTrigger>\n          </TabsList>\n\n          <TabsContent value=\"visitors\">\n            <div className=\"mb-2 flex justify-end gap-x-2\">\n              <div className=\"relative w-full sm:max-w-xs\">\n                <SearchBoxPersisted\n                  loading={isValidating}\n                  placeholder=\"Search visitors...\"\n                  inputClassName=\"h-10\"\n                />\n              </div>\n            </div>\n\n            <Separator className=\"bg-gray-200 dark:bg-gray-800\" />\n\n            <div className=\"relative pt-4\">\n              <ContactsTable\n                viewers={viewers}\n                pagination={pagination}\n                sorting={{ sortBy, sortOrder }}\n                onPageChange={handlePageChange}\n                onPageSizeChange={handlePageSizeChange}\n                onSortChange={handleSortChange}\n              />\n            </div>\n          </TabsContent>\n\n          <TabsContent value=\"groups\">\n            <VisitorGroupsSection />\n          </TabsContent>\n        </Tabs>\n      </div>\n    </AppLayout>\n  );\n}"
  },
  {
    "path": "pages/welcome.tsx",
    "content": "import { useRouter } from \"next/router\";\n\nimport { useEffect, useRef } from \"react\";\nimport { useState } from \"react\";\n\nimport DataroomTemplates from \"@/ee/features/templates/components/dataroom-templates\";\nimport { sendGTMEvent } from \"@next/third-parties/google\";\nimport { ArrowLeft as ArrowLeftIcon } from \"lucide-react\";\nimport { AnimatePresence } from \"motion/react\";\nimport { useSession } from \"next-auth/react\";\n\nimport { CustomUser } from \"@/lib/types\";\nimport { cn } from \"@/lib/utils\";\n\nimport { GTMComponent } from \"@/components/gtm-component\";\nimport { Button } from \"@/components/ui/button\";\nimport Dataroom from \"@/components/welcome/dataroom\";\nimport DataroomAIGenerate from \"@/components/welcome/dataroom-ai-generate\";\nimport DataroomChoice from \"@/components/welcome/dataroom-choice\";\nimport DataroomTrial from \"@/components/welcome/dataroom-trial\";\nimport DataroomUpload from \"@/components/welcome/dataroom-upload\";\nimport Intro from \"@/components/welcome/intro\";\nimport Next from \"@/components/welcome/next\";\nimport NotionForm from \"@/components/welcome/notion-form\";\nimport Select from \"@/components/welcome/select\";\nimport Upload from \"@/components/welcome/upload\";\n\nexport default function Welcome() {\n  const router = useRouter();\n  const [showSkipButtons, setShowSkipButtons] = useState(false);\n  const { data: session } = useSession();\n  const signupEventSent = useRef(false);\n\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      setShowSkipButtons(true);\n    }, 10000);\n\n    return () => clearTimeout(timer);\n  }, []);\n\n  // Track signup for new users when welcome page loads (with deduplication)\n  useEffect(() => {\n    const user = session?.user as CustomUser;\n\n    if (user?.createdAt && !signupEventSent.current) {\n      // Check if user was created within the last 10 seconds (indicating new signup)\n      const isNewUser = new Date(user.createdAt).getTime() > Date.now() - 10000;\n\n      if (isNewUser) {\n        sendGTMEvent({ event: \"signup\" });\n        signupEventSent.current = true;\n      }\n    }\n  }, [session]);\n\n  const isDataroomUpload = router.query.type === \"dataroom-upload\";\n  const isDataroomChoice = router.query.type === \"dataroom-choice\";\n  const isDataroomTemplates = router.query.type === \"dataroom-templates\";\n  const isDataroomAIGenerate = router.query.type === \"dataroom-ai-generate\";\n\n  const skipButtonText =\n    isDataroomUpload || isDataroomChoice || isDataroomTemplates || isDataroomAIGenerate\n      ? \"Skip to dataroom\"\n      : \"Skip to dashboard\";\n  const skipButtonPath =\n    (isDataroomUpload || isDataroomChoice || isDataroomTemplates) &&\n    router.query.dataroomId\n      ? `/datarooms/${router.query.dataroomId}`\n      : \"/documents\";\n\n  return (\n    <>\n      <GTMComponent />\n      <div className=\"mx-auto flex h-screen max-w-3xl flex-col items-center justify-center overflow-x-hidden\">\n        <AnimatePresence mode=\"wait\">\n          {router.query.type ? (\n            <>\n              <button\n                className=\"group absolute left-2 top-10 z-40 rounded-full p-2 transition-all hover:bg-gray-400 sm:left-10\"\n                onClick={() => router.back()}\n              >\n                <ArrowLeftIcon className=\"h-8 w-8 text-gray-500 group-hover:text-gray-800 group-active:scale-90\" />\n              </button>\n\n              <Button\n                variant={\"link\"}\n                onClick={() => router.push(skipButtonPath)}\n                className={cn(\n                  \"absolute right-2 top-10 z-40 p-2 text-muted-foreground sm:right-10\",\n                  showSkipButtons ? \"block\" : \"hidden\",\n                )}\n              >\n                {skipButtonText}\n              </Button>\n            </>\n          ) : (\n            <Intro key=\"intro\" />\n          )}\n          {router.query.type === \"next\" && <Next key=\"next\" />}\n          {router.query.type === \"select\" && <Select key=\"select\" />}\n          {router.query.type === \"pitchdeck\" && <Upload key=\"pitchdeck\" />}\n          {router.query.type === \"document\" && <Upload key=\"document\" />}\n          {router.query.type === \"sales-document\" && (\n            <Upload key=\"sales-document\" />\n          )}\n          {router.query.type === \"notion\" && <NotionForm key=\"notion\" />}\n          {router.query.type === \"dataroom\" && <Dataroom key=\"dataroom\" />}\n          {router.query.type === \"dataroom-trial\" && (\n            <DataroomTrial key=\"dataroom-trial\" />\n          )}\n          {router.query.type === \"dataroom-choice\" &&\n            router.query.dataroomId && (\n              <DataroomChoice\n                key=\"dataroom-choice\"\n                dataroomId={router.query.dataroomId as string}\n              />\n            )}\n          {router.query.type === \"dataroom-templates\" &&\n            router.query.dataroomId && (\n              <DataroomTemplates\n                key=\"dataroom-templates\"\n                dataroomId={router.query.dataroomId as string}\n              />\n            )}\n          {router.query.type === \"dataroom-upload\" &&\n            router.query.dataroomId && (\n              <DataroomUpload\n                key=\"dataroom-upload\"\n                dataroomId={router.query.dataroomId as string}\n              />\n            )}\n          {router.query.type === \"dataroom-ai-generate\" && (\n            <DataroomAIGenerate key=\"dataroom-ai-generate\" />\n          )}\n        </AnimatePresence>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "pages/workflows/[id].tsx",
    "content": "import WorkflowDetail from \"@/ee/features/workflows/pages/workflow-detail\";\n\nexport default function WorkflowDetailPage() {\n  return <WorkflowDetail />;\n}\n"
  },
  {
    "path": "pages/workflows/index.tsx",
    "content": "import WorkflowOverview from \"@/ee/features/workflows/pages/workflow-overview\";\n\nexport default function WorkflowOverviewPage() {\n  return <WorkflowOverview />;\n}\n"
  },
  {
    "path": "pages/workflows/new.tsx",
    "content": "import WorkflowNew from \"@/ee/features/workflows/pages/workflow-new\";\n\nexport default function WorkflowNewPage() {\n  return <WorkflowNew />;\n}\n"
  },
  {
    "path": "pkgx.yaml",
    "content": "dependencies: |\n  pipenv.pypa.io@2023.9.1\n  python@3.11\n  node@22\n  npm@11\n"
  },
  {
    "path": "postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "prettier.config.js",
    "content": "module.exports = {\n  semi: true,\n  singleQuote: false,\n  jsxSingleQuote: false,\n  bracketSpacing: true,\n  singleAttributePerLine: false,\n  bracketSameLine: false,\n  tabWidth: 2,\n  useTabs: false,\n  trailingComma: \"all\",\n  printWidth: 80,\n  quoteProps: \"as-needed\",\n  arrowParens: \"always\",\n  endOfLine: \"lf\",\n  proseWrap: \"preserve\",\n  importOrder: [\n    \"^(next/(.*)$)|^(next$)\",\n    \"^(react/(.*)$)|^(react$)\",\n    \"<THIRD_PARTY_MODULES>\",\n    \"^@/lib/(.*)$\",\n    \"^@/components/(.*)$|^components/(.*)$\",\n    \"^[./]\",\n    \"^@/styles/(.*)$\",\n  ],\n  importOrderSeparation: true,\n  importOrderSortSpecifiers: true,\n  plugins: [\n    \"@trivago/prettier-plugin-sort-imports\",\n    \"prettier-plugin-tailwindcss\",\n  ],\n};\n"
  },
  {
    "path": "prisma/README.md",
    "content": "## Create migrations after changes in schema.prisma\n\nSo you have made changes to the schema.prisma file and you want to apply those changes to the database.\n\nChances are you are getting this error from Prisma after running `npx prisma migrate dev`:\n\n```txt\nDrift detected: Your database schema is not in sync with your migration history.\n...\nDo you want to continue? All data will be lost.\n```\n\n### Requirements\n\n1. Local prisma\n\n```bash\nnpm install -g prisma\n# npx prisma won't work\n```\n\n2. shadow database, otherwise you overwrite your local db\n\n### Steps\n\n- Create a new migration folder\n\n```bash\nmkdir -p prisma/migrations/20240408000000_add_model\n```\n\n- Generate the migration\n\n```bash\nprisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url \"postgresql://<USER>@localhost:5432/papermark-shadow-db\" --script > prisma/migrations/20240408000000_add_model/migration.sql\n```\n\n- Apply the migration\n\n```bash\nprisma migrate resolve --applied 20240408000000_add_model\n```\n"
  },
  {
    "path": "prisma/add-migration.sh",
    "content": "#!/bin/bash\n\n# Default values\nNAME=\"\"\nUSER=\"\"\n\n# Parse command line arguments\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        --name)\n            NAME=\"$2\"\n            shift 2\n            ;;\n        --user)\n            USER=\"$2\"\n            shift 2\n            ;;\n        *)\n            echo \"Unknown parameter: $1\"\n            exit 1\n            ;;\n    esac\ndone\n\n# Validate required parameters\nif [ -z \"$NAME\" ]; then\n    echo \"Please provide a migration name\"\n    echo \"Usage: ./add-migration.sh --name <migration_name> --user <database_user>\"\n    exit 1\nfi\n\nif [ -z \"$USER\" ]; then\n    echo \"Please provide a database user\"\n    echo \"Usage: ./add-migration.sh --name <migration_name> --user <database_user>\"\n    exit 1\nfi\n\n# Get current date in YYYYMMDD000000 format (midnight of current day)\ndate=$(date +%Y%m%d)000000\n\n# Create migration name with date prefix\nmigration_name=\"${date}_${NAME}\"\n\n# Create migration directory\nmkdir -p \"prisma/migrations/${migration_name}\" || {\n    echo \"Failed to create migration directory\"\n    exit 1\n}\n\n# Generate migration\nnpx prisma migrate diff \\\n    --from-migrations prisma/migrations \\\n    --to-schema-datasource prisma/schema \\\n    --shadow-database-url \"postgresql://${USER}@localhost:5432/papermark-shadow-db\" \\\n    --script > \"prisma/migrations/${migration_name}/migration.sql\" || {\n    echo \"Failed to generate migration\"\n    exit 1\n}\n\n# Apply migration\nnpx prisma migrate resolve --applied \"${migration_name}\" || {\n    echo \"Failed to apply migration\"\n    exit 1\n}\n\necho \"Migration ${migration_name} created and applied successfully\"\n\n\n\n"
  },
  {
    "path": "prisma/migrations/20230912150657_initialize/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"Account\" (\n    \"id\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"provider\" TEXT NOT NULL,\n    \"providerAccountId\" TEXT NOT NULL,\n    \"refresh_token\" TEXT,\n    \"access_token\" TEXT,\n    \"expires_at\" INTEGER,\n    \"token_type\" TEXT,\n    \"scope\" TEXT,\n    \"id_token\" TEXT,\n    \"session_state\" TEXT,\n\n    CONSTRAINT \"Account_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"Session\" (\n    \"id\" TEXT NOT NULL,\n    \"sessionToken\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"expires\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Session_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"User\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT,\n    \"email\" TEXT,\n    \"emailVerified\" TIMESTAMP(3),\n    \"image\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"plan\" TEXT NOT NULL DEFAULT 'free',\n    \"stripeId\" TEXT,\n    \"subscriptionId\" TEXT,\n    \"startsAt\" TIMESTAMP(3),\n    \"endsAt\" TIMESTAMP(3),\n\n    CONSTRAINT \"User_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"VerificationToken\" (\n    \"identifier\" TEXT NOT NULL,\n    \"token\" TEXT NOT NULL,\n    \"expires\" TIMESTAMP(3) NOT NULL\n);\n\n-- CreateTable\nCREATE TABLE \"Document\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"file\" TEXT NOT NULL,\n    \"type\" TEXT,\n    \"numPages\" INTEGER,\n    \"ownerId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Document_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"Link\" (\n    \"id\" TEXT NOT NULL,\n    \"documentId\" TEXT NOT NULL,\n    \"url\" TEXT,\n    \"name\" TEXT,\n    \"slug\" TEXT,\n    \"expiresAt\" TIMESTAMP(3),\n    \"password\" TEXT,\n    \"allowedEmails\" TEXT[],\n    \"emailProtected\" BOOLEAN NOT NULL DEFAULT true,\n    \"isArchived\" BOOLEAN NOT NULL DEFAULT false,\n    \"domainId\" TEXT,\n    \"domainSlug\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Link_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"Domain\" (\n    \"id\" TEXT NOT NULL,\n    \"slug\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"verified\" BOOLEAN NOT NULL DEFAULT false,\n    \"lastChecked\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Domain_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"View\" (\n    \"id\" TEXT NOT NULL,\n    \"linkId\" TEXT NOT NULL,\n    \"documentId\" TEXT NOT NULL,\n    \"viewerEmail\" TEXT,\n    \"viewedAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"View_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Account_provider_providerAccountId_key\" ON \"Account\"(\"provider\", \"providerAccountId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Session_sessionToken_key\" ON \"Session\"(\"sessionToken\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"User_email_key\" ON \"User\"(\"email\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"User_stripeId_key\" ON \"User\"(\"stripeId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"User_subscriptionId_key\" ON \"User\"(\"subscriptionId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"VerificationToken_token_key\" ON \"VerificationToken\"(\"token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"VerificationToken_identifier_token_key\" ON \"VerificationToken\"(\"identifier\", \"token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Link_url_key\" ON \"Link\"(\"url\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Link_domainSlug_slug_key\" ON \"Link\"(\"domainSlug\", \"slug\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Domain_slug_key\" ON \"Domain\"(\"slug\");\n\n-- AddForeignKey\nALTER TABLE \"Account\" ADD CONSTRAINT \"Account_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Session\" ADD CONSTRAINT \"Session_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Document\" ADD CONSTRAINT \"Document_ownerId_fkey\" FOREIGN KEY (\"ownerId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_domainId_fkey\" FOREIGN KEY (\"domainId\") REFERENCES \"Domain\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Domain\" ADD CONSTRAINT \"Domain_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"View\" ADD CONSTRAINT \"View_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE RESTRICT ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"View\" ADD CONSTRAINT \"View_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/202310122339_NewColumnInLinkTable/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"allowDownload\" BOOLEAN DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20231013165123_create_document_version/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"DocumentVersion\" (\n    \"id\" TEXT NOT NULL,\n    \"versionNumber\" INTEGER NOT NULL,\n    \"documentId\" TEXT NOT NULL,\n    \"file\" TEXT NOT NULL,\n    \"type\" TEXT,\n    \"numPages\" INTEGER,\n    \"isPrimary\" BOOLEAN NOT NULL DEFAULT false,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"DocumentVersion_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- AddForeignKey\nALTER TABLE \"DocumentVersion\" ADD CONSTRAINT \"DocumentVersion_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20231014200337_create_document_pages/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[versionNumber,documentId]` on the table `DocumentVersion` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"hasPages\" BOOLEAN NOT NULL DEFAULT false;\n\n-- CreateTable\nCREATE TABLE \"DocumentPage\" (\n    \"id\" TEXT NOT NULL,\n    \"versionId\" TEXT NOT NULL,\n    \"pageNumber\" INTEGER NOT NULL,\n    \"file\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"DocumentPage_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"DocumentPage_pageNumber_versionId_key\" ON \"DocumentPage\"(\"pageNumber\", \"versionId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"DocumentVersion_versionNumber_documentId_key\" ON \"DocumentVersion\"(\"versionNumber\", \"documentId\");\n\n-- AddForeignKey\nALTER TABLE \"DocumentPage\" ADD CONSTRAINT \"DocumentPage_versionId_fkey\" FOREIGN KEY (\"versionId\") REFERENCES \"DocumentVersion\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/202310311254_NewColumnEnableNotificationLinkTable/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableNotification\" BOOLEAN DEFAULT true;\n\n"
  },
  {
    "path": "prisma/migrations/20231105152632_create_team/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"Role\" AS ENUM ('ADMIN', 'MEMBER');\n\n-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"teamId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"Domain\" ADD COLUMN     \"teamId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"User\" ALTER COLUMN \"plan\" SET DEFAULT 'trial';\n\n-- CreateTable\nCREATE TABLE \"Team\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"plan\" TEXT NOT NULL DEFAULT 'free',\n    \"stripeId\" TEXT,\n    \"subscriptionId\" TEXT,\n    \"startsAt\" TIMESTAMP(3),\n    \"endsAt\" TIMESTAMP(3),\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Team_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"UserTeam\" (\n    \"role\" \"Role\" NOT NULL DEFAULT 'MEMBER',\n    \"userId\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n\n    CONSTRAINT \"UserTeam_pkey\" PRIMARY KEY (\"userId\",\"teamId\")\n);\n\n-- CreateTable\nCREATE TABLE \"Invitation\" (\n    \"email\" TEXT NOT NULL,\n    \"expires\" TIMESTAMP(3) NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"token\" TEXT NOT NULL\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Team_stripeId_key\" ON \"Team\"(\"stripeId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Team_subscriptionId_key\" ON \"Team\"(\"subscriptionId\");\n\n-- CreateIndex\nCREATE INDEX \"UserTeam_userId_idx\" ON \"UserTeam\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"UserTeam_teamId_idx\" ON \"UserTeam\"(\"teamId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Invitation_token_key\" ON \"Invitation\"(\"token\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Invitation_email_key\" ON \"Invitation\"(\"email\");\n\n-- AddForeignKey\nALTER TABLE \"UserTeam\" ADD CONSTRAINT \"UserTeam_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"UserTeam\" ADD CONSTRAINT \"UserTeam_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Document\" ADD CONSTRAINT \"Document_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Domain\" ADD CONSTRAINT \"Domain_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Invitation\" ADD CONSTRAINT \"Invitation_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20231113051339_create_sent_email/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"EmailType\" AS ENUM ('FIRST_DAY_DOMAIN_REMINDER_EMAIL', 'FIRST_DOMAIN_INVALID_EMAIL', 'SECOND_DOMAIN_INVALID_EMAIL', 'FIRST_TRIAL_END_REMINDER_EMAIL', 'FINAL_TRIAL_END_REMINDER_EMAIL');\n\n-- CreateTable\nCREATE TABLE \"SentEmail\" (\n    \"id\" TEXT NOT NULL,\n    \"type\" \"EmailType\" NOT NULL,\n    \"recipient\" TEXT NOT NULL,\n    \"marketing\" BOOLEAN NOT NULL DEFAULT false,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"teamId\" TEXT NOT NULL,\n\n    CONSTRAINT \"SentEmail_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"SentEmail_teamId_idx\" ON \"SentEmail\"(\"teamId\");\n\n-- AddForeignKey\nALTER TABLE \"SentEmail\" ADD CONSTRAINT \"SentEmail_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;"
  },
  {
    "path": "prisma/migrations/20231114054509_add_domain_to_sent_emails/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"SentEmail\" ADD COLUMN     \"domainSlug\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20231116093816_update_invitations/migration.sql",
    "content": "/*\n  Warnings:\n\n  - A unique constraint covering the columns `[email,teamId]` on the table `Invitation` will be added. If there are existing duplicate values, this will fail.\n\n*/\n-- DropIndex\nDROP INDEX \"Invitation_email_key\";\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Invitation_email_teamId_key\" ON \"Invitation\"(\"email\", \"teamId\");\n"
  },
  {
    "path": "prisma/migrations/20231127062841_add_conversation/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"assistantEnabled\" BOOLEAN NOT NULL DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"fileId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"Conversation\" (\n    \"id\" TEXT NOT NULL,\n    \"threadId\" TEXT NOT NULL,\n    \"userId\" TEXT NOT NULL,\n    \"documentId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"lastMessageAt\" TIMESTAMP(3),\n\n    CONSTRAINT \"Conversation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Conversation_threadId_key\" ON \"Conversation\"(\"threadId\");\n\n-- CreateIndex\nCREATE INDEX \"Conversation_threadId_idx\" ON \"Conversation\"(\"threadId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Conversation_userId_documentId_key\" ON \"Conversation\"(\"userId\", \"documentId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Conversation_threadId_documentId_key\" ON \"Conversation\"(\"threadId\", \"documentId\");\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20231128064540_add_indices/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"Document_ownerId_idx\" ON \"Document\"(\"ownerId\");\n\n-- CreateIndex\nCREATE INDEX \"Document_teamId_idx\" ON \"Document\"(\"teamId\");\n\n-- CreateIndex\nCREATE INDEX \"DocumentPage_versionId_idx\" ON \"DocumentPage\"(\"versionId\");\n\n-- CreateIndex\nCREATE INDEX \"DocumentVersion_documentId_idx\" ON \"DocumentVersion\"(\"documentId\");\n\n-- CreateIndex\nCREATE INDEX \"Domain_userId_idx\" ON \"Domain\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"Domain_teamId_idx\" ON \"Domain\"(\"teamId\");\n\n-- CreateIndex\nCREATE INDEX \"Link_documentId_idx\" ON \"Link\"(\"documentId\");\n\n-- CreateIndex\nCREATE INDEX \"View_linkId_idx\" ON \"View\"(\"linkId\");\n\n-- CreateIndex\nCREATE INDEX \"View_documentId_idx\" ON \"View\"(\"documentId\");\n"
  },
  {
    "path": "prisma/migrations/20231204070250_remove_trial/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"User\" ALTER COLUMN \"plan\" SET DEFAULT 'free';\n"
  },
  {
    "path": "prisma/migrations/20231207081407_add_reactions/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"Reaction\" (\n    \"id\" TEXT NOT NULL,\n    \"viewId\" TEXT NOT NULL,\n    \"pageNumber\" INTEGER NOT NULL,\n    \"type\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"Reaction_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"Reaction_viewId_idx\" ON \"Reaction\"(\"viewId\");\n\n-- AddForeignKey\nALTER TABLE \"Reaction\" ADD CONSTRAINT \"Reaction_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20240110233134_add_disable_feedback/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableFeedback\" BOOLEAN DEFAULT true;\n"
  },
  {
    "path": "prisma/migrations/20240117020456_add_branding/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableCustomMetatag\" BOOLEAN DEFAULT false,\nADD COLUMN     \"metaDescription\" TEXT,\nADD COLUMN     \"metaImage\" TEXT,\nADD COLUMN     \"metaTitle\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"Brand\" (\n    \"id\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"brandColor\" TEXT,\n    \"accentColor\" TEXT,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Brand_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Brand_teamId_key\" ON \"Brand\"(\"teamId\");\n\n-- AddForeignKey\nALTER TABLE \"Brand\" ADD CONSTRAINT \"Brand_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20240202052149_add_email_authentication_to_link_and_view/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"emailAuthenticated\" BOOLEAN NOT NULL DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"verified\" BOOLEAN NOT NULL DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20240205170242_embedded_links/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DocumentPage\" ADD COLUMN     \"embeddedLinks\" TEXT[];\n"
  },
  {
    "path": "prisma/migrations/20240212081614_add_downloaded_time_to_view/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"downloadedAt\" TIMESTAMP(3);\n"
  },
  {
    "path": "prisma/migrations/20240215035046_add_allow_deny_list_to_links/migration.sql",
    "content": "/*\n  Warnings:\n\n  - You are about to drop the column `allowedEmails` on the `Link` table. All the data in the column will be lost.\n\n*/\n-- AlterTable\nALTER TABLE \"Link\" DROP COLUMN \"allowedEmails\",\nADD COLUMN     \"allowList\" TEXT[],\nADD COLUMN     \"denyList\" TEXT[];\n"
  },
  {
    "path": "prisma/migrations/20240221042933_add_document_storage_type_enum/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"DocumentStorageType\" AS ENUM ('S3_PATH', 'VERCEL_BLOB');\n\n-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"storageType\" \"DocumentStorageType\" NOT NULL DEFAULT 'VERCEL_BLOB';\n\n-- AlterTable\nALTER TABLE \"DocumentPage\" ADD COLUMN     \"storageType\" \"DocumentStorageType\" NOT NULL DEFAULT 'VERCEL_BLOB';\n\n-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"storageType\" \"DocumentStorageType\" NOT NULL DEFAULT 'VERCEL_BLOB';\n"
  },
  {
    "path": "prisma/migrations/20240313100203_add_folders/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"folderId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"Folder\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"path\" TEXT NOT NULL,\n    \"parentId\" TEXT,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Folder_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"Folder_parentId_idx\" ON \"Folder\"(\"parentId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Folder_teamId_path_key\" ON \"Folder\"(\"teamId\", \"path\");\n\n-- CreateIndex\nCREATE INDEX \"Document_folderId_idx\" ON \"Document\"(\"folderId\");\n\n-- AddForeignKey\nALTER TABLE \"Document\" ADD CONSTRAINT \"Document_folderId_fkey\" FOREIGN KEY (\"folderId\") REFERENCES \"Folder\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Folder\" ADD CONSTRAINT \"Folder_parentId_fkey\" FOREIGN KEY (\"parentId\") REFERENCES \"Folder\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Folder\" ADD CONSTRAINT \"Folder_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20240327102407_add_dataroom/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"LinkType\" AS ENUM ('DOCUMENT_LINK', 'DATAROOM_LINK');\n\n-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"dataroomId\" TEXT,\nADD COLUMN     \"linkType\" \"LinkType\" NOT NULL DEFAULT 'DOCUMENT_LINK',\nALTER COLUMN \"documentId\" DROP NOT NULL;\n\n-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"dataroomId\" TEXT,\nADD COLUMN     \"dataroomViewId\" TEXT,\nALTER COLUMN \"documentId\" DROP NOT NULL;\n\n-- CreateTable\nCREATE TABLE \"Dataroom\" (\n    \"id\" TEXT NOT NULL,\n    \"pId\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Dataroom_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"DataroomDocument\" (\n    \"id\" TEXT NOT NULL,\n    \"dataroomId\" TEXT NOT NULL,\n    \"documentId\" TEXT NOT NULL,\n    \"folderId\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"DataroomDocument_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"DataroomFolder\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"path\" TEXT NOT NULL,\n    \"parentId\" TEXT,\n    \"dataroomId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"DataroomFolder_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Dataroom_pId_key\" ON \"Dataroom\"(\"pId\");\n\n-- CreateIndex\nCREATE INDEX \"Dataroom_teamId_idx\" ON \"Dataroom\"(\"teamId\");\n\n-- CreateIndex\nCREATE INDEX \"DataroomDocument_folderId_idx\" ON \"DataroomDocument\"(\"folderId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"DataroomDocument_dataroomId_documentId_key\" ON \"DataroomDocument\"(\"dataroomId\", \"documentId\");\n\n-- CreateIndex\nCREATE INDEX \"DataroomFolder_parentId_idx\" ON \"DataroomFolder\"(\"parentId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"DataroomFolder_dataroomId_path_key\" ON \"DataroomFolder\"(\"dataroomId\", \"path\");\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"View\" ADD CONSTRAINT \"View_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Dataroom\" ADD CONSTRAINT \"Dataroom_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomDocument\" ADD CONSTRAINT \"DataroomDocument_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomDocument\" ADD CONSTRAINT \"DataroomDocument_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomDocument\" ADD CONSTRAINT \"DataroomDocument_folderId_fkey\" FOREIGN KEY (\"folderId\") REFERENCES \"DataroomFolder\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFolder\" ADD CONSTRAINT \"DataroomFolder_parentId_fkey\" FOREIGN KEY (\"parentId\") REFERENCES \"DataroomFolder\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFolder\" ADD CONSTRAINT \"DataroomFolder_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20240330062000_add_viewtype/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"ViewType\" AS ENUM ('DOCUMENT_VIEW', 'DATAROOM_VIEW');\n\n-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"viewType\" \"ViewType\" NOT NULL DEFAULT 'DOCUMENT_VIEW';\n\n-- CreateIndex\nCREATE INDEX \"View_dataroomId_idx\" ON \"View\"(\"dataroomId\");\n\n-- CreateIndex\nCREATE INDEX \"View_dataroomViewId_idx\" ON \"View\"(\"dataroomViewId\");\n\n"
  },
  {
    "path": "prisma/migrations/20240401000000_add_dataroom_brand/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"DataroomBrand\" (\n    \"id\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"banner\" TEXT,\n    \"brandColor\" TEXT,\n    \"accentColor\" TEXT,\n    \"dataroomId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"DataroomBrand_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"DataroomBrand_dataroomId_key\" ON \"DataroomBrand\"(\"dataroomId\");\n\n-- AddForeignKey\nALTER TABLE \"DataroomBrand\" ADD CONSTRAINT \"DataroomBrand_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20240408000000_add_viewer/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"viewerId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"Viewer\" (\n    \"id\" TEXT NOT NULL,\n    \"email\" TEXT NOT NULL,\n    \"verified\" BOOLEAN NOT NULL DEFAULT false,\n    \"invitedAt\" TIMESTAMP(3),\n    \"dataroomId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Viewer_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"Viewer_dataroomId_idx\" ON \"Viewer\"(\"dataroomId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Viewer_dataroomId_email_key\" ON \"Viewer\"(\"dataroomId\", \"email\");\n\n-- AddForeignKey\nALTER TABLE \"View\" ADD CONSTRAINT \"View_viewerId_fkey\" FOREIGN KEY (\"viewerId\") REFERENCES \"Viewer\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Viewer\" ADD CONSTRAINT \"Viewer_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20240415000000_add_feedback/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableQuestion\" BOOLEAN DEFAULT false;\n\n-- CreateTable\nCREATE TABLE \"Feedback\" (\n    \"id\" TEXT NOT NULL,\n    \"linkId\" TEXT NOT NULL,\n    \"data\" JSONB NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Feedback_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"FeedbackResponse\" (\n    \"id\" TEXT NOT NULL,\n    \"feedbackId\" TEXT NOT NULL,\n    \"data\" JSONB NOT NULL,\n    \"viewId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"FeedbackResponse_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Feedback_linkId_key\" ON \"Feedback\"(\"linkId\");\n\n-- CreateIndex\nCREATE INDEX \"Feedback_linkId_idx\" ON \"Feedback\"(\"linkId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"FeedbackResponse_viewId_key\" ON \"FeedbackResponse\"(\"viewId\");\n\n-- CreateIndex\nCREATE INDEX \"FeedbackResponse_feedbackId_idx\" ON \"FeedbackResponse\"(\"feedbackId\");\n\n-- CreateIndex\nCREATE INDEX \"FeedbackResponse_viewId_idx\" ON \"FeedbackResponse\"(\"viewId\");\n\n-- AddForeignKey\nALTER TABLE \"Feedback\" ADD CONSTRAINT \"Feedback_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"FeedbackResponse\" ADD CONSTRAINT \"FeedbackResponse_feedbackId_fkey\" FOREIGN KEY (\"feedbackId\") REFERENCES \"Feedback\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"FeedbackResponse\" ADD CONSTRAINT \"FeedbackResponse_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20240424152839_add_screenprotection_to_link/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableScreenshotProtection\" BOOLEAN DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20240511000000_add_team_limits/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"limits\" JSONB;\n\n"
  },
  {
    "path": "prisma/migrations/20240520000000_add_vertical_to_document_version/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"isVertical\" BOOLEAN NOT NULL DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20240521000000_add_manager_role/migration.sql",
    "content": "-- AlterEnum\nALTER TYPE \"Role\" ADD VALUE 'MANAGER';\n\n"
  },
  {
    "path": "prisma/migrations/20240611000000_add_agreements/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"agreementId\" TEXT,\nADD COLUMN     \"enableAgreement\" BOOLEAN DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"viewerName\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"Agreement\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"content\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Agreement_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"AgreementResponse\" (\n    \"id\" TEXT NOT NULL,\n    \"agreementId\" TEXT NOT NULL,\n    \"viewId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"AgreementResponse_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"Agreement_teamId_idx\" ON \"Agreement\"(\"teamId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"AgreementResponse_viewId_key\" ON \"AgreementResponse\"(\"viewId\");\n\n-- CreateIndex\nCREATE INDEX \"AgreementResponse_agreementId_idx\" ON \"AgreementResponse\"(\"agreementId\");\n\n-- CreateIndex\nCREATE INDEX \"AgreementResponse_viewId_idx\" ON \"AgreementResponse\"(\"viewId\");\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_agreementId_fkey\" FOREIGN KEY (\"agreementId\") REFERENCES \"Agreement\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Agreement\" ADD CONSTRAINT \"Agreement_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"AgreementResponse\" ADD CONSTRAINT \"AgreementResponse_agreementId_fkey\" FOREIGN KEY (\"agreementId\") REFERENCES \"Agreement\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"AgreementResponse\" ADD CONSTRAINT \"AgreementResponse_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20240712000000_add_page_metadata/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DocumentPage\" ADD COLUMN     \"metadata\" JSONB,\nADD COLUMN     \"pageLinks\" JSONB;\n\n"
  },
  {
    "path": "prisma/migrations/20240720000000_change_owner_dependecy/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"Document\" DROP CONSTRAINT \"Document_ownerId_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"Document\" DROP CONSTRAINT \"Document_teamId_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"Domain\" DROP CONSTRAINT \"Domain_teamId_fkey\";\n\n-- DropForeignKey\nALTER TABLE \"Domain\" DROP CONSTRAINT \"Domain_userId_fkey\";\n\n-- AlterTable\nALTER TABLE \"Document\" ALTER COLUMN \"ownerId\" DROP NOT NULL,\nALTER COLUMN \"teamId\" SET NOT NULL;\n\n-- AlterTable\nALTER TABLE \"Domain\" ALTER COLUMN \"userId\" DROP NOT NULL,\nALTER COLUMN \"teamId\" SET NOT NULL;\n\n-- AddForeignKey\nALTER TABLE \"Document\" ADD CONSTRAINT \"Document_ownerId_fkey\" FOREIGN KEY (\"ownerId\") REFERENCES \"User\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Document\" ADD CONSTRAINT \"Document_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Domain\" ADD CONSTRAINT \"Domain_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Domain\" ADD CONSTRAINT \"Domain_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20240730000000_update_link_defaults/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ALTER COLUMN \"enableFeedback\" SET DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20240731000000_add_link_show_banner/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"showBanner\" BOOLEAN DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20240809000000_add_dataroom_order_index/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DataroomDocument\" ADD COLUMN     \"orderIndex\" INTEGER;\n\n-- AlterTable\nALTER TABLE \"DataroomFolder\" ADD COLUMN     \"orderIndex\" INTEGER;\n\n-- CreateIndex\nCREATE INDEX \"DataroomDocument_dataroomId_folderId_orderIndex_idx\" ON \"DataroomDocument\"(\"dataroomId\", \"folderId\", \"orderIndex\");\n\n-- CreateIndex\nCREATE INDEX \"DataroomFolder_dataroomId_parentId_orderIndex_idx\" ON \"DataroomFolder\"(\"dataroomId\", \"parentId\", \"orderIndex\");\n\n"
  },
  {
    "path": "prisma/migrations/20240821000000_add_require_name/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Agreement\" ADD COLUMN     \"requireName\" BOOLEAN NOT NULL DEFAULT true;\n\n"
  },
  {
    "path": "prisma/migrations/20240830000000_add_watermarks/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableWatermark\" BOOLEAN DEFAULT false,\nADD COLUMN     \"watermarkConfig\" JSONB;\n\n"
  },
  {
    "path": "prisma/migrations/20240901000000_add_domain_default/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Domain\" ADD COLUMN     \"isDefault\" BOOLEAN NOT NULL DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20240902000000_add_link_presets/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"LinkPreset\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"enableCustomMetaTag\" BOOLEAN DEFAULT false,\n    \"metaTitle\" TEXT,\n    \"metaDescription\" TEXT,\n    \"metaImage\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"LinkPreset_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"LinkPreset_teamId_idx\" ON \"LinkPreset\"(\"teamId\");\n\n-- AddForeignKey\nALTER TABLE \"LinkPreset\" ADD CONSTRAINT \"LinkPreset_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20240911000000_add_dataroom_groups_permissions/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"LinkAudienceType\" AS ENUM ('GENERAL', 'GROUP', 'TEAM');\n\n-- CreateEnum\nCREATE TYPE \"ItemType\" AS ENUM ('DATAROOM_DOCUMENT', 'DATAROOM_FOLDER');\n\n-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"audienceType\" \"LinkAudienceType\" NOT NULL DEFAULT 'GENERAL',\nADD COLUMN     \"groupId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"groupId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"Viewer\" ADD COLUMN     \"teamId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"ViewerGroup\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"dataroomId\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"ViewerGroup_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"ViewerGroupMembership\" (\n    \"id\" TEXT NOT NULL,\n    \"viewerId\" TEXT NOT NULL,\n    \"groupId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"ViewerGroupMembership_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"ViewerGroupAccessControls\" (\n    \"id\" TEXT NOT NULL,\n    \"groupId\" TEXT NOT NULL,\n    \"itemId\" TEXT NOT NULL,\n    \"itemType\" \"ItemType\" NOT NULL,\n    \"canView\" BOOLEAN NOT NULL DEFAULT true,\n    \"canDownload\" BOOLEAN NOT NULL DEFAULT false,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"ViewerGroupAccessControls_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"ViewerGroup_dataroomId_idx\" ON \"ViewerGroup\"(\"dataroomId\");\n\n-- CreateIndex\nCREATE INDEX \"ViewerGroup_teamId_idx\" ON \"ViewerGroup\"(\"teamId\");\n\n-- CreateIndex\nCREATE INDEX \"ViewerGroupMembership_viewerId_idx\" ON \"ViewerGroupMembership\"(\"viewerId\");\n\n-- CreateIndex\nCREATE INDEX \"ViewerGroupMembership_groupId_idx\" ON \"ViewerGroupMembership\"(\"groupId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ViewerGroupMembership_viewerId_groupId_key\" ON \"ViewerGroupMembership\"(\"viewerId\", \"groupId\");\n\n-- CreateIndex\nCREATE INDEX \"ViewerGroupAccessControls_groupId_idx\" ON \"ViewerGroupAccessControls\"(\"groupId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ViewerGroupAccessControls_groupId_itemId_key\" ON \"ViewerGroupAccessControls\"(\"groupId\", \"itemId\");\n\n-- CreateIndex\nCREATE INDEX \"Viewer_teamId_idx\" ON \"Viewer\"(\"teamId\");\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_groupId_fkey\" FOREIGN KEY (\"groupId\") REFERENCES \"ViewerGroup\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"View\" ADD CONSTRAINT \"View_groupId_fkey\" FOREIGN KEY (\"groupId\") REFERENCES \"ViewerGroup\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Viewer\" ADD CONSTRAINT \"Viewer_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ViewerGroup\" ADD CONSTRAINT \"ViewerGroup_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ViewerGroup\" ADD CONSTRAINT \"ViewerGroup_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ViewerGroupMembership\" ADD CONSTRAINT \"ViewerGroupMembership_viewerId_fkey\" FOREIGN KEY (\"viewerId\") REFERENCES \"Viewer\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ViewerGroupMembership\" ADD CONSTRAINT \"ViewerGroupMembership_groupId_fkey\" FOREIGN KEY (\"groupId\") REFERENCES \"ViewerGroup\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ViewerGroupAccessControls\" ADD CONSTRAINT \"ViewerGroupAccessControls_groupId_fkey\" FOREIGN KEY (\"groupId\") REFERENCES \"ViewerGroup\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20240915000000_add_advanced_mode/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"advancedExcelEnabled\" BOOLEAN NOT NULL DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20240916000000_add_content_type_to_document/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"contentType\" TEXT,\nADD COLUMN     \"originalFile\" TEXT;\n\n-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"contentType\" TEXT,\nADD COLUMN     \"originalFile\" TEXT;\n\n"
  },
  {
    "path": "prisma/migrations/20240921000000_add_viewer_migration/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"Viewer\" DROP CONSTRAINT \"Viewer_dataroomId_fkey\";\n\n-- DropIndex\nDROP INDEX \"Viewer_dataroomId_email_key\";\n\n-- AlterTable\nALTER TABLE \"Viewer\" ALTER COLUMN \"dataroomId\" DROP NOT NULL,\nALTER COLUMN \"teamId\" SET NOT NULL;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Viewer_teamId_email_key\" ON \"Viewer\"(\"teamId\", \"email\");\n\n-- AddForeignKey\nALTER TABLE \"Viewer\" ADD CONSTRAINT \"Viewer_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20241004024010_add_favicon_column/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"metaFavicon\" TEXT;\n\n-- AlterTable\nALTER TABLE \"LinkPreset\" ADD COLUMN     \"metaFavicon\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20241020000000_add_teamid_to_link_and_view/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"teamId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"teamId\" TEXT;\n\n-- CreateIndex\nCREATE INDEX \"Link_teamId_idx\" ON \"Link\"(\"teamId\");\n\n-- CreateIndex\nCREATE INDEX \"View_teamId_idx\" ON \"View\"(\"teamId\");\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"View\" ADD CONSTRAINT \"View_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20241029000000_add_archived_view/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"View\" ADD COLUMN     \"isArchived\" BOOLEAN NOT NULL DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20241107000000_add_tokens_and_webhooks/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"IncomingWebhook\" (\n    \"id\" TEXT NOT NULL,\n    \"externalId\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"secret\" TEXT,\n    \"source\" TEXT,\n    \"actions\" TEXT,\n    \"consecutiveFailures\" INTEGER NOT NULL DEFAULT 0,\n    \"lastFailedAt\" TIMESTAMP(3),\n    \"disabledAt\" TIMESTAMP(3),\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"IncomingWebhook_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"RestrictedToken\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"hashedKey\" TEXT NOT NULL,\n    \"partialKey\" TEXT NOT NULL,\n    \"scopes\" TEXT,\n    \"expires\" TIMESTAMP(3),\n    \"lastUsed\" TIMESTAMP(3),\n    \"rateLimit\" INTEGER NOT NULL DEFAULT 60,\n    \"userId\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"RestrictedToken_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"IncomingWebhook_externalId_key\" ON \"IncomingWebhook\"(\"externalId\");\n\n-- CreateIndex\nCREATE INDEX \"IncomingWebhook_teamId_idx\" ON \"IncomingWebhook\"(\"teamId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"RestrictedToken_hashedKey_key\" ON \"RestrictedToken\"(\"hashedKey\");\n\n-- CreateIndex\nCREATE INDEX \"RestrictedToken_userId_idx\" ON \"RestrictedToken\"(\"userId\");\n\n-- CreateIndex\nCREATE INDEX \"RestrictedToken_teamId_idx\" ON \"RestrictedToken\"(\"teamId\");\n\n-- AddForeignKey\nALTER TABLE \"IncomingWebhook\" ADD CONSTRAINT \"IncomingWebhook_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"RestrictedToken\" ADD CONSTRAINT \"RestrictedToken_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"RestrictedToken\" ADD CONSTRAINT \"RestrictedToken_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20241118000000_add_filesize_and_downloadonly/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"downloadOnly\" BOOLEAN NOT NULL DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"fileSize\" INTEGER;\n\n"
  },
  {
    "path": "prisma/migrations/20241123000000_add_viewer_notification_preferences/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Viewer\" ADD COLUMN     \"notificationPreferences\" JSONB;\n\n"
  },
  {
    "path": "prisma/migrations/20241126000000_add_screen_shield/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"screenShieldPercentage\" INTEGER;\n\n"
  },
  {
    "path": "prisma/migrations/20241208000000_add_webhooks/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"Webhook\" (\n    \"id\" TEXT NOT NULL,\n    \"pId\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"url\" TEXT NOT NULL,\n    \"secret\" TEXT NOT NULL,\n    \"triggers\" JSONB NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Webhook_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Webhook_pId_key\" ON \"Webhook\"(\"pId\");\n\n-- CreateIndex\nCREATE INDEX \"Webhook_teamId_idx\" ON \"Webhook\"(\"teamId\");\n\n-- AddForeignKey\nALTER TABLE \"Webhook\" ADD CONSTRAINT \"Webhook_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20241212000000_add_yir/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"UserTeam\" ADD COLUMN     \"notificationPreferences\" JSONB;\n\n-- CreateTable\nCREATE TABLE \"YearInReview\" (\n    \"id\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"status\" TEXT NOT NULL DEFAULT 'pending',\n    \"attempts\" INTEGER NOT NULL DEFAULT 0,\n    \"lastAttempted\" TIMESTAMP(3),\n    \"error\" TEXT,\n    \"stats\" JSONB NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"YearInReview_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"YearInReview_status_attempts_idx\" ON \"YearInReview\"(\"status\", \"attempts\");\n\n-- CreateIndex\nCREATE INDEX \"YearInReview_teamId_idx\" ON \"YearInReview\"(\"teamId\");\n\n"
  },
  {
    "path": "prisma/migrations/20241231_add_dataroom_allow_bulk_download/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN \"allowBulkDownload\" BOOLEAN NOT NULL DEFAULT true;\n"
  },
  {
    "path": "prisma/migrations/20250106000000_add_download_type/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"DownloadType\" AS ENUM ('SINGLE', 'BULK', 'FOLDER');\n\n-- AlterTable\nALTER TABLE \"View\" ADD COLUMN \"downloadType\" \"DownloadType\";\nALTER TABLE \"View\" ADD COLUMN \"downloadMetadata\" JSONB;\n\n"
  },
  {
    "path": "prisma/migrations/20250110000000_add_length_to_document_version/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"length\" INTEGER;\n\n"
  },
  {
    "path": "prisma/migrations/20250113000000_add_custom_fields/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"CustomFieldType\" AS ENUM ('SHORT_TEXT', 'LONG_TEXT', 'NUMBER', 'PHONE_NUMBER', 'URL', 'CHECKBOX', 'SELECT', 'MULTI_SELECT');\n\n-- CreateTable\nCREATE TABLE \"CustomField\" (\n    \"id\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"type\" \"CustomFieldType\" NOT NULL,\n    \"identifier\" TEXT NOT NULL,\n    \"label\" TEXT NOT NULL,\n    \"placeholder\" TEXT,\n    \"required\" BOOLEAN NOT NULL DEFAULT false,\n    \"disabled\" BOOLEAN NOT NULL DEFAULT false,\n    \"linkId\" TEXT NOT NULL,\n    \"orderIndex\" INTEGER NOT NULL DEFAULT 0,\n\n    CONSTRAINT \"CustomField_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"CustomFieldResponse\" (\n    \"id\" TEXT NOT NULL,\n    \"data\" JSONB NOT NULL,\n    \"viewId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"CustomFieldResponse_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"CustomField_linkId_idx\" ON \"CustomField\"(\"linkId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"CustomFieldResponse_viewId_key\" ON \"CustomFieldResponse\"(\"viewId\");\n\n-- CreateIndex\nCREATE INDEX \"CustomFieldResponse_viewId_idx\" ON \"CustomFieldResponse\"(\"viewId\");\n\n-- AddForeignKey\nALTER TABLE \"CustomField\" ADD CONSTRAINT \"CustomField_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"CustomFieldResponse\" ADD CONSTRAINT \"CustomFieldResponse_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250204000000_add_anonymous_group/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"ViewerGroup\" ADD COLUMN     \"allowAll\" BOOLEAN NOT NULL DEFAULT false,\nADD COLUMN     \"domains\" TEXT[];\n\n"
  },
  {
    "path": "prisma/migrations/20250217000000_add_contactid_to_user/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"User\" ADD COLUMN     \"contactId\" TEXT;\n\n"
  },
  {
    "path": "prisma/migrations/20250217000000_remove_link_column/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" DROP COLUMN \"screenShieldPercentage\";\n\n"
  },
  {
    "path": "prisma/migrations/20250310000000_rename_conversation_table/migration.sql",
    "content": "-- Rename the table (this preserves all data)\nALTER TABLE \"Conversation\" RENAME TO \"Chat\";\n\n-- Rename constraints and indices to match the new table name\nALTER INDEX \"Conversation_pkey\" RENAME TO \"Chat_pkey\";\nALTER INDEX \"Conversation_threadId_key\" RENAME TO \"Chat_threadId_key\";\nALTER INDEX \"Conversation_threadId_idx\" RENAME TO \"Chat_threadId_idx\";\nALTER INDEX \"Conversation_userId_documentId_key\" RENAME TO \"Chat_userId_documentId_key\";\nALTER INDEX \"Conversation_threadId_documentId_key\" RENAME TO \"Chat_threadId_documentId_key\";\n\n-- Rename foreign key constraints\nALTER TABLE \"Chat\" RENAME CONSTRAINT \"Conversation_userId_fkey\" TO \"Chat_userId_fkey\";\nALTER TABLE \"Chat\" RENAME CONSTRAINT \"Conversation_documentId_fkey\" TO \"Chat_documentId_fkey\";\n"
  },
  {
    "path": "prisma/migrations/20250404000000_add_questions_answer_conversation/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"ConversationVisibility\" AS ENUM ('PRIVATE', 'PUBLIC_LINK', 'PUBLIC_GROUP', 'PUBLIC_DOCUMENT', 'PUBLIC_DATAROOM');\n\n-- CreateEnum\nCREATE TYPE \"ParticipantRole\" AS ENUM ('OWNER', 'PARTICIPANT');\n\n-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN     \"conversationsEnabled\" BOOLEAN NOT NULL DEFAULT false,\nADD COLUMN     \"description\" TEXT;\n\n-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableConversation\" BOOLEAN NOT NULL DEFAULT false;\n\n-- CreateTable\nCREATE TABLE \"Conversation\" (\n    \"id\" TEXT NOT NULL,\n    \"title\" TEXT,\n    \"isEnabled\" BOOLEAN NOT NULL DEFAULT true,\n    \"visibilityMode\" \"ConversationVisibility\" NOT NULL DEFAULT 'PRIVATE',\n    \"dataroomId\" TEXT NOT NULL,\n    \"dataroomDocumentId\" TEXT,\n    \"documentVersionNumber\" INTEGER,\n    \"documentPageNumber\" INTEGER,\n    \"linkId\" TEXT,\n    \"viewerGroupId\" TEXT,\n    \"initialViewId\" TEXT,\n    \"lastMessageAt\" TIMESTAMP(3),\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n\n    CONSTRAINT \"Conversation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"ConversationParticipant\" (\n    \"id\" TEXT NOT NULL,\n    \"conversationId\" TEXT NOT NULL,\n    \"role\" \"ParticipantRole\" NOT NULL DEFAULT 'PARTICIPANT',\n    \"viewerId\" TEXT,\n    \"userId\" TEXT,\n    \"receiveNotifications\" BOOLEAN NOT NULL DEFAULT false,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"ConversationParticipant_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"ConversationView\" (\n    \"id\" TEXT NOT NULL,\n    \"conversationId\" TEXT NOT NULL,\n    \"viewId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"ConversationView_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"Message\" (\n    \"id\" TEXT NOT NULL,\n    \"content\" TEXT NOT NULL,\n    \"conversationId\" TEXT NOT NULL,\n    \"userId\" TEXT,\n    \"viewerId\" TEXT,\n    \"viewId\" TEXT,\n    \"isRead\" BOOLEAN NOT NULL DEFAULT false,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Message_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"Conversation_dataroomDocumentId_idx\" ON \"Conversation\"(\"dataroomDocumentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Conversation_dataroomId_idx\" ON \"Conversation\"(\"dataroomId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Conversation_initialViewId_idx\" ON \"Conversation\"(\"initialViewId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Conversation_linkId_idx\" ON \"Conversation\"(\"linkId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Conversation_teamId_idx\" ON \"Conversation\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Conversation_viewerGroupId_idx\" ON \"Conversation\"(\"viewerGroupId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ConversationParticipant_conversationId_idx\" ON \"ConversationParticipant\"(\"conversationId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ConversationParticipant_conversationId_userId_key\" ON \"ConversationParticipant\"(\"conversationId\" ASC, \"userId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ConversationParticipant_conversationId_viewerId_key\" ON \"ConversationParticipant\"(\"conversationId\" ASC, \"viewerId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ConversationParticipant_userId_idx\" ON \"ConversationParticipant\"(\"userId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ConversationParticipant_viewerId_idx\" ON \"ConversationParticipant\"(\"viewerId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ConversationView_conversationId_idx\" ON \"ConversationView\"(\"conversationId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"ConversationView_conversationId_viewId_key\" ON \"ConversationView\"(\"conversationId\" ASC, \"viewId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ConversationView_viewId_idx\" ON \"ConversationView\"(\"viewId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Message_conversationId_idx\" ON \"Message\"(\"conversationId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Message_userId_idx\" ON \"Message\"(\"userId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Message_viewId_idx\" ON \"Message\"(\"viewId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Message_viewerId_idx\" ON \"Message\"(\"viewerId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_dataroomDocumentId_fkey\" FOREIGN KEY (\"dataroomDocumentId\") REFERENCES \"DataroomDocument\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_initialViewId_fkey\" FOREIGN KEY (\"initialViewId\") REFERENCES \"View\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Conversation\" ADD CONSTRAINT \"Conversation_viewerGroupId_fkey\" FOREIGN KEY (\"viewerGroupId\") REFERENCES \"ViewerGroup\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ConversationParticipant\" ADD CONSTRAINT \"ConversationParticipant_conversationId_fkey\" FOREIGN KEY (\"conversationId\") REFERENCES \"Conversation\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ConversationParticipant\" ADD CONSTRAINT \"ConversationParticipant_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ConversationParticipant\" ADD CONSTRAINT \"ConversationParticipant_viewerId_fkey\" FOREIGN KEY (\"viewerId\") REFERENCES \"Viewer\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ConversationView\" ADD CONSTRAINT \"ConversationView_conversationId_fkey\" FOREIGN KEY (\"conversationId\") REFERENCES \"Conversation\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ConversationView\" ADD CONSTRAINT \"ConversationView_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Message\" ADD CONSTRAINT \"Message_conversationId_fkey\" FOREIGN KEY (\"conversationId\") REFERENCES \"Conversation\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Message\" ADD CONSTRAINT \"Message_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Message\" ADD CONSTRAINT \"Message_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Message\" ADD CONSTRAINT \"Message_viewerId_fkey\" FOREIGN KEY (\"viewerId\") REFERENCES \"Viewer\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250413000000_add_dataroom_upload/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"isExternalUpload\" BOOLEAN NOT NULL DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableUpload\" BOOLEAN DEFAULT false,\nADD COLUMN     \"isFileRequestOnly\" BOOLEAN DEFAULT false,\nADD COLUMN     \"uploadFolderId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"DocumentUpload\" (\n    \"id\" TEXT NOT NULL,\n    \"documentId\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"viewerId\" TEXT,\n    \"viewId\" TEXT,\n    \"linkId\" TEXT NOT NULL,\n    \"dataroomId\" TEXT,\n    \"dataroomDocumentId\" TEXT,\n    \"originalFilename\" TEXT,\n    \"fileSize\" INTEGER,\n    \"mimeType\" TEXT,\n    \"uploadedAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"numPages\" INTEGER,\n\n    CONSTRAINT \"DocumentUpload_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"DocumentUpload_dataroomDocumentId_idx\" ON \"DocumentUpload\"(\"dataroomDocumentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentUpload_dataroomId_idx\" ON \"DocumentUpload\"(\"dataroomId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentUpload_documentId_idx\" ON \"DocumentUpload\"(\"documentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentUpload_linkId_idx\" ON \"DocumentUpload\"(\"linkId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentUpload_teamId_idx\" ON \"DocumentUpload\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentUpload_viewId_idx\" ON \"DocumentUpload\"(\"viewId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentUpload_viewerId_idx\" ON \"DocumentUpload\"(\"viewerId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"DocumentUpload\" ADD CONSTRAINT \"DocumentUpload_dataroomDocumentId_fkey\" FOREIGN KEY (\"dataroomDocumentId\") REFERENCES \"DataroomDocument\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentUpload\" ADD CONSTRAINT \"DocumentUpload_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentUpload\" ADD CONSTRAINT \"DocumentUpload_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentUpload\" ADD CONSTRAINT \"DocumentUpload_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentUpload\" ADD CONSTRAINT \"DocumentUpload_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentUpload\" ADD CONSTRAINT \"DocumentUpload_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentUpload\" ADD CONSTRAINT \"DocumentUpload_viewerId_fkey\" FOREIGN KEY (\"viewerId\") REFERENCES \"Viewer\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250425000000_update_link_presets/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Agreement\" ADD COLUMN     \"deletedAt\" TIMESTAMP(3),\nADD COLUMN     \"deletedBy\" TEXT;\n\n-- AlterTable\nALTER TABLE \"LinkPreset\" ADD COLUMN     \"allowDownload\" BOOLEAN DEFAULT false,\nADD COLUMN     \"allowList\" TEXT[],\nADD COLUMN     \"denyList\" TEXT[],\nADD COLUMN     \"emailAuthenticated\" BOOLEAN DEFAULT false,\nADD COLUMN     \"emailProtected\" BOOLEAN DEFAULT true,\nADD COLUMN     \"enableAllowList\" BOOLEAN DEFAULT false,\nADD COLUMN     \"enableDenyList\" BOOLEAN DEFAULT false,\nADD COLUMN     \"enablePassword\" BOOLEAN DEFAULT false,\nADD COLUMN     \"enableWatermark\" BOOLEAN DEFAULT false,\nADD COLUMN     \"expiresAt\" TIMESTAMP(3),\nADD COLUMN     \"expiresIn\" INTEGER,\nADD COLUMN     \"isDefault\" BOOLEAN NOT NULL DEFAULT false,\nADD COLUMN     \"pId\" TEXT,\nADD COLUMN     \"password\" TEXT,\nADD COLUMN     \"watermarkConfig\" JSONB;\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"LinkPreset_pId_key\" ON \"LinkPreset\"(\"pId\" ASC);\n\n"
  },
  {
    "path": "prisma/migrations/20250428000000_add_tags/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"TagType\" AS ENUM ('LINK_TAG', 'DOCUMENT_TAG', 'DATAROOM_TAG');\n\n-- CreateTable\nCREATE TABLE \"Tag\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"color\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"teamId\" TEXT NOT NULL,\n    \"createdBy\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Tag_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"TagItem\" (\n    \"id\" TEXT NOT NULL,\n    \"tagId\" TEXT NOT NULL,\n    \"linkId\" TEXT,\n    \"documentId\" TEXT,\n    \"dataroomId\" TEXT,\n    \"taggedBy\" TEXT,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"itemType\" \"TagType\" NOT NULL,\n\n    CONSTRAINT \"TagItem_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"Tag_id_idx\" ON \"Tag\"(\"id\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Tag_name_idx\" ON \"Tag\"(\"name\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Tag_teamId_idx\" ON \"Tag\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Tag_teamId_name_key\" ON \"Tag\"(\"teamId\" ASC, \"name\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"TagItem_tagId_dataroomId_idx\" ON \"TagItem\"(\"tagId\" ASC, \"dataroomId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"TagItem_tagId_documentId_idx\" ON \"TagItem\"(\"tagId\" ASC, \"documentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"TagItem_tagId_linkId_idx\" ON \"TagItem\"(\"tagId\" ASC, \"linkId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"Tag\" ADD CONSTRAINT \"Tag_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"TagItem\" ADD CONSTRAINT \"TagItem_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"TagItem\" ADD CONSTRAINT \"TagItem_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"TagItem\" ADD CONSTRAINT \"TagItem_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"TagItem\" ADD CONSTRAINT \"TagItem_tagId_fkey\" FOREIGN KEY (\"tagId\") REFERENCES \"Tag\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250502000000_add_additional_present_fields/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"LinkPreset\" ADD COLUMN     \"agreementId\" TEXT,\nADD COLUMN     \"customFields\" JSONB,\nADD COLUMN     \"enableAgreement\" BOOLEAN DEFAULT false,\nADD COLUMN     \"enableCustomFields\" BOOLEAN DEFAULT false,\nADD COLUMN     \"enableScreenshotProtection\" BOOLEAN DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20250511000000_add_index_file_to_links/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableIndexFile\" BOOLEAN DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20250513000000_add_notification_to_dataroom/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN     \"enableChangeNotifications\" BOOLEAN NOT NULL DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20250516000000_add_advanced_mode_to_team/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"enableExcelAdvancedMode\" BOOLEAN NOT NULL DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20250526000000_add_status_to_users/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"UserTeam\" ADD COLUMN     \"blockedAt\" TIMESTAMP(3),\nADD COLUMN     \"status\" TEXT NOT NULL DEFAULT 'ACTIVE';\n\n"
  },
  {
    "path": "prisma/migrations/20250607000000_add_notification_to_presets/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"LinkPreset\" ADD COLUMN     \"enableNotification\" BOOLEAN DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20250612000000_add_permissions_to_link/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"permissionGroupId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"PermissionGroup\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"dataroomId\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"PermissionGroup_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"PermissionGroupAccessControls\" (\n    \"id\" TEXT NOT NULL,\n    \"groupId\" TEXT NOT NULL,\n    \"itemId\" TEXT NOT NULL,\n    \"itemType\" \"ItemType\" NOT NULL,\n    \"canView\" BOOLEAN NOT NULL DEFAULT true,\n    \"canDownload\" BOOLEAN NOT NULL DEFAULT false,\n    \"canDownloadOriginal\" BOOLEAN NOT NULL DEFAULT false,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"PermissionGroupAccessControls_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"PermissionGroup_dataroomId_idx\" ON \"PermissionGroup\"(\"dataroomId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"PermissionGroup_teamId_idx\" ON \"PermissionGroup\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"PermissionGroupAccessControls_groupId_idx\" ON \"PermissionGroupAccessControls\"(\"groupId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"PermissionGroupAccessControls_groupId_itemId_key\" ON \"PermissionGroupAccessControls\"(\"groupId\" ASC, \"itemId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_permissionGroupId_fkey\" FOREIGN KEY (\"permissionGroupId\") REFERENCES \"PermissionGroup\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"PermissionGroup\" ADD CONSTRAINT \"PermissionGroup_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"PermissionGroup\" ADD CONSTRAINT \"PermissionGroup_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"PermissionGroupAccessControls\" ADD CONSTRAINT \"PermissionGroupAccessControls_groupId_fkey\" FOREIGN KEY (\"groupId\") REFERENCES \"PermissionGroup\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250624042156_viewers_performance_optimization/migration.sql",
    "content": "CREATE INDEX IF NOT EXISTS \"View_viewerId_idx\" ON \"View\"(\"viewerId\");\n\nCREATE INDEX IF NOT EXISTS \"View_viewedAt_idx\" ON \"View\"(\"viewedAt\" DESC);\n\nCREATE INDEX IF NOT EXISTS \"View_viewerId_documentId_idx\" ON \"View\"(\"viewerId\", \"documentId\");\n\n-- Index for viewer email search\nCREATE INDEX IF NOT EXISTS \"Viewer_email_prefix_idx\" ON \"Viewer\"(lower(\"email\") text_pattern_ops);\n\nCREATE INDEX IF NOT EXISTS \"Viewer_teamId_email_idx\" ON \"Viewer\"(\"teamId\", lower(\"email\"));\n\n-- Additional indexes for sorting optimization\nCREATE INDEX IF NOT EXISTS \"Viewer_teamId_createdAt_idx\" ON \"Viewer\"(\"teamId\", \"createdAt\" DESC);\n\nCREATE INDEX IF NOT EXISTS \"View_viewerId_viewedAt_idx\" ON \"View\"(\"viewerId\", \"viewedAt\" DESC) WHERE \"documentId\" IS NOT NULL;\n"
  },
  {
    "path": "prisma/migrations/20250711000000_add_ignored_domains_to_team/migration.sql",
    "content": "-- DropIndex\nDROP INDEX IF EXISTS \"Viewer_teamId_createdAt_idx\";\n\n-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"ignoredDomains\" TEXT[];\n\n"
  },
  {
    "path": "prisma/migrations/20250715042921_add_global_block_list_to_team/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"globalBlockList\" TEXT[];\n"
  },
  {
    "path": "prisma/migrations/20250716000000_add_dataroom_permission_defaults/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"DefaultPermissionStrategy\" AS ENUM ('INHERIT_FROM_PARENT', 'ASK_EVERY_TIME', 'HIDDEN_BY_DEFAULT');\n\n-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN     \"defaultPermissionStrategy\" \"DefaultPermissionStrategy\" NOT NULL DEFAULT 'INHERIT_FROM_PARENT';\n\n"
  },
  {
    "path": "prisma/migrations/20250725000000_add_welcome_message_to_dataroom/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DataroomBrand\" ADD COLUMN     \"welcomeMessage\" TEXT;\n\n"
  },
  {
    "path": "prisma/migrations/20250730000000_change_file_size_bigint/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DocumentUpload\" ALTER COLUMN \"fileSize\" SET DATA TYPE BIGINT;\n\n-- AlterTable\nALTER TABLE \"DocumentVersion\" ALTER COLUMN \"fileSize\" SET DATA TYPE BIGINT;\n\n"
  },
  {
    "path": "prisma/migrations/20250806000000_add_billing_details_to_team/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"cancelledAt\" TIMESTAMP(3),\nADD COLUMN     \"pauseEndsAt\" TIMESTAMP(3),\nADD COLUMN     \"pauseStartsAt\" TIMESTAMP(3),\nADD COLUMN     \"pausedAt\" TIMESTAMP(3);\n\n"
  },
  {
    "path": "prisma/migrations/20250830000000_dataroom_faq_items_model/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"FaqStatus\" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');\n\n-- CreateEnum\nCREATE TYPE \"FaqVisibility\" AS ENUM ('PUBLIC_DATAROOM', 'PUBLIC_LINK', 'PUBLIC_DOCUMENT');\n\n-- CreateTable\nCREATE TABLE \"DataroomFaqItem\" (\n    \"id\" TEXT NOT NULL,\n    \"title\" TEXT,\n    \"editedQuestion\" TEXT NOT NULL,\n    \"originalQuestion\" TEXT,\n    \"answer\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"dataroomId\" TEXT NOT NULL,\n    \"linkId\" TEXT,\n    \"dataroomDocumentId\" TEXT,\n    \"sourceConversationId\" TEXT,\n    \"questionMessageId\" TEXT,\n    \"answerMessageId\" TEXT,\n    \"teamId\" TEXT NOT NULL,\n    \"publishedByUserId\" TEXT NOT NULL,\n    \"visibilityMode\" \"FaqVisibility\" NOT NULL DEFAULT 'PUBLIC_DATAROOM',\n    \"status\" \"FaqStatus\" NOT NULL DEFAULT 'PUBLISHED',\n    \"isAnonymized\" BOOLEAN NOT NULL DEFAULT true,\n    \"viewCount\" INTEGER NOT NULL DEFAULT 0,\n    \"tags\" TEXT[] DEFAULT ARRAY[]::TEXT[],\n    \"documentPageNumber\" INTEGER,\n    \"documentVersionNumber\" INTEGER,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"DataroomFaqItem_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_createdAt_idx\" ON \"DataroomFaqItem\"(\"createdAt\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_dataroomDocumentId_idx\" ON \"DataroomFaqItem\"(\"dataroomDocumentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_dataroomId_idx\" ON \"DataroomFaqItem\"(\"dataroomId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_linkId_idx\" ON \"DataroomFaqItem\"(\"linkId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_publishedByUserId_idx\" ON \"DataroomFaqItem\"(\"publishedByUserId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_sourceConversationId_idx\" ON \"DataroomFaqItem\"(\"sourceConversationId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_status_idx\" ON \"DataroomFaqItem\"(\"status\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_teamId_idx\" ON \"DataroomFaqItem\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DataroomFaqItem_visibilityMode_idx\" ON \"DataroomFaqItem\"(\"visibilityMode\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_answerMessageId_fkey\" FOREIGN KEY (\"answerMessageId\") REFERENCES \"Message\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_dataroomDocumentId_fkey\" FOREIGN KEY (\"dataroomDocumentId\") REFERENCES \"DataroomDocument\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_publishedByUserId_fkey\" FOREIGN KEY (\"publishedByUserId\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_questionMessageId_fkey\" FOREIGN KEY (\"questionMessageId\") REFERENCES \"Message\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_sourceConversationId_fkey\" FOREIGN KEY (\"sourceConversationId\") REFERENCES \"Conversation\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DataroomFaqItem\" ADD CONSTRAINT \"DataroomFaqItem_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250903000000_add_agreement_contenttype/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Agreement\" ADD COLUMN     \"contentType\" TEXT NOT NULL DEFAULT 'LINK';\n\n"
  },
  {
    "path": "prisma/migrations/20250905000000_add_hierarchical_index_to_dataroom/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"DataroomDocument\" ADD COLUMN     \"hierarchicalIndex\" TEXT;\n\n-- AlterTable\nALTER TABLE \"DataroomFolder\" ADD COLUMN     \"hierarchicalIndex\" TEXT;\n\n"
  },
  {
    "path": "prisma/migrations/20250913000000_add_integrations/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"InstalledIntegration\" (\n    \"id\" TEXT NOT NULL,\n    \"credentials\" JSONB,\n    \"enabled\" BOOLEAN NOT NULL DEFAULT true,\n    \"integrationId\" TEXT NOT NULL,\n    \"userId\" TEXT,\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n    \"configuration\" JSONB,\n\n    CONSTRAINT \"InstalledIntegration_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"Integration\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"slug\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"readme\" TEXT,\n    \"developer\" TEXT NOT NULL,\n    \"website\" TEXT NOT NULL,\n    \"logo\" TEXT,\n    \"screenshots\" JSONB,\n    \"verified\" BOOLEAN NOT NULL DEFAULT false,\n    \"installUrl\" TEXT,\n    \"category\" TEXT,\n    \"comingSoon\" BOOLEAN NOT NULL DEFAULT false,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Integration_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"InstalledIntegration_integrationId_idx\" ON \"InstalledIntegration\"(\"integrationId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"InstalledIntegration_teamId_idx\" ON \"InstalledIntegration\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"InstalledIntegration_teamId_integrationId_key\" ON \"InstalledIntegration\"(\"teamId\" ASC, \"integrationId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Integration_slug_key\" ON \"Integration\"(\"slug\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"InstalledIntegration\" ADD CONSTRAINT \"InstalledIntegration_integrationId_fkey\" FOREIGN KEY (\"integrationId\") REFERENCES \"Integration\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"InstalledIntegration\" ADD CONSTRAINT \"InstalledIntegration_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"InstalledIntegration\" ADD CONSTRAINT \"InstalledIntegration_userId_fkey\" FOREIGN KEY (\"userId\") REFERENCES \"User\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250915000000_add_annotations/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"AnnotationImage\" (\n    \"id\" TEXT NOT NULL,\n    \"filename\" TEXT NOT NULL,\n    \"url\" TEXT NOT NULL,\n    \"size\" INTEGER,\n    \"mimeType\" TEXT,\n    \"annotationId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"AnnotationImage_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"DocumentAnnotation\" (\n    \"id\" TEXT NOT NULL,\n    \"title\" TEXT NOT NULL,\n    \"content\" JSONB NOT NULL,\n    \"pages\" INTEGER[],\n    \"documentId\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"createdById\" TEXT NOT NULL,\n    \"isVisible\" BOOLEAN NOT NULL DEFAULT true,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"DocumentAnnotation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"AnnotationImage_annotationId_idx\" ON \"AnnotationImage\"(\"annotationId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentAnnotation_createdById_idx\" ON \"DocumentAnnotation\"(\"createdById\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentAnnotation_documentId_idx\" ON \"DocumentAnnotation\"(\"documentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentAnnotation_teamId_idx\" ON \"DocumentAnnotation\"(\"teamId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"AnnotationImage\" ADD CONSTRAINT \"AnnotationImage_annotationId_fkey\" FOREIGN KEY (\"annotationId\") REFERENCES \"DocumentAnnotation\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentAnnotation\" ADD CONSTRAINT \"DocumentAnnotation_createdById_fkey\" FOREIGN KEY (\"createdById\") REFERENCES \"User\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentAnnotation\" ADD CONSTRAINT \"DocumentAnnotation_documentId_fkey\" FOREIGN KEY (\"documentId\") REFERENCES \"Document\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"DocumentAnnotation\" ADD CONSTRAINT \"DocumentAnnotation_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20250915000000_add_performance_indexes/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"Document_teamId_folderId_idx\" ON \"Document\"(\"teamId\" ASC, \"folderId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Document_teamId_name_idx\" ON \"Document\"(\"teamId\" ASC, \"name\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentVersion_documentId_createdAt_idx\" ON \"DocumentVersion\"(\"documentId\" ASC, \"createdAt\" DESC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentVersion_documentId_isPrimary_idx\" ON \"DocumentVersion\"(\"documentId\" ASC, \"isPrimary\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Link_documentId_isArchived_idx\" ON \"Link\"(\"documentId\" ASC, \"isArchived\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Reaction_viewId_type_idx\" ON \"Reaction\"(\"viewId\" ASC, \"type\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"View_documentId_isArchived_idx\" ON \"View\"(\"documentId\" ASC, \"isArchived\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"View_documentId_viewedAt_idx\" ON \"View\"(\"documentId\" ASC, \"viewedAt\" DESC);\n\n-- CreateIndex\nCREATE INDEX \"View_viewerEmail_idx\" ON \"View\"(\"viewerEmail\" ASC);\n\n"
  },
  {
    "path": "prisma/migrations/20250917000000_add_performance_index_dataroom/migration.sql",
    "content": "-- CreateIndex\nCREATE INDEX \"DataroomDocument_documentId_idx\" ON \"DataroomDocument\"(\"documentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"DocumentVersion_documentId_isPrimary_createdAt_idx\" ON \"DocumentVersion\"(\"documentId\" ASC, \"isPrimary\" ASC, \"createdAt\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Link_permissionGroupId_idx\" ON \"Link\"(\"permissionGroupId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ViewerGroup_dataroomId_createdAt_idx\" ON \"ViewerGroup\"(\"dataroomId\" ASC, \"createdAt\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"View_groupId_idx\" ON \"View\"(\"groupId\" ASC);"
  },
  {
    "path": "prisma/migrations/20251007000000_add_welcome_message_to_brand/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Brand\" ADD COLUMN     \"welcomeMessage\" TEXT;"
  },
  {
    "path": "prisma/migrations/20251009000000_update-replicate-folders/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"replicateDataroomFolders\" BOOLEAN NOT NULL DEFAULT true;\n\n"
  },
  {
    "path": "prisma/migrations/20251017000000_add_show_banner_to_presets/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"LinkPreset\" ADD COLUMN \"showBanner\" BOOLEAN DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20251021000000_add_deleted_at_to_links/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"deletedAt\" TIMESTAMP(3);\n\n-- CreateIndex\nCREATE INDEX \"Link_deletedAt_idx\" ON \"Link\"(\"deletedAt\" ASC);\n\n"
  },
  {
    "path": "prisma/migrations/20251027000000_add_banner_to_brand/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Brand\" ADD COLUMN IF NOT EXISTS \"banner\" TEXT;\n\n-- AlterTable\nALTER TABLE \"Brand\" ADD COLUMN IF NOT EXISTS \"welcomeMessage\" TEXT;\n\n"
  },
  {
    "path": "prisma/migrations/20251031000000_add_message_to_link/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN     \"showLastUpdated\" BOOLEAN NOT NULL DEFAULT true;\n\n-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"welcomeMessage\" TEXT;\n\n-- AlterTable\nALTER TABLE \"LinkPreset\" ADD COLUMN     \"welcomeMessage\" TEXT;\n\n"
  },
  {
    "path": "prisma/migrations/20251111000000_add_viewer_invitations/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"InvitationStatus\" AS ENUM ('SENT', 'FAILED', 'BOUNCED');\n\n-- CreateTable\nCREATE TABLE \"ViewerInvitation\" (\n    \"id\" TEXT NOT NULL,\n    \"viewerId\" TEXT NOT NULL,\n    \"linkId\" TEXT NOT NULL,\n    \"groupId\" TEXT,\n    \"invitedBy\" TEXT NOT NULL,\n    \"customMessage\" TEXT,\n    \"sentAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"status\" \"InvitationStatus\" NOT NULL DEFAULT 'SENT',\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"ViewerInvitation_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"ViewerInvitation_viewerId_idx\" ON \"ViewerInvitation\"(\"viewerId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ViewerInvitation_linkId_idx\" ON \"ViewerInvitation\"(\"linkId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ViewerInvitation_groupId_idx\" ON \"ViewerInvitation\"(\"groupId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"ViewerInvitation\" ADD CONSTRAINT \"ViewerInvitation_viewerId_fkey\" FOREIGN KEY (\"viewerId\") REFERENCES \"Viewer\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ViewerInvitation\" ADD CONSTRAINT \"ViewerInvitation_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ViewerInvitation\" ADD CONSTRAINT \"ViewerInvitation_groupId_fkey\" FOREIGN KEY (\"groupId\") REFERENCES \"ViewerGroup\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n\n"
  },
  {
    "path": "prisma/migrations/20251116000000_add_workflow/migration.sql",
    "content": "-- CreateEnum\nCREATE TYPE \"ExecutionStatus\" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED', 'BLOCKED');\n\n-- CreateEnum\nCREATE TYPE \"WorkflowStepType\" AS ENUM ('ROUTER');\n\n-- AlterEnum\nALTER TYPE \"LinkType\" ADD VALUE 'WORKFLOW_LINK';\n\n-- CreateTable\nCREATE TABLE \"Workflow\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"description\" TEXT,\n    \"entryLinkId\" TEXT NOT NULL,\n    \"teamId\" TEXT NOT NULL,\n    \"isActive\" BOOLEAN NOT NULL DEFAULT true,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"Workflow_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"WorkflowExecution\" (\n    \"id\" TEXT NOT NULL,\n    \"workflowId\" TEXT NOT NULL,\n    \"visitorEmail\" TEXT,\n    \"visitorIp\" TEXT,\n    \"status\" \"ExecutionStatus\" NOT NULL,\n    \"startedAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"completedAt\" TIMESTAMP(3),\n    \"result\" JSONB,\n    \"metadata\" JSONB,\n\n    CONSTRAINT \"WorkflowExecution_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"WorkflowStep\" (\n    \"id\" TEXT NOT NULL,\n    \"workflowId\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"stepOrder\" INTEGER NOT NULL,\n    \"stepType\" \"WorkflowStepType\" NOT NULL DEFAULT 'ROUTER',\n    \"conditions\" JSONB NOT NULL,\n    \"actions\" JSONB NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"WorkflowStep_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"WorkflowStepLog\" (\n    \"id\" TEXT NOT NULL,\n    \"executionId\" TEXT NOT NULL,\n    \"workflowStepId\" TEXT NOT NULL,\n    \"conditionsMatched\" BOOLEAN NOT NULL,\n    \"conditionResults\" JSONB,\n    \"actionsExecuted\" JSONB,\n    \"executedAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"duration\" INTEGER,\n    \"error\" TEXT,\n\n    CONSTRAINT \"WorkflowStepLog_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"Workflow_entryLinkId_idx\" ON \"Workflow\"(\"entryLinkId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"Workflow_entryLinkId_key\" ON \"Workflow\"(\"entryLinkId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Workflow_isActive_idx\" ON \"Workflow\"(\"isActive\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Workflow_teamId_idx\" ON \"Workflow\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"WorkflowExecution_status_idx\" ON \"WorkflowExecution\"(\"status\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"WorkflowExecution_visitorEmail_idx\" ON \"WorkflowExecution\"(\"visitorEmail\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"WorkflowExecution_workflowId_startedAt_idx\" ON \"WorkflowExecution\"(\"workflowId\" ASC, \"startedAt\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"WorkflowStep_workflowId_idx\" ON \"WorkflowStep\"(\"workflowId\" ASC);\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"WorkflowStep_workflowId_stepOrder_key\" ON \"WorkflowStep\"(\"workflowId\" ASC, \"stepOrder\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"WorkflowStepLog_executionId_idx\" ON \"WorkflowStepLog\"(\"executionId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"WorkflowStepLog_workflowStepId_idx\" ON \"WorkflowStepLog\"(\"workflowStepId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"Workflow\" ADD CONSTRAINT \"Workflow_entryLinkId_fkey\" FOREIGN KEY (\"entryLinkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Workflow\" ADD CONSTRAINT \"Workflow_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"WorkflowExecution\" ADD CONSTRAINT \"WorkflowExecution_workflowId_fkey\" FOREIGN KEY (\"workflowId\") REFERENCES \"Workflow\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"WorkflowStep\" ADD CONSTRAINT \"WorkflowStep_workflowId_fkey\" FOREIGN KEY (\"workflowId\") REFERENCES \"Workflow\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"WorkflowStepLog\" ADD CONSTRAINT \"WorkflowStepLog_executionId_fkey\" FOREIGN KEY (\"executionId\") REFERENCES \"WorkflowExecution\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"WorkflowStepLog\" ADD CONSTRAINT \"WorkflowStepLog_workflowStepId_fkey\" FOREIGN KEY (\"workflowStepId\") REFERENCES \"WorkflowStep\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20251208000000_add_chat_model/migration.sql",
    "content": "-- DropIndex\nDROP INDEX \"Chat_threadId_documentId_key\";\n\n-- DropIndex\nDROP INDEX \"Chat_threadId_idx\";\n\n-- DropIndex\nDROP INDEX \"Chat_threadId_key\";\n\n-- DropIndex\nDROP INDEX \"Chat_userId_documentId_key\";\n\n-- AlterTable\nALTER TABLE \"Chat\" DROP COLUMN \"threadId\",\nADD COLUMN     \"dataroomId\" TEXT,\nADD COLUMN     \"linkId\" TEXT,\nADD COLUMN     \"teamId\" TEXT NOT NULL,\nADD COLUMN     \"title\" TEXT,\nADD COLUMN     \"updatedAt\" TIMESTAMP(3) NOT NULL,\nADD COLUMN     \"vectorStoreId\" TEXT,\nADD COLUMN     \"viewId\" TEXT,\nADD COLUMN     \"viewerId\" TEXT,\nALTER COLUMN \"userId\" DROP NOT NULL,\nALTER COLUMN \"documentId\" DROP NOT NULL;\n\n-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN     \"agentsEnabled\" BOOLEAN NOT NULL DEFAULT false,\nADD COLUMN     \"vectorStoreId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"DataroomDocument\" ADD COLUMN     \"vectorStoreFileId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN     \"agentsEnabled\" BOOLEAN NOT NULL DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"DocumentVersion\" ADD COLUMN     \"vectorStoreFileId\" TEXT;\n\n-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"enableAIAgents\" BOOLEAN DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"agentsEnabled\" BOOLEAN NOT NULL DEFAULT false,\nADD COLUMN     \"vectorStoreId\" TEXT;\n\n-- CreateTable\nCREATE TABLE \"ChatMessage\" (\n    \"id\" TEXT NOT NULL,\n    \"chatId\" TEXT NOT NULL,\n    \"role\" TEXT NOT NULL,\n    \"content\" TEXT NOT NULL,\n    \"metadata\" JSONB,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"ChatMessage_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"ChatMessage_chatId_createdAt_idx\" ON \"ChatMessage\"(\"chatId\" ASC, \"createdAt\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"ChatMessage_chatId_idx\" ON \"ChatMessage\"(\"chatId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_createdAt_idx\" ON \"Chat\"(\"createdAt\" DESC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_dataroomId_idx\" ON \"Chat\"(\"dataroomId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_documentId_idx\" ON \"Chat\"(\"documentId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_linkId_idx\" ON \"Chat\"(\"linkId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_teamId_idx\" ON \"Chat\"(\"teamId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_userId_idx\" ON \"Chat\"(\"userId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_viewId_idx\" ON \"Chat\"(\"viewId\" ASC);\n\n-- CreateIndex\nCREATE INDEX \"Chat_viewerId_idx\" ON \"Chat\"(\"viewerId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"Chat\" ADD CONSTRAINT \"Chat_dataroomId_fkey\" FOREIGN KEY (\"dataroomId\") REFERENCES \"Dataroom\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Chat\" ADD CONSTRAINT \"Chat_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Chat\" ADD CONSTRAINT \"Chat_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Chat\" ADD CONSTRAINT \"Chat_viewId_fkey\" FOREIGN KEY (\"viewId\") REFERENCES \"View\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"Chat\" ADD CONSTRAINT \"Chat_viewerId_fkey\" FOREIGN KEY (\"viewerId\") REFERENCES \"Viewer\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"ChatMessage\" ADD CONSTRAINT \"ChatMessage_chatId_fkey\" FOREIGN KEY (\"chatId\") REFERENCES \"Chat\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20251209000000_update_view_model/migration.sql",
    "content": "-- DropForeignKey\nALTER TABLE \"View\" DROP CONSTRAINT \"View_linkId_fkey\";\n\n-- AddForeignKey\nALTER TABLE \"View\" ADD CONSTRAINT \"View_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20251230000000_add_timezone_to_team/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN \"timezone\" TEXT NOT NULL DEFAULT 'Etc/UTC';\n"
  },
  {
    "path": "prisma/migrations/20260113000000_add_hidden_in_all_documents/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Document\" ADD COLUMN \"hiddenInAllDocuments\" BOOLEAN NOT NULL DEFAULT false;\n\n-- AlterTable\nALTER TABLE \"Folder\" ADD COLUMN \"hiddenInAllDocuments\" BOOLEAN NOT NULL DEFAULT false;\n\n-- CreateIndex\nCREATE INDEX \"Document_teamId_hiddenInAllDocuments_idx\" ON \"Document\"(\"teamId\", \"hiddenInAllDocuments\");\n\n-- CreateIndex\nCREATE INDEX \"Folder_teamId_hiddenInAllDocuments_idx\" ON \"Folder\"(\"teamId\", \"hiddenInAllDocuments\");\n"
  },
  {
    "path": "prisma/migrations/20260113000000_add_internal_name_to_dataroom/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN     \"internalName\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20260120000000_add_folder_icon_color/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Folder\" ADD COLUMN \"icon\" TEXT;\nALTER TABLE \"Folder\" ADD COLUMN \"color\" TEXT;\n\n-- AlterTable\nALTER TABLE \"DataroomFolder\" ADD COLUMN \"icon\" TEXT;\nALTER TABLE \"DataroomFolder\" ADD COLUMN \"color\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20260204000000_add_introduction_page/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Dataroom\" ADD COLUMN     \"introductionContent\" JSONB,\nADD COLUMN     \"introductionEnabled\" BOOLEAN NOT NULL DEFAULT false;\n\n"
  },
  {
    "path": "prisma/migrations/20260210000000_add_saml_scim_to_team/migration.sql",
    "content": "-- AlterTable: Add slug and SSO fields to Team\nALTER TABLE \"Team\" ADD COLUMN \"slug\" TEXT;\nALTER TABLE \"Team\" ADD COLUMN \"ssoEnabled\" BOOLEAN NOT NULL DEFAULT false;\nALTER TABLE \"Team\" ADD COLUMN \"ssoEmailDomain\" TEXT;\nALTER TABLE \"Team\" ADD COLUMN \"ssoEnforcedAt\" TIMESTAMP(3);\n\n-- CreateIndex: unique slug for SSO login\nCREATE UNIQUE INDEX \"Team_slug_key\" ON \"Team\"(\"slug\");\n\n-- CreateIndex: unique email domain for SSO\nCREATE UNIQUE INDEX \"Team_ssoEmailDomain_key\" ON \"Team\"(\"ssoEmailDomain\");\n\n-- CreateTable: Jackson SAML/SCIM internal tables\nCREATE TABLE \"jackson_index\" (\n    \"id\" SERIAL NOT NULL,\n    \"key\" TEXT NOT NULL,\n    \"storeKey\" TEXT NOT NULL,\n\n    CONSTRAINT \"jackson_index_pkey\" PRIMARY KEY (\"id\")\n);\n\nCREATE TABLE \"jackson_store\" (\n    \"key\" TEXT NOT NULL,\n    \"value\" TEXT NOT NULL,\n    \"iv\" TEXT,\n    \"tag\" TEXT,\n    \"namespace\" TEXT,\n    \"createdAt\" TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"modifiedAt\" TIMESTAMP(0),\n\n    CONSTRAINT \"jackson_store_pkey\" PRIMARY KEY (\"key\")\n);\n\nCREATE TABLE \"jackson_ttl\" (\n    \"key\" TEXT NOT NULL,\n    \"expiresAt\" BIGINT NOT NULL,\n\n    CONSTRAINT \"jackson_ttl_pkey\" PRIMARY KEY (\"key\")\n);\n\n-- CreateIndex\nCREATE INDEX \"_jackson_index_key_store\" ON \"jackson_index\"(\"key\", \"storeKey\");\n\n-- CreateIndex\nCREATE INDEX \"_jackson_store_namespace\" ON \"jackson_store\"(\"namespace\");\n\n-- CreateIndex\nCREATE INDEX \"_jackson_ttl_expires_at\" ON \"jackson_ttl\"(\"expiresAt\");\n"
  },
  {
    "path": "prisma/migrations/20260212000000_add_owner_to_link/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Link\" ADD COLUMN     \"ownerId\" TEXT;\n\n-- CreateIndex\nCREATE INDEX \"Link_ownerId_idx\" ON \"Link\"(\"ownerId\" ASC);\n\n-- AddForeignKey\nALTER TABLE \"Link\" ADD CONSTRAINT \"Link_ownerId_fkey\" FOREIGN KEY (\"ownerId\") REFERENCES \"User\"(\"id\") ON DELETE SET NULL ON UPDATE CASCADE;\n\n"
  },
  {
    "path": "prisma/migrations/20260212000000_add_visitor_groups/migration.sql",
    "content": "-- CreateTable\nCREATE TABLE \"VisitorGroup\" (\n    \"id\" TEXT NOT NULL,\n    \"name\" TEXT NOT NULL,\n    \"emails\" TEXT[],\n    \"teamId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    \"updatedAt\" TIMESTAMP(3) NOT NULL,\n\n    CONSTRAINT \"VisitorGroup_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateTable\nCREATE TABLE \"LinkVisitorGroup\" (\n    \"id\" TEXT NOT NULL,\n    \"linkId\" TEXT NOT NULL,\n    \"visitorGroupId\" TEXT NOT NULL,\n    \"createdAt\" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n    CONSTRAINT \"LinkVisitorGroup_pkey\" PRIMARY KEY (\"id\")\n);\n\n-- CreateIndex\nCREATE INDEX \"VisitorGroup_teamId_idx\" ON \"VisitorGroup\"(\"teamId\");\n\n-- CreateIndex\nCREATE INDEX \"LinkVisitorGroup_linkId_idx\" ON \"LinkVisitorGroup\"(\"linkId\");\n\n-- CreateIndex\nCREATE INDEX \"LinkVisitorGroup_visitorGroupId_idx\" ON \"LinkVisitorGroup\"(\"visitorGroupId\");\n\n-- CreateIndex\nCREATE UNIQUE INDEX \"LinkVisitorGroup_linkId_visitorGroupId_key\" ON \"LinkVisitorGroup\"(\"linkId\", \"visitorGroupId\");\n\n-- AddForeignKey\nALTER TABLE \"VisitorGroup\" ADD CONSTRAINT \"VisitorGroup_teamId_fkey\" FOREIGN KEY (\"teamId\") REFERENCES \"Team\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"LinkVisitorGroup\" ADD CONSTRAINT \"LinkVisitorGroup_linkId_fkey\" FOREIGN KEY (\"linkId\") REFERENCES \"Link\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n\n-- AddForeignKey\nALTER TABLE \"LinkVisitorGroup\" ADD CONSTRAINT \"LinkVisitorGroup_visitorGroupId_fkey\" FOREIGN KEY (\"visitorGroupId\") REFERENCES \"VisitorGroup\"(\"id\") ON DELETE CASCADE ON UPDATE CASCADE;\n"
  },
  {
    "path": "prisma/migrations/20260219171000_add_dataroom_bg_toggle_to_brand/migration.sql",
    "content": "-- Add toggle to control whether accentColor applies to dataroom viewer\nALTER TABLE \"DataroomBrand\"\nADD COLUMN \"applyAccentColorToDataroomView\" BOOLEAN NOT NULL DEFAULT false;\n\n-- Add global toggle to control whether accentColor applies to dataroom viewer by default\nALTER TABLE \"Brand\"\nADD COLUMN \"applyAccentColorToDataroomView\" BOOLEAN NOT NULL DEFAULT false;\n"
  },
  {
    "path": "prisma/migrations/20260220000000_add_redirect_url_to_domain/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Domain\" ADD COLUMN     \"redirectUrl\" TEXT;\n"
  },
  {
    "path": "prisma/migrations/20260227000000_add_survey_data_to_team/migration.sql",
    "content": "-- AlterTable\nALTER TABLE \"Team\" ADD COLUMN     \"surveyData\" JSONB;\n\n"
  },
  {
    "path": "prisma/migrations/migration_lock.toml",
    "content": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"postgresql\""
  },
  {
    "path": "prisma/schema/annotation.prisma",
    "content": "model DocumentAnnotation {\n  id           String   @id @default(cuid())\n  title        String\n  content      Json     // Rich text content stored as JSON (Tiptap/ProseMirror format)\n  pages        Int[]    // Array of page numbers this annotation applies to\n  documentId   String\n  document     Document @relation(fields: [documentId], references: [id], onDelete: Cascade)\n  teamId       String\n  team         Team     @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  createdById  String\n  createdBy    User     @relation(fields: [createdById], references: [id], onDelete: Cascade)\n  isVisible    Boolean  @default(true) // Allow admin to hide/show annotations\n  images       AnnotationImage[] // Images attached to this annotation\n  createdAt    DateTime @default(now())\n  updatedAt    DateTime @updatedAt\n\n  @@index([documentId])\n  @@index([teamId])\n  @@index([createdById])\n}\n\nmodel AnnotationImage {\n  id           String             @id @default(cuid())\n  filename     String\n  url          String\n  size         Int?\n  mimeType     String?\n  annotationId String\n  annotation   DocumentAnnotation @relation(fields: [annotationId], references: [id], onDelete: Cascade)\n  createdAt    DateTime           @default(now())\n\n  @@index([annotationId])\n}\n"
  },
  {
    "path": "prisma/schema/conversation.prisma",
    "content": "model Conversation {\n  id        String  @id @default(cuid())\n  title     String? // Optional title for the conversation\n  isEnabled Boolean @default(true)\n\n  // Visibility control\n  visibilityMode ConversationVisibility @default(PRIVATE)\n\n  // Core relationships\n  dataroomId String\n  dataroom   Dataroom @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n\n  // Optional attachments\n  dataroomDocumentId    String?\n  dataroomDocument      DataroomDocument? @relation(fields: [dataroomDocumentId], references: [id], onDelete: SetNull)\n  documentVersionNumber Int? // Optional document version number reference\n  documentPageNumber    Int? // Optional document page number reference\n\n  // Optional link relationship\n  linkId String?\n  link   Link?   @relation(fields: [linkId], references: [id], onDelete: SetNull)\n\n  // Optional viewer group relationship\n  viewerGroupId String?\n  viewerGroup   ViewerGroup? @relation(fields: [viewerGroupId], references: [id], onDelete: SetNull)\n\n  // Original view that initiated the conversation (for reference)\n  initialViewId String?\n  initialView   View?   @relation(name: \"initialView\", fields: [initialViewId], references: [id], onDelete: SetNull)\n\n  teamId String\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  // Track all views that accessed this conversation\n  views ConversationView[]\n\n  // Track participants including the owner\n  participants ConversationParticipant[]\n\n  // Track conversations\n  messages      Message[]\n  lastMessageAt DateTime? // Last message timestamp\n\n  // Published FAQs created from this conversation\n  faqItems DataroomFaqItem[]\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([dataroomId])\n  @@index([dataroomDocumentId])\n  @@index([linkId])\n  @@index([teamId])\n  @@index([viewerGroupId])\n  @@index([initialViewId])\n}\n\n// Define conversation visibility options\nenum ConversationVisibility {\n  PRIVATE // Only visible to participants and team members\n  PUBLIC_LINK // Visible to all viewers with access to the specific link\n  PUBLIC_GROUP // Visible to all viewers in the specific group\n  PUBLIC_DOCUMENT // Visible to all viewers with access to the document, across any link\n  PUBLIC_DATAROOM // Visible to all viewers with access to the dataroom\n}\n\n// Define participant roles\nenum ParticipantRole {\n  OWNER // Created the conversation\n  PARTICIPANT // Joined the conversation later\n}\n\n// Track participants in a conversation (including the owner)\nmodel ConversationParticipant {\n  id             String       @id @default(cuid())\n  conversationId String\n  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n\n  // Role in the conversation\n  role ParticipantRole @default(PARTICIPANT)\n\n  // Participant can be either a viewer or a user\n  viewerId String?\n  viewer   Viewer? @relation(fields: [viewerId], references: [id], onDelete: SetNull)\n  userId   String?\n  user     User?   @relation(fields: [userId], references: [id], onDelete: SetNull)\n\n  // Notification preferences\n  receiveNotifications Boolean @default(false)\n\n  createdAt DateTime @default(now())\n\n  @@unique([conversationId, viewerId])\n  @@unique([conversationId, userId])\n  @@index([conversationId])\n  @@index([viewerId])\n  @@index([userId])\n}\n\nmodel Message {\n  id      String @id @default(cuid())\n  content String // The actual message content\n\n  // Conversation relationship\n  conversationId String\n  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n\n  // Sender information\n  userId String? // Optional - for team members\n  user   User?   @relation(fields: [userId], references: [id], onDelete: SetNull)\n\n  viewerId String? // Optional - for viewers\n  viewer   Viewer? @relation(fields: [viewerId], references: [id], onDelete: SetNull)\n\n  // The specific view when this message was sent (for tracking)\n  viewId String?\n  view   View?   @relation(fields: [viewId], references: [id], onDelete: SetNull)\n\n  // Tracking\n  isRead Boolean @default(false)\n\n  // FAQ relationships (reverse relations)\n  faqAsQuestion DataroomFaqItem[] @relation(\"FAQQuestionMessage\")\n  faqAsAnswer   DataroomFaqItem[] @relation(\"FAQAnswerMessage\")\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([conversationId])\n  @@index([userId])\n  @@index([viewerId])\n  @@index([viewId])\n}\n\n// Join table to track all views that accessed a conversation\nmodel ConversationView {\n  id             String       @id @default(cuid())\n  conversationId String\n  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)\n  viewId         String\n  view           View         @relation(fields: [viewId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n\n  @@unique([conversationId, viewId])\n  @@index([conversationId])\n  @@index([viewId])\n}\n\n// Published FAQ items for datarooms\nmodel DataroomFaqItem {\n  id               String  @id @default(cuid())\n  title            String? // Optional title for the FAQ\n  editedQuestion   String // Admin-edited version of the question\n  originalQuestion String? // Original question from visitor (for reference)\n  answer           String // The answer content\n  description      String? // Optional context or description\n\n  // Relationships\n  dataroomId String\n  dataroom   Dataroom @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n\n  linkId String? // Optional: specific link visibility\n  link   Link?   @relation(fields: [linkId], references: [id], onDelete: SetNull)\n\n  dataroomDocumentId String? // Optional: document-specific FAQ\n  dataroomDocument   DataroomDocument? @relation(fields: [dataroomDocumentId], references: [id], onDelete: SetNull)\n\n  // Source conversation and messages (for reference and editing)\n  sourceConversationId String? // Original conversation\n  sourceConversation   Conversation? @relation(fields: [sourceConversationId], references: [id], onDelete: SetNull)\n\n  questionMessageId String? // Reference to original question message\n  questionMessage   Message? @relation(name: \"FAQQuestionMessage\", fields: [questionMessageId], references: [id], onDelete: SetNull)\n\n  answerMessageId String? // Reference to answer message\n  answerMessage   Message? @relation(name: \"FAQAnswerMessage\", fields: [answerMessageId], references: [id], onDelete: SetNull)\n\n  // Publishing details\n  teamId String\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  publishedByUserId String\n  publishedByUser   User   @relation(fields: [publishedByUserId], references: [id], onDelete: Cascade)\n\n  // Visibility and status\n  visibilityMode FaqVisibility @default(PUBLIC_DATAROOM)\n  status         FaqStatus     @default(PUBLISHED)\n  isAnonymized   Boolean       @default(true)\n\n  // Analytics\n  viewCount Int @default(0)\n\n  // Optional categorization\n  tags String[] @default([])\n\n  // Metadata\n  documentPageNumber    Int? // Optional: specific page reference\n  documentVersionNumber Int? // Optional: specific version reference\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([dataroomId])\n  @@index([linkId])\n  @@index([dataroomDocumentId])\n  @@index([sourceConversationId])\n  @@index([teamId])\n  @@index([publishedByUserId])\n  @@index([status])\n  @@index([visibilityMode])\n  @@index([createdAt])\n}\n\n// Define FAQ visibility options\nenum FaqVisibility {\n  PUBLIC_DATAROOM // Visible to all dataroom visitors\n  PUBLIC_LINK // Visible only to specific link visitors\n  PUBLIC_DOCUMENT // Visible only when viewing specific document\n}\n\n// Define FAQ status\nenum FaqStatus {\n  DRAFT // Not yet published\n  PUBLISHED // Live and visible to visitors\n  ARCHIVED // No longer visible but kept for reference\n}\n"
  },
  {
    "path": "prisma/schema/dataroom.prisma",
    "content": "model Dataroom {\n  id           String             @id @default(cuid())\n  pId          String             @unique // This is the generated public ID for the dataroom dr_1234\n  name         String\n  internalName String?            // Private, internal name/alias visible only to the dataroom owner\n  description  String?\n  teamId       String\n  team         Team               @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  documents    DataroomDocument[]\n  folders      DataroomFolder[]\n  links        Link[]\n  views        View[]\n  viewers      Viewer[]\n  viewerGroups ViewerGroup[]\n  brand        DataroomBrand?\n\n  permissionGroups PermissionGroup[]\n\n  // conversation\n  conversationsEnabled Boolean        @default(false)\n  conversations        Conversation[]\n\n  // FAQ system\n  faqItems DataroomFaqItem[]\n\n  // AI agents\n  agentsEnabled Boolean @default(false) // Enable AI agents for this dataroom\n  vectorStoreId String? // OpenAI vector store ID for AI search\n  chats         Chat[]\n\n  // upload external documents\n  uploadedDocuments DocumentUpload[]\n\n  // tags\n  tags TagItem[]\n\n  // notification settings\n  enableChangeNotifications Boolean @default(false)\n\n  // unified permission strategy\n  defaultPermissionStrategy DefaultPermissionStrategy @default(INHERIT_FROM_PARENT)\n\n  // bulk download setting\n  allowBulkDownload Boolean @default(true) // Allow bulk download of entire dataroom\n\n  // display settings\n  showLastUpdated Boolean @default(true) // Show/hide last updated date in dataroom view\n\n  // Introduction page settings\n  introductionEnabled Boolean @default(false) // Toggle to show introduction on first visit\n  introductionContent Json?   // TipTap JSON content for the introduction page\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([teamId])\n}\n\nmodel DataroomDocument {\n  id                String          @id @default(cuid())\n  dataroomId        String\n  dataroom          Dataroom        @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n  documentId        String\n  document          Document        @relation(fields: [documentId], references: [id], onDelete: Cascade)\n  folderId          String?\n  folder            DataroomFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)\n  orderIndex        Int?\n  hierarchicalIndex String? // Computed field like \"1.2.3\" for hierarchical display\n\n  conversations Conversation[]\n\n  // FAQ system\n  dataroomFAQs DataroomFaqItem[]\n\n  uploadedDocuments DocumentUpload[]\n\n  vectorStoreFileId String? // vector store file ID for AI search\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([dataroomId, documentId])\n  @@index([folderId])\n  @@index([dataroomId, folderId, orderIndex])\n  @@index([documentId])\n}\n\nmodel DataroomFolder {\n  id                String             @id @default(cuid())\n  name              String\n  path              String // the materialized path to the folder; starts always with \"/\"\n  parentId          String?\n  icon              String? // Icon identifier from predefined icon set (e.g., \"folder\", \"briefcase\", \"archive\")\n  color             String? // Color from predefined palette (e.g., \"gray\", \"red\", \"blue\")\n  documents         DataroomDocument[]\n  childFolders      DataroomFolder[]   @relation(\"SubFolders\")\n  parentFolder      DataroomFolder?    @relation(\"SubFolders\", fields: [parentId], references: [id], onDelete: SetNull)\n  dataroomId        String\n  dataroom          Dataroom           @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n  orderIndex        Int?\n  hierarchicalIndex String? // Computed field like \"1.2\" for hierarchical display\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([dataroomId, path])\n  @@index([parentId])\n  @@index([dataroomId, parentId, orderIndex])\n}\n\nmodel DataroomBrand {\n  id             String   @id @default(cuid())\n  logo           String? // This should be a reference to where the file is stored (S3, Google Cloud Storage, etc.)\n  banner         String? // This should be a reference to where the file is stored (S3, Google Cloud Storage, etc.)\n  brandColor     String? // This should be a reference to the brand color\n  accentColor    String? // This should be a reference to the accent color\n  applyAccentColorToDataroomView Boolean @default(false) // When true, apply accentColor to dataroom list/tree viewer background\n  welcomeMessage String? // This should be a reference to the welcome message\n  dataroomId     String   @unique\n  dataroom       Dataroom @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n\nmodel ViewerGroup {\n  id             String                      @id @default(cuid())\n  name           String\n  members        ViewerGroupMembership[]\n  domains        String[]\n  links          Link[]\n  accessControls ViewerGroupAccessControls[]\n  allowAll       Boolean                     @default(false)\n\n  dataroomId String\n  dataroom   Dataroom @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n  teamId     String\n  team       Team     @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  views         View[]\n  conversations Conversation[]\n  invitations   ViewerInvitation[]\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([dataroomId])\n  @@index([teamId])\n  @@index([dataroomId, createdAt])\n}\n\nmodel ViewerGroupMembership {\n  id       String      @id @default(cuid())\n  viewerId String\n  viewer   Viewer      @relation(fields: [viewerId], references: [id], onDelete: Cascade)\n  groupId  String\n  group    ViewerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([viewerId, groupId])\n  @@index([viewerId])\n  @@index([groupId])\n}\n\nmodel ViewerGroupAccessControls {\n  id      String      @id @default(cuid())\n  groupId String\n  group   ViewerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)\n\n  // Access control for items (documents or dataroom items)\n  itemId   String // This can be a document ID or a dataroom item ID\n  itemType ItemType // Enum: DATAROOM_DOCUMENT, DATAROOM_FOLDER\n\n  // Granular permissions\n  canView     Boolean @default(true)\n  canDownload Boolean @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([groupId, itemId])\n  @@index([groupId])\n}\n\nenum ItemType {\n  DATAROOM_DOCUMENT\n  DATAROOM_FOLDER\n}\n\nenum DefaultPermissionStrategy {\n  INHERIT_FROM_PARENT\n  ASK_EVERY_TIME\n  HIDDEN_BY_DEFAULT\n}\n\nmodel PermissionGroup {\n  id          String  @id @default(cuid())\n  name        String\n  description String?\n\n  links          Link[]\n  accessControls PermissionGroupAccessControls[]\n\n  dataroomId String\n  dataroom   Dataroom @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n  teamId     String\n  team       Team     @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([dataroomId])\n  @@index([teamId])\n}\n\nmodel PermissionGroupAccessControls {\n  id      String          @id @default(cuid())\n  groupId String\n  group   PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)\n\n  // Access control for items (documents or dataroom items)\n  itemId   String // This can be a document ID or a dataroom item ID\n  itemType ItemType // Enum: DATAROOM_DOCUMENT, DATAROOM_FOLDER\n\n  // Granular permissions\n  canView             Boolean @default(true)\n  canDownload         Boolean @default(false)\n  canDownloadOriginal Boolean @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([groupId, itemId])\n  @@index([groupId])\n}\n"
  },
  {
    "path": "prisma/schema/document.prisma",
    "content": "model Document {\n  id                   String              @id @default(cuid())\n  name                 String\n  description          String?\n  file                 String // This should be a reference to where the file is stored (S3, Google Cloud Storage, etc.)\n  originalFile         String? // This should be a reference to the original file like pptx, xlsx, etc. (S3, Google Cloud Storage, etc.)\n  type                 String? // This should be a reference to the file type (pdf, sheet, etc.)\n  contentType          String? // This should be the actual contentType of the file like application/pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, etc.\n  storageType          DocumentStorageType @default(VERCEL_BLOB)\n  numPages             Int? // This should be a reference to the number of pages in the document\n  owner                User?               @relation(fields: [ownerId], references: [id], onDelete: SetNull)\n  teamId               String\n  team                 Team                @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  ownerId              String? // This field holds the foreign key.\n  assistantEnabled     Boolean             @default(false) // This indicates if assistant is enabled for this document\n  advancedExcelEnabled Boolean             @default(false) // This indicates if advanced Excel is enabled for this document\n  agentsEnabled        Boolean             @default(false) // This indicates if AI agents are enabled for this document\n  downloadOnly         Boolean             @default(false) // Indicates if the document is download only\n  hiddenInAllDocuments Boolean             @default(false) // Indicates if the document is hidden from All Documents view\n  createdAt            DateTime            @default(now())\n  updatedAt            DateTime            @updatedAt\n  links                Link[]\n  views                View[]\n  versions             DocumentVersion[]\n  chats                Chat[]\n\n  folderId String? // Optional Folder ID for documents in folders\n  folder   Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)\n\n  datarooms DataroomDocument[] // Datarooms associated with this document\n  tags      TagItem[]\n  annotations DocumentAnnotation[] // Annotations for this document\n\n  // upload external documents\n  uploadedDocument DocumentUpload[]\n  isExternalUpload Boolean          @default(false) // Indicates if the document is an external upload\n\n  @@index([ownerId])\n  @@index([teamId])\n  @@index([folderId])\n  @@index([teamId, folderId]) // Performance optimization for document filtering by team and folder\n  @@index([teamId, name]) // Performance optimization for document search by name\n  @@index([teamId, hiddenInAllDocuments]) // Performance optimization for filtering hidden documents\n}\n\nmodel DocumentVersion {\n  id            String              @id @default(cuid())\n  versionNumber Int // e.g., 1, 2, 3 for version control\n  document      Document            @relation(fields: [documentId], references: [id], onDelete: Cascade)\n  documentId    String\n  file          String // This should be a reference to where the file is stored (S3, Google Cloud Storage, etc.)\n  originalFile  String? // This should be a reference to the original file like pptx, xlsx, etc. (S3, Google Cloud Storage, etc.)\n  type          String? // This should be a reference to the file type (pdf, docx, etc.)\n  contentType   String? // This should be the actual contentType of the file like application/pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, etc.\n  fileSize      BigInt? // This should be the size of the file in bytes\n  storageType   DocumentStorageType @default(VERCEL_BLOB)\n  numPages      Int? // This should be a reference to the number of pages in the document\n  isPrimary        Boolean @default(false) // Indicates if this is the primary version\n  isVertical       Boolean @default(false) // Indicates if the document is vertical (portrait) or not (landscape)\n  fileId           String? // This is the file ID of the OpenAI File API\n  vectorStoreFileId String? // OpenAI vector store file ID for AI search\n  pages            DocumentPage[]\n  hasPages         Boolean @default(false) // Indicates if the document has pages\n  length        Int? // This is the length of the video in seconds\n  createdAt     DateTime            @default(now())\n  updatedAt     DateTime            @updatedAt\n\n  @@unique([versionNumber, documentId])\n  @@index([documentId])\n  @@index([documentId, isPrimary]) // Performance optimization for primary version queries\n  @@index([documentId, createdAt(sort: Desc)]) // Partial index for primary versions\n  @@index([documentId, isPrimary, createdAt]) // Optimize primary version lookups with ordering\n}\n\nmodel DocumentPage {\n  id            String              @id @default(cuid())\n  version       DocumentVersion     @relation(fields: [versionId], references: [id], onDelete: Cascade)\n  versionId     String\n  pageNumber    Int // e.g., 1, 2, 3 for \n  embeddedLinks String[]\n  pageLinks     Json? // This will store the page links data: [{href: \"https://example.com\", coords: \"0,0,100,100\"}]\n  metadata      Json? // This will store the page metadata: {originalWidth: 100, origianlHeight: 100, scaledWidth: 50, scaledHeight: 50, scaleFactor: 2}\n  file          String // This should be a reference to where the file / page is stored (S3, Google Cloud Storage, etc.)\n  storageType   DocumentStorageType @default(VERCEL_BLOB)\n  createdAt     DateTime            @default(now())\n  updatedAt     DateTime            @updatedAt\n\n  @@unique([pageNumber, versionId])\n  @@index([versionId])\n}\n\nenum DocumentStorageType {\n  S3_PATH\n  VERCEL_BLOB\n}\n\nmodel Folder {\n  id                   String     @id @default(cuid())\n  name                 String\n  path                 String // the materialized path to the folder; starts always with \"/\"\n  parentId             String?\n  hiddenInAllDocuments Boolean    @default(false) // Indicates if the folder is hidden from All Documents view\n  icon                 String? // Icon identifier from predefined icon set (e.g., \"folder\", \"briefcase\", \"archive\")\n  color                String? // Color from predefined palette (e.g., \"gray\", \"red\", \"blue\")\n  documents            Document[]\n  childFolders         Folder[]   @relation(\"SubFolders\")\n  parentFolder         Folder?    @relation(\"SubFolders\", fields: [parentId], references: [id], onDelete: SetNull)\n  teamId               String\n  team                 Team       @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([teamId, path])\n  @@index([parentId])\n  @@index([teamId, hiddenInAllDocuments]) // Performance optimization for filtering hidden folders\n}\n\nmodel DocumentUpload {\n  id         String   @id @default(cuid())\n  documentId String\n  document   Document @relation(fields: [documentId], references: [id], onDelete: Cascade)\n  teamId     String\n  team       Team     @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  viewerId   String?\n  viewer     Viewer?  @relation(fields: [viewerId], references: [id], onDelete: SetNull)\n  viewId     String?\n  view       View?    @relation(fields: [viewId], references: [id], onDelete: SetNull)\n  linkId     String\n  link       Link     @relation(fields: [linkId], references: [id], onDelete: Cascade)\n\n  // Optional dataroom relations\n  dataroomId         String?\n  dataroom           Dataroom?         @relation(fields: [dataroomId], references: [id], onDelete: SetNull)\n  dataroomDocumentId String?\n  dataroomDocument   DataroomDocument? @relation(fields: [dataroomDocumentId], references: [id], onDelete: SetNull)\n\n  // Additional metadata\n  originalFilename String?\n  fileSize         BigInt?\n  numPages         Int?\n  mimeType         String?\n  uploadedAt       DateTime @default(now())\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([documentId])\n  @@index([viewerId])\n  @@index([viewId])\n  @@index([linkId])\n  @@index([teamId])\n  @@index([dataroomId])\n  @@index([dataroomDocumentId])\n}\n"
  },
  {
    "path": "prisma/schema/integration.prisma",
    "content": "model Integration {\n  id          String   @id @default(cuid())\n  name        String\n  slug        String   @unique\n  description String?\n  readme      String?\n  developer   String\n  website     String\n  logo        String?\n  screenshots Json?\n  verified    Boolean  @default(false)\n  installUrl  String?\n  category    String?\n  comingSoon  Boolean  @default(false)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  installedIntegrations InstalledIntegration[]\n}\n\nmodel InstalledIntegration {\n  id            String  @id @default(cuid())\n  credentials   Json?\n  configuration Json?\n  enabled       Boolean @default(true)\n\n  integrationId String\n  integration   Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n  userId        String? // user who installed the integration\n  user          User?       @relation(fields: [userId], references: [id], onDelete: SetNull)\n  teamId        String // team where the integration was installed\n  team          Team        @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([teamId, integrationId])\n  @@index([teamId])\n  @@index([integrationId])\n}\n"
  },
  {
    "path": "prisma/schema/jackson.prisma",
    "content": "// Jackson SAML/SCIM tables\n// These are managed by @boxyhq/saml-jackson internally.\n// We define them here so Prisma is aware of the tables and\n// migrations stay in sync with the main app schema.\n\nmodel jackson_index {\n  id       Int    @id @default(autoincrement())\n  key      String\n  storeKey String\n\n  @@index([key, storeKey], map: \"_jackson_index_key_store\")\n}\n\nmodel jackson_store {\n  key        String    @id\n  value      String\n  iv         String?\n  tag        String?\n  namespace  String?\n  createdAt  DateTime  @default(now()) @db.Timestamp(0)\n  modifiedAt DateTime? @db.Timestamp(0)\n\n  @@index([namespace], map: \"_jackson_store_namespace\")\n}\n\nmodel jackson_ttl {\n  key       String @id\n  expiresAt BigInt\n\n  @@index([expiresAt], map: \"_jackson_ttl_expires_at\")\n}\n"
  },
  {
    "path": "prisma/schema/link.prisma",
    "content": "enum LinkType {\n  DOCUMENT_LINK\n  DATAROOM_LINK\n  WORKFLOW_LINK\n}\n\nenum LinkAudienceType {\n  GENERAL\n  GROUP\n  TEAM\n}\n\nmodel Link {\n  id                         String     @id @default(cuid())\n  document                   Document?  @relation(fields: [documentId], references: [id], onDelete: Cascade)\n  documentId                 String? // This can be nullable, representing links without documents\n  dataroom                   Dataroom?  @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n  dataroomId                 String? // This can be nullable, representing links without datarooms\n  linkType                   LinkType   @default(DOCUMENT_LINK) // This will store the type of the link\n  url                        String?    @unique\n  name                       String? // Link name\n  slug                       String? // Link slug for pretty URLs\n  expiresAt                  DateTime? // Optional expiration date\n  password                   String? // Optional password for link protection\n  allowList                  String[] // Array of emails and domains allowed to view the document\n  denyList                   String[] // Array of emails and domains denied to view the document\n  emailProtected             Boolean    @default(true) // Optional email protection\n  emailAuthenticated         Boolean    @default(false) // Optional email authentication flag\n  allowDownload              Boolean?   @default(false) // Optional give user a option to allow to download the document\n  isArchived                 Boolean    @default(false) // Indicates if the link is archived\n  deletedAt                  DateTime? // Soft delete timestamp\n  views                      View[]\n  domain                     Domain?    @relation(fields: [domainId], references: [id], onDelete: SetNull)\n  domainId                   String? // This can be nullable, representing links without custom domains\n  domainSlug                 String? // This will store the domain's slug even if the domain is deleted\n  createdAt                  DateTime   @default(now())\n  updatedAt                  DateTime   @updatedAt\n  enableNotification         Boolean?   @default(true) // Optional give user a option to pause/resume the notifications\n  enableFeedback             Boolean?   @default(false) // Optional give user a option to enable the reactions toolbar\n  enableQuestion             Boolean?   @default(false) // Optional give user a option to enable the question feedback\n  enableScreenshotProtection Boolean?   @default(false) // Optional give user a option to enable the screenshot protection\n  feedback                   Feedback?\n  enableAgreement            Boolean?   @default(false) // Optional give user a option to enable the terms and conditions\n  agreement                  Agreement? @relation(fields: [agreementId], references: [id], onDelete: SetNull)\n  agreementId                String? // This can be nullable, representing links without agreements\n  showBanner                 Boolean?   @default(false) // Optional give user a option to show the banner and end of document signup form\n  enableWatermark            Boolean?   @default(false) // Optional give user a option to enable the watermark\n  watermarkConfig            Json? // This will store the watermark configuration: {text: \"Confidential\", isTiled: false, color: \"#000000\", fontSize: 12, opacity: 0.5, rotation: 30, position: \"top-right\"}\n\n  // group links\n  audienceType LinkAudienceType @default(GENERAL) // This will store the audience type of the link\n  groupId      String?\n  group        ViewerGroup?     @relation(fields: [groupId], references: [id], onDelete: SetNull)\n\n  // granular permissions\n  permissionGroupId String?\n  permissionGroup   PermissionGroup? @relation(fields: [permissionGroupId], references: [id], onDelete: SetNull)\n\n  // custom metatags\n  metaTitle           String? // This will be the meta title of the link\n  metaDescription     String? // This will be the meta description of the link\n  metaImage           String? // This will be the meta image of the link\n  metaFavicon         String? // This will be the meta favicon of the link\n  enableCustomMetatag Boolean? @default(false) // Optional give user a option to enable the custom metatag\n\n  // custom welcome message (overrides brand welcome message)\n  welcomeMessage String? // Custom welcome message for this specific link\n\n  // conversation\n  enableConversation Boolean        @default(false) // Controls if conversations are allowed on this link\n  conversations      Conversation[]\n\n  // FAQ system\n  dataroomFaqItems DataroomFaqItem[]\n\n  // AI chats\n  enableAIAgents Boolean? @default(false) // Enable AI agents for this link\n  chats          Chat[]\n\n  // upload\n  enableUpload      Boolean? @default(false) // Optional give user a option to enable the upload document function\n  isFileRequestOnly Boolean? @default(false) // Optional give user a option to enable the file request only\n  uploadFolderId    String? // This can be nullable, indicating upload to root folder, either document folder or dataroom folder\n  enableIndexFile   Boolean? @default(false)\n\n  uploadedDocuments DocumentUpload[]\n\n  teamId String?\n  team   Team?   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  ownerId String?\n  owner   User?   @relation(\"LinkOwner\", fields: [ownerId], references: [id], onDelete: SetNull)\n\n  customFields CustomField[]\n\n  tags TagItem[]\n  viewerInvitations ViewerInvitation[]\n\n  // visitor groups for allow-list\n  visitorGroups LinkVisitorGroup[]\n\n  // workflow entry link\n  workflow Workflow?\n\n  @@unique([domainSlug, slug])\n  @@index([documentId])\n  @@index([teamId])\n  @@index([documentId, isArchived]) // Performance optimization for active links filtering\n  @@index([permissionGroupId]) // Performance optimization for permission group queries\n  @@index([deletedAt]) // Performance optimization for filtering deleted links\n  @@index([ownerId]) // Performance optimization for link owner queries\n}\n\nmodel LinkPreset {\n  id     String  @id @default(cuid())\n  name   String\n  teamId String\n  team   Team    @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  pId    String? @unique\n\n  enableCustomMetaTag Boolean? @default(false) // Optional give user a option to enable the custom metatag\n  metaTitle           String? // This will be the meta title of the link\n  metaDescription     String? // This will be the meta description of the link\n  metaImage           String? // This will be the meta image of the link \n  metaFavicon         String? // This will be the meta favicon of the link\n\n  enableNotification         Boolean?  @default(false)\n  emailProtected             Boolean?  @default(true)\n  emailAuthenticated         Boolean?  @default(false)\n  allowDownload              Boolean?  @default(false)\n  enableAllowList            Boolean?  @default(false)\n  allowList                  String[]\n  enableDenyList             Boolean?  @default(false)\n  denyList                   String[]\n  expiresIn                  Int? // how many days from link creation until it expires in seconds\n  enableScreenshotProtection Boolean?  @default(false)\n  expiresAt                  DateTime?\n  enablePassword             Boolean?  @default(false)\n  password                   String?\n  enableWatermark            Boolean?  @default(false)\n  watermarkConfig            Json? //{text: \"Confidential\", isTiled: false, color: \"#000000\", fontSize: 12, opacity: 0.5, rotation: 30, position: \"top-right\"}\n\n  enableAgreement Boolean? @default(false)\n  agreementId     String?\n\n  enableCustomFields Boolean? @default(false)\n  customFields       Json? //[{type: \"SHORT_TEXT\", identifier: \"name\", label: \"Name\", required: true}]\n\n  showBanner Boolean? @default(false) // Optional give user a option to show the \"Powered by Papermark\" banner\n\n  welcomeMessage String? // Custom welcome message for links created from this preset\n\n  isDefault Boolean @default(false)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([teamId])\n}\n\nenum CustomFieldType {\n  SHORT_TEXT\n  LONG_TEXT\n  NUMBER\n  PHONE_NUMBER\n  URL\n  CHECKBOX\n  SELECT\n  MULTI_SELECT\n}\n\nmodel VisitorGroup {\n  id     String   @id @default(cuid())\n  name   String\n  emails String[] // List of emails and domains (e.g., \"user@example.com\", \"@example.org\")\n\n  teamId String\n  team   Team @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  links LinkVisitorGroup[]\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([teamId])\n}\n\nmodel LinkVisitorGroup {\n  id             String       @id @default(cuid())\n  linkId         String\n  link           Link         @relation(fields: [linkId], references: [id], onDelete: Cascade)\n  visitorGroupId String\n  visitorGroup   VisitorGroup @relation(fields: [visitorGroupId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n\n  @@unique([linkId, visitorGroupId])\n  @@index([linkId])\n  @@index([visitorGroupId])\n}\n\nmodel CustomField {\n  id          String          @id @default(cuid())\n  createdAt   DateTime        @default(now())\n  updatedAt   DateTime        @updatedAt\n  type        CustomFieldType\n  identifier  String\n  label       String\n  placeholder String?\n  required    Boolean         @default(false)\n  disabled    Boolean         @default(false)\n  link        Link            @relation(fields: [linkId], references: [id], onDelete: Cascade)\n  linkId      String\n  orderIndex  Int             @default(0)\n\n  @@index([linkId])\n}\n\nmodel CustomFieldResponse {\n  id        String   @id @default(cuid())\n  data      Json // Store the custom field responses as a JSON object [{ \"identifier\": \"value\", \"label\": \"value\", \"response:\" }]\n  viewId    String   @unique\n  view      View     @relation(fields: [viewId], references: [id], onDelete: Cascade)\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([viewId])\n}\n"
  },
  {
    "path": "prisma/schema/schema.prisma",
    "content": "datasource db {\n  provider          = \"postgresql\"\n  url               = env(\"POSTGRES_PRISMA_URL\") // uses connection pooling\n  directUrl         = env(\"POSTGRES_PRISMA_URL_NON_POOLING\") // uses a direct connection\n  shadowDatabaseUrl = env(\"POSTGRES_PRISMA_SHADOW_URL\") // used for migrations\n}\n\ngenerator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"relationJoins\", \"prismaSchemaFolder\"]\n}\n\nmodel Account {\n  id                String  @id @default(cuid())\n  userId            String\n  type              String\n  provider          String\n  providerAccountId String\n  refresh_token     String? @db.Text\n  access_token      String? @db.Text\n  expires_at        Int?\n  token_type        String?\n  scope             String?\n  id_token          String? @db.Text\n  session_state     String?\n\n  user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  @@unique([provider, providerAccountId])\n}\n\nmodel Session {\n  id           String   @id @default(cuid())\n  sessionToken String   @unique\n  userId       String\n  expires      DateTime\n  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n  id             String     @id @default(cuid())\n  name           String?\n  email          String?    @unique\n  emailVerified  DateTime?\n  image          String?\n  createdAt      DateTime   @default(now())\n  accounts       Account[]\n  sessions       Session[]\n  documents      Document[]\n  teams          UserTeam[]\n  domains        Domain[]\n  chats          Chat[]\n  contactId      String?\n  plan           String     @default(\"free\")\n  stripeId       String?    @unique // Stripe subscription / customer ID\n  subscriptionId String?    @unique // Stripe subscription ID\n  startsAt       DateTime? // Stripe subscription start date\n  endsAt         DateTime? // Stripe subscription end date\n\n  restrictedTokens RestrictedToken[]\n\n  // conversation\n  participatedConversations ConversationParticipant[]\n  messages                  Message[]\n\n  // FAQ system\n  publishedFaqItems DataroomFaqItem[]\n  createdAnnotations DocumentAnnotation[] // Annotations created by this user\n\n  installedIntegrations InstalledIntegration[]\n\n  links Link[] @relation(\"LinkOwner\")\n}\n\nmodel Brand {\n  id             String  @id @default(cuid())\n  logo           String? // This should be a reference to where the file is stored (S3, Google Cloud Storage, etc.)\n  banner         String? // Banner image for dataroom view (fallback)\n  brandColor     String? // This should be a reference to the brand color\n  accentColor    String? // This should be a reference to the accent color\n  applyAccentColorToDataroomView Boolean @default(false) // Global default for whether accentColor applies to dataroom list/tree viewer background\n  welcomeMessage String? // This should be a reference to the welcome message\n  teamId         String  @unique\n  team           Team    @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n}\n\nmodel VerificationToken {\n  identifier String\n  token      String   @unique\n  expires    DateTime\n\n  @@unique([identifier, token])\n}\n\nmodel Domain {\n  id          String   @id @default(cuid())\n  slug        String   @unique\n  user        User?    @relation(fields: [userId], references: [id], onDelete: SetNull)\n  userId      String?\n  teamId      String\n  Team        Team     @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  verified    Boolean  @default(false) // Whether the domain has been verified\n  isDefault   Boolean  @default(false) // Whether the domain is the primary domain\n  redirectUrl String?  // URL to redirect to when visiting the root of this domain\n  lastChecked DateTime @default(now())\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n  links       Link[] // links associated with this domain\n\n  @@index([userId])\n  @@index([teamId])\n}\n\nmodel View {\n  id                  String               @id @default(cuid())\n  link                Link                 @relation(fields: [linkId], references: [id], onDelete: Cascade)\n  linkId              String\n  document            Document?            @relation(fields: [documentId], references: [id], onDelete: Cascade)\n  documentId          String?\n  dataroom            Dataroom?            @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n  dataroomId          String?\n  dataroomViewId      String? // This is the view ID from the dataroom\n  viewerEmail         String? // Email of the viewer if known\n  viewerName          String? // Name of the viewer if known\n  verified            Boolean              @default(false) // Whether the viewer email has been verified\n  viewedAt            DateTime             @default(now())\n  downloadedAt        DateTime? // This is the time the document was downloaded\n  downloadType        DownloadType? // Type of download: SINGLE, BULK, or FOLDER\n  downloadMetadata    Json? // Metadata about the download (folder name, document list, etc.)\n  reactions           Reaction[]\n  viewType            ViewType             @default(DOCUMENT_VIEW)\n  viewerId            String? // This is the viewer ID from the dataroom\n  viewer              Viewer?              @relation(fields: [viewerId], references: [id], onDelete: Cascade)\n  groupId             String? // This is the group ID from the dataroom\n  group               ViewerGroup?         @relation(fields: [groupId], references: [id], onDelete: SetNull)\n  feedbackResponse    FeedbackResponse?\n  agreementResponse   AgreementResponse?\n  customFieldResponse CustomFieldResponse?\n\n  isArchived Boolean @default(false) // Indicates if the view is archived and not counted in the analytics\n\n  // conversation\n  conversationViews    ConversationView[]\n  messages             Message[]\n  initialConversations Conversation[]     @relation(\"initialView\")\n\n  uploadedDocuments DocumentUpload[] // uploaded documents by this view\n\n  // AI chats\n  chats Chat[]\n\n  teamId String?\n  team   Team?   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  @@index([linkId])\n  @@index([documentId])\n  @@index([dataroomId])\n  @@index([dataroomViewId])\n  @@index([viewerId])\n  @@index([groupId]) // Performance optimization for groupBy queries on groupId\n  @@index([teamId])\n  @@index([viewedAt(sort: Desc)]) // Performance optimization for date aggregations\n  @@index([viewerId, documentId]) // Performance optimization for joins with filtering\n  @@index([viewerEmail]) // Performance optimization for viewer email filtering\n  @@index([documentId, isArchived]) // Performance optimization for active views filtering\n  @@index([documentId, viewedAt(sort: Desc)]) // Performance optimization for latest views queries\n}\n\nenum ViewType {\n  DOCUMENT_VIEW\n  DATAROOM_VIEW\n}\n\nenum DownloadType {\n  SINGLE  // Individual document download\n  BULK    // Full dataroom bulk download\n  FOLDER  // Folder download\n}\n\nmodel Viewer {\n  id                      String    @id @default(cuid())\n  email                   String\n  verified                Boolean   @default(false) // Whether the viewer email has been verified\n  invitedAt               DateTime? // This is the time the viewer was invited\n  notificationPreferences Json? // Format: { dataroom: {\"dr_123\": { \"enabled\": false }, \"dr_456\": { \"enabled\": true } } } }\n\n  dataroomId String?\n  dataroom   Dataroom? @relation(fields: [dataroomId], references: [id], onDelete: SetNull)\n  teamId     String\n  team       Team      @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  views                     View[]\n  groups                    ViewerGroupMembership[]\n  invitations               ViewerInvitation[]\n  participatedConversations ConversationParticipant[]\n  messages                  Message[]\n\n  uploadedDocuments DocumentUpload[] // uploaded documents by this viewer\n\n  // AI chats\n  chats Chat[]\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([teamId, email])\n  @@index([teamId])\n  @@index([dataroomId])\n}\n\nmodel Reaction {\n  id         String   @id @default(cuid())\n  view       View     @relation(fields: [viewId], references: [id], onDelete: Cascade)\n  viewId     String\n  pageNumber Int\n  type       String // e.g., \"like\", \"dislike\", \"love\", \"hate\", etc.\n  createdAt  DateTime @default(now())\n\n  @@index([viewId])\n  @@index([viewId, type]) // Performance optimization for reaction grouping\n}\n\nmodel Invitation {\n  email     String\n  expires   DateTime\n  teamId    String\n  team      Team     @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  createdAt DateTime @default(now())\n  token     String   @unique\n\n  @@unique([email, teamId])\n}\n\nenum EmailType {\n  FIRST_DAY_DOMAIN_REMINDER_EMAIL\n  FIRST_DOMAIN_INVALID_EMAIL\n  SECOND_DOMAIN_INVALID_EMAIL\n  FIRST_TRIAL_END_REMINDER_EMAIL\n  FINAL_TRIAL_END_REMINDER_EMAIL\n}\n\nmodel SentEmail {\n  id         String    @id @default(cuid())\n  type       EmailType\n  recipient  String // Email address of the recipient\n  marketing  Boolean   @default(false)\n  createdAt  DateTime  @default(now())\n  team       Team      @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  teamId     String\n  domainSlug String? // Domain that triggered the email. This can be nullable, representing emails not triggered by domains\n\n  @@index([teamId])\n}\n\nmodel Chat {\n  id        String   @id @default(cuid())\n  title     String? // Generated title from first message\n\n  // Context associations\n  teamId String\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  documentId String?\n  document   Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)\n\n  dataroomId String?\n  dataroom   Dataroom? @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n\n  linkId String?\n  link   Link?   @relation(fields: [linkId], references: [id], onDelete: Cascade)\n\n  viewId String?\n  view   View?   @relation(fields: [viewId], references: [id], onDelete: Cascade)\n\n  // User associations (internal or external)\n  userId String?\n  user   User?   @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n  viewerId String?\n  viewer   Viewer? @relation(fields: [viewerId], references: [id], onDelete: Cascade)\n\n  // OpenAI references\n  vectorStoreId String? // The vector store used for this chat\n\n  messages ChatMessage[]\n\n  createdAt     DateTime  @default(now())\n  updatedAt     DateTime  @updatedAt\n  lastMessageAt DateTime? // Track latest activity\n\n  @@index([teamId])\n  @@index([documentId])\n  @@index([dataroomId])\n  @@index([linkId])\n  @@index([userId])\n  @@index([viewerId])\n  @@index([viewId])\n  @@index([createdAt(sort: Desc)])\n}\n\nmodel ChatMessage {\n  id     String @id @default(cuid())\n  chatId String\n  chat   Chat   @relation(fields: [chatId], references: [id], onDelete: Cascade)\n\n  role    String // \"user\" | \"assistant\" | \"system\"\n  content String @db.Text\n\n  // Optional structured data\n  metadata Json? // Store sources, page numbers, confidence scores, etc.\n\n  createdAt DateTime @default(now())\n\n  @@index([chatId])\n  @@index([chatId, createdAt])\n}\n\nmodel Feedback {\n  id     String @id @default(cuid())\n  linkId String @unique\n  link   Link   @relation(fields: [linkId], references: [id], onDelete: Cascade)\n  data   Json // This will store the feedback question data: {question: \"What is the purpose of this document?\", type: \"yes/no\", options: [\"Yes\", \"No\"]}\n\n  responses FeedbackResponse[]\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([linkId])\n}\n\nmodel FeedbackResponse {\n  id         String   @id @default(cuid())\n  feedbackId String\n  feedback   Feedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)\n  data       Json // This will store the feedback question data: {question: \"What is the purpose of this document?\", type: \"yes/no\", options: [\"Yes\", \"No\"], answer: \"Yes\"}\n  viewId     String   @unique\n  view       View     @relation(fields: [viewId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([feedbackId])\n  @@index([viewId])\n}\n\nmodel Agreement {\n  id          String @id @default(cuid())\n  name        String // Easily identifiable name for the agreement\n  content     String // This will store the agreement content (URL or text)\n  contentType String @default(\"LINK\") // \"LINK\" or \"TEXT\" - determines how content should be displayed\n\n  links     Link[]\n  responses AgreementResponse[]\n\n  requireName Boolean @default(true) // Optional require name field\n\n  teamId String\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  deletedAt DateTime?\n  deletedBy String?\n\n  @@index([teamId])\n}\n\nmodel AgreementResponse {\n  id          String    @id @default(cuid())\n  agreementId String\n  agreement   Agreement @relation(fields: [agreementId], references: [id], onDelete: Cascade)\n  viewId      String    @unique\n  view        View      @relation(fields: [viewId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([agreementId])\n  @@index([viewId])\n}\n\nmodel IncomingWebhook {\n  id                  String    @id @default(cuid())\n  externalId          String    @unique\n  name                String\n  secret              String? // Webhook signing secret for verification\n  source              String? // Allowed source URL/domain\n  actions             String? // comma separated (Eg: \"documents:write,documentVersions:write\")\n  consecutiveFailures Int       @default(0)\n  lastFailedAt        DateTime?\n  disabledAt          DateTime?\n\n  teamId String\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([teamId])\n}\n\nmodel RestrictedToken {\n  id         String    @id @default(cuid())\n  name       String\n  hashedKey  String    @unique\n  partialKey String\n  scopes     String? // comma separated (Eg: \"documents:write,links:write\")\n  expires    DateTime?\n  lastUsed   DateTime?\n  rateLimit  Int       @default(60) // rate limit per minute\n\n  userId String\n  teamId String\n  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([userId])\n  @@index([teamId])\n}\n\nmodel Webhook {\n  id       String @id @default(cuid())\n  pId      String @unique // public ID for the webhook\n  name     String\n  url      String\n  secret   String // signing secret for the webhook\n  triggers Json\n\n  teamId String\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([teamId])\n}\n\nmodel YearInReview {\n  id            String    @id @default(cuid())\n  teamId        String\n  status        String    @default(\"pending\") // pending, processing, completed, failed\n  attempts      Int       @default(0)\n  lastAttempted DateTime?\n  error         String?\n\n  stats Json\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([status, attempts])\n  @@index([teamId])\n}\n\nenum TagType {\n  LINK_TAG\n  DOCUMENT_TAG\n  DATAROOM_TAG\n}\n\nmodel Tag {\n  id          String  @id @default(cuid())\n  name        String\n  color       String\n  description String?\n\n  teamId String\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  items TagItem[]\n\n  createdBy String?\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@unique([teamId, name])\n  @@index([teamId])\n  @@index([name])\n  @@index([id])\n}\n\nmodel TagItem {\n  id       String  @id @default(cuid())\n  tagId    String\n  tag      Tag     @relation(fields: [tagId], references: [id], onDelete: Cascade)\n  itemType TagType\n\n  // tag can be linked to a link, document or dataroom\n  linkId     String?\n  link       Link?     @relation(fields: [linkId], references: [id], onDelete: Cascade)\n  documentId String?\n  document   Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)\n  dataroomId String?\n  dataroom   Dataroom? @relation(fields: [dataroomId], references: [id], onDelete: Cascade)\n\n  taggedBy  String?\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  @@index([tagId, linkId])\n  @@index([tagId, documentId])\n  @@index([tagId, dataroomId])\n}\n\nmodel ViewerInvitation {\n  id            String             @id @default(cuid())\n  viewerId      String\n  viewer        Viewer             @relation(fields: [viewerId], references: [id], onDelete: Cascade)\n  linkId        String\n  link          Link               @relation(fields: [linkId], references: [id], onDelete: Cascade)\n  groupId       String?\n  group         ViewerGroup?       @relation(fields: [groupId], references: [id], onDelete: SetNull)\n  invitedBy     String\n  customMessage String?\n  sentAt        DateTime           @default(now())\n  status        InvitationStatus   @default(SENT)\n\n  createdAt DateTime @default(now())\n\n  @@index([viewerId])\n  @@index([linkId])\n  @@index([groupId])\n}\n\nenum InvitationStatus {\n  SENT\n  FAILED\n  BOUNCED\n}\n"
  },
  {
    "path": "prisma/schema/team.prisma",
    "content": "model Team {\n  id           String        @id @default(cuid())\n  name         String\n  slug         String?       @unique // Optional human-readable identifier (e.g. \"acme\"), used for SSO login\n  users        UserTeam[]\n  documents    Document[]\n  folders      Folder[]\n  domains      Domain[]\n  invitations  Invitation[]\n  sentEmails   SentEmail[]\n  brand        Brand?\n  datarooms    Dataroom[]\n  agreements   Agreement[]\n  viewerGroups  ViewerGroup[]\n  visitorGroups VisitorGroup[]\n  viewers       Viewer[]\n\n  permissionGroups  PermissionGroup[]\n  linkPresets       LinkPreset[] // Link presets for the team\n  incomingWebhooks  IncomingWebhook[]\n  restrictedTokens  RestrictedToken[]\n  webhooks          Webhook[]\n  conversations     Conversation[]\n  dataroomFaqItems  DataroomFaqItem[]\n  uploadedDocuments DocumentUpload[]\n  Tag               Tag[]\n  annotations       DocumentAnnotation[] // Annotations created by team members\n\n  installedIntegrations InstalledIntegration[]\n\n  links     Link[]\n  views     View[]\n  workflows Workflow[]\n\n  plan           String    @default(\"free\")\n  stripeId       String?   @unique // Stripe customer ID\n  subscriptionId String?   @unique // Stripe subscription ID\n  startsAt       DateTime? // Stripe subscription start date\n  endsAt         DateTime? // Stripe subscription end date\n  pausedAt       DateTime? // When the subscription was paused\n  pauseStartsAt  DateTime? // When the pause period starts\n  pauseEndsAt    DateTime? // When the pause period ends\n  cancelledAt    DateTime? // When the subscription was cancelled\n\n  limits Json? // Plan limits // {datarooms: 1, users: 1, domains: 1, customDomainOnPro: boolean, customDomainInDataroom: boolean}\n\n  // team settings\n  enableExcelAdvancedMode  Boolean @default(false) // Enable Excel advanced mode for all documents in the team\n  replicateDataroomFolders Boolean @default(true) // Replicate dataroom folder structure in \"All Documents\"\n  timezone                 String  @default(\"Etc/UTC\") // Team-wide timezone for analytics display (IANA timezone identifier)\n\n  // SSO (SAML/SCIM via BoxyHQ Jackson)\n  ssoEnabled     Boolean   @default(false) // Feature flag: team has access to SSO\n  ssoEmailDomain String?   @unique // Email domain enforced for SSO (e.g. \"acme.com\")\n  ssoEnforcedAt  DateTime? // When set, all users with this domain MUST use SSO\n\n  // Survey data (stored as JSON for flexibility)\n  surveyData Json? // { dealType?: string, dealSize?: string, ... }\n\n  // AI agents\n  agentsEnabled Boolean @default(false) // Enable AI agents for the team\n  vectorStoreId String? // OpenAI vector store ID for team-level document search\n  chats         Chat[]\n\n  createdAt DateTime @default(now())\n  updatedAt DateTime @updatedAt\n\n  ignoredDomains  String[] // Domains that are ignored for the team\n  globalBlockList String[] // Email and domains that are blocked for the team\n}\n\nenum Role {\n  ADMIN\n  MANAGER\n  MEMBER\n}\n\nmodel UserTeam {\n  role   Role   @default(MEMBER)\n  status String @default(\"ACTIVE\")\n  userId String\n  teamId String\n  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)\n  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)\n\n  blockedAt               DateTime? // When the user was blocked\n  notificationPreferences Json? // Format: { yearInReview: { \"enabled\": false } }\n\n  @@id([userId, teamId])\n  @@index([userId])\n  @@index([teamId])\n}\n"
  },
  {
    "path": "prisma/schema/workflow.prisma",
    "content": "// Workflow models for routing visitors based on email/domain rules\n\nenum WorkflowStepType {\n  ROUTER // Route to different links based on conditions\n}\n\nenum ExecutionStatus {\n  PENDING\n  IN_PROGRESS\n  COMPLETED\n  FAILED\n  BLOCKED\n}\n\nmodel Workflow {\n  id          String   @id @default(cuid())\n  name        String\n  description String?\n  entryLinkId String   @unique\n  teamId      String\n  isActive    Boolean  @default(true)\n  createdAt   DateTime @default(now())\n  updatedAt   DateTime @updatedAt\n\n  entryLink  Link               @relation(fields: [entryLinkId], references: [id], onDelete: Cascade)\n  team       Team               @relation(fields: [teamId], references: [id], onDelete: Cascade)\n  steps      WorkflowStep[]\n  executions WorkflowExecution[]\n\n  @@index([entryLinkId])\n  @@index([teamId])\n  @@index([isActive])\n}\n\nmodel WorkflowStep {\n  id         String           @id @default(cuid())\n  workflowId String\n  name       String\n  stepOrder  Int              // Execution order (priority-based routing)\n  stepType   WorkflowStepType @default(ROUTER)\n  conditions Json             // JSON array of conditions to evaluate\n  actions    Json             // JSON array of actions to execute\n  createdAt  DateTime         @default(now())\n  updatedAt  DateTime         @updatedAt\n\n  workflow      Workflow          @relation(fields: [workflowId], references: [id], onDelete: Cascade)\n  executionLogs WorkflowStepLog[]\n\n  @@unique([workflowId, stepOrder])\n  @@index([workflowId])\n}\n\nmodel WorkflowExecution {\n  id           String          @id @default(cuid())\n  workflowId   String\n  visitorEmail String?\n  visitorIp    String?\n  status       ExecutionStatus\n  startedAt    DateTime        @default(now())\n  completedAt  DateTime?\n  result       Json?           // Final result/output (routing target)\n  metadata     Json?           // Context data (referrer, user agent, etc.)\n\n  workflow Workflow          @relation(fields: [workflowId], references: [id], onDelete: Cascade)\n  stepLogs WorkflowStepLog[]\n\n  @@index([workflowId, startedAt])\n  @@index([visitorEmail])\n  @@index([status])\n}\n\nmodel WorkflowStepLog {\n  id                String    @id @default(cuid())\n  executionId       String\n  workflowStepId    String\n  conditionsMatched Boolean\n  conditionResults  Json?     // Which conditions passed/failed\n  actionsExecuted   Json?     // Which actions ran\n  executedAt        DateTime  @default(now())\n  duration          Int?      // Execution time in ms\n  error             String?\n\n  execution WorkflowExecution @relation(fields: [executionId], references: [id], onDelete: Cascade)\n  step      WorkflowStep      @relation(fields: [workflowStepId], references: [id], onDelete: Cascade)\n\n  @@index([executionId])\n  @@index([workflowStepId])\n}\n\n"
  },
  {
    "path": "styles/custom-notion-styles.css",
    "content": "/* \n * Notion layout:\n * - Regular content (text, headings, lists): 708px max\n * - Tables and wide elements: up to 990px on desktop\n */\n:root {\n  --notion-max-width: 990px;\n}\n\n.dark-mode {\n  --bg-color: rgb(25, 25, 25);\n}\n\n/* \n * Constrain regular content blocks to 708px (Notion's default text width).\n * Tables and collections will use the full 990px container width.\n */\n.notion-text,\n.notion-h1,\n.notion-h2,\n.notion-h3,\n.notion-hr,\n.notion-quote,\n.notion-callout,\n.notion-toggle,\n.notion-bulleted_list,\n.notion-numbered_list,\n.notion-to_do,\n.notion-list,\n.notion-code,\n.notion-bookmark,\n.notion-column_list {\n  max-width: 708px;\n}\n\n/* Tables size to content, scroll horizontally if needed */\n.notion-simple-table {\n  display: block;\n  max-width: 100%;\n  overflow-x: auto;\n  -webkit-overflow-scrolling: touch;\n}\n\n.notion-simple-table > table {\n  table-layout: auto;\n  width: auto; /* Size to content, don't force full width */\n}\n\n/* Give table cells proper spacing */\n.notion-simple-table td {\n  padding: 8px 10px;\n}\n\n/* On mobile, everything uses full width */\n@media (max-width: 768px) {\n  .notion-text,\n  .notion-h1,\n  .notion-h2,\n  .notion-h3,\n  .notion-hr,\n  .notion-quote,\n  .notion-callout,\n  .notion-toggle,\n  .notion-bulleted_list,\n  .notion-numbered_list,\n  .notion-to_do,\n  .notion-list,\n  .notion-code,\n  .notion-bookmark,\n  .notion-column_list {\n    max-width: 100%;\n  }\n}\n\n/* Disable user selection for all text content (default) */\n.notion-page,\n.notion-text,\n.notion-h1,\n.notion-h2,\n.notion-h3,\n.notion-h4,\n.notion-h5,\n.notion-h6,\n.notion-header,\n.notion-sub_header,\n.notion-sub_sub_header,\n.notion-quote,\n.notion-callout,\n.notion-toggle,\n.notion-list,\n.notion-numbered-list,\n.notion-to_do,\n.notion-code,\n.notion-simple-table,\n.notion-table,\n.notion-column-list,\n.notion-bookmark,\n.notion-embed,\n.notion-database {\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n}\n\n/* Re-enable user selection when text selection is allowed */\n.notion-text-selection-enabled .notion-page,\n.notion-text-selection-enabled .notion-text,\n.notion-text-selection-enabled .notion-h1,\n.notion-text-selection-enabled .notion-h2,\n.notion-text-selection-enabled .notion-h3,\n.notion-text-selection-enabled .notion-h4,\n.notion-text-selection-enabled .notion-h5,\n.notion-text-selection-enabled .notion-h6,\n.notion-text-selection-enabled .notion-header,\n.notion-text-selection-enabled .notion-sub_header,\n.notion-text-selection-enabled .notion-sub_sub_header,\n.notion-text-selection-enabled .notion-quote,\n.notion-text-selection-enabled .notion-callout,\n.notion-text-selection-enabled .notion-toggle,\n.notion-text-selection-enabled .notion-list,\n.notion-text-selection-enabled .notion-numbered-list,\n.notion-text-selection-enabled .notion-to_do,\n.notion-text-selection-enabled .notion-code,\n.notion-text-selection-enabled .notion-simple-table,\n.notion-text-selection-enabled .notion-table,\n.notion-text-selection-enabled .notion-column-list,\n.notion-text-selection-enabled .notion-bookmark,\n.notion-text-selection-enabled .notion-embed,\n.notion-text-selection-enabled .notion-database {\n  -webkit-user-select: text;\n  -moz-user-select: text;\n  -ms-user-select: text;\n  user-select: text;\n}\n\n/* Disable image dragging */\n.notion-image,\n.notion-asset,\nimg {\n  /* Prevent long-press context menu on mobile */\n  -webkit-touch-callout: none;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n\n  /* Prevent image highlighting */\n  -webkit-tap-highlight-color: transparent;\n\n  /* Disable pointer events for saving */\n  pointer-events: auto;\n}\n\n/* Re-enable pointer events for links to keep them clickable */\n.notion-link,\n.notion-link *,\na,\na * {\n  pointer-events: auto !important;\n  cursor: pointer !important;\n}\n\n/* Ensure buttons and interactive elements remain clickable */\nbutton,\ninput,\nselect,\ntextarea,\n.notion-toggle-button,\n.notion-page-icon {\n  pointer-events: auto !important;\n  cursor: pointer !important;\n}\n\n/* Prevent page-level horizontal overflow */\n.notion-frame {\n  max-width: 100%;\n  overflow-x: hidden;\n}\n\n/* Allow notion-page to show icon without clipping, content handles its own overflow */\n.notion-page {\n  max-width: 100%;\n}\n\n.notion-page-content {\n  max-width: 100%;\n  overflow-x: hidden;\n}\n\n/* Table overflow handling - make tables horizontally scrollable */\n.notion-table {\n  width: 100%;\n  display: block;\n  overflow-x: auto;\n  -webkit-overflow-scrolling: touch;\n  max-width: 100%;\n}\n\n/* Ensure table content can scroll when needed */\n.notion-table > table {\n  min-width: max-content;\n}\n\n.notion-simple-table-row td {\n  vertical-align: top;\n}\n\n/* Collection/Database table wrapper for horizontal scroll */\n.notion-collection {\n  max-width: 100%;\n  overflow-x: auto;\n  -webkit-overflow-scrolling: touch;\n}\n\n.notion-collection-table {\n  overflow-x: auto;\n  -webkit-overflow-scrolling: touch;\n  max-width: 100%;\n}\n\n.notion-collection-table-wrapper {\n  overflow-x: auto;\n  -webkit-overflow-scrolling: touch;\n  max-width: 100%;\n}\n\n/* Ensure the table body allows scrolling */\n.notion-collection-table-body {\n  overflow-x: auto;\n  -webkit-overflow-scrolling: touch;\n}\n\n/* Mobile-specific table overflow handling */\n@media (max-width: 768px) {\n  .notion-table,\n  .notion-collection,\n  .notion-collection-table,\n  .notion-collection-table-wrapper {\n    max-width: calc(100vw - 2rem);\n    overflow-x: auto;\n    margin-left: 0;\n    margin-right: 0;\n  }\n\n  /* Simple tables on mobile - contained width with scroll */\n  .notion-simple-table {\n    max-width: calc(100vw - 2rem);\n    overflow-x: auto;\n  }\n\n  /* Ensure full-width tables on mobile have proper containment */\n  .notion-page-content-inner {\n    max-width: 100%;\n    overflow-x: hidden;\n  }\n\n  /* Prevent body/html from scrolling horizontally */\n  .notion-app,\n  .notion-frame {\n    max-width: 100vw;\n    overflow-x: hidden;\n  }\n\n  .notion-page {\n    max-width: 100vw;\n  }\n}\n\n/* Video blocks - match Notion's video layout */\n.notion-asset-wrapper video {\n  width: 100%;\n  max-width: 100%;\n  border-radius: 4px;\n  display: block;\n  background: #000;\n}\n\n/* Ensure video containers have proper aspect ratio handling */\n.notion-asset-wrapper:has(video) {\n  width: 100%;\n  max-width: 100%;\n}\n\n/* Video iframe embeds (YouTube, Vimeo, etc.) */\n.notion-asset-wrapper iframe {\n  border-radius: 4px;\n}\n\n/* YouTube lite embed styling */\n.notion-yt-lite {\n  border-radius: 4px;\n  overflow: hidden;\n}\n\n/* Ensure asset wrappers for videos take full width by default */\n.notion-asset-wrapper-video {\n  width: 100% !important;\n  max-width: 100%;\n}\n\n/* Video block specific layout - match Notion's page width behavior */\n.notion-video {\n  width: 100%;\n  max-width: 100%;\n}\n\n/* Embed blocks (external videos like Sieve, etc.) - ensure proper aspect ratio */\n.notion-asset-wrapper:has(iframe) {\n  width: 100%;\n}\n\n/* Fix for video embeds that don't have explicit dimensions */\n.notion-asset-wrapper > div[style*=\"padding-bottom\"] {\n  width: 100%;\n}\n\n/* Ensure external embed iframes fill their container */\n.notion-asset-wrapper iframe.notion-asset-object-fit {\n  width: 100%;\n  height: 100%;\n}\n\n/* Video wrapper default aspect ratio when not specified */\n.notion-asset-wrapper > div:has(> video):not([style*=\"padding-bottom\"]):not([style*=\"height\"]) {\n  aspect-ratio: 16 / 9;\n}\n\n/* Ensure videos without explicit sizing get proper dimensions */\n.notion-asset-wrapper video:not([style*=\"width\"]) {\n  width: 100%;\n}\n\n.notion-asset-wrapper video:not([style*=\"height\"]) {\n  height: auto;\n}\n\n/* \n * Fix for native video elements that don't have aspect ratio set by react-notion-x\n * react-notion-x removes paddingBottom for video blocks, so we need to ensure\n * videos maintain proper layout while loading\n */\n.notion-asset-wrapper > div:has(> video) {\n  min-height: 200px; /* Fallback minimum height while video loads */\n}\n\n/* Once video has loaded and has natural dimensions, allow it to use those */\n.notion-asset-wrapper video[src] {\n  min-height: unset;\n}\n\n/* Responsive video handling */\n@media (max-width: 768px) {\n  .notion-asset-wrapper video,\n  .notion-asset-wrapper iframe {\n    max-width: 100%;\n  }\n  \n  .notion-asset-wrapper:has(video),\n  .notion-asset-wrapper:has(iframe) {\n    max-width: calc(100vw - 2rem);\n  }\n}\n\n.notion-hr {\n  border-top: 1px solid var(--fg-color-0);\n}\n\n.notion-link {\n  display: inline-flex;\n  opacity: 1;\n  cursor: pointer;\n  border-bottom: none;\n  text-decoration: underline;\n  text-underline-offset: 4px;\n  text-decoration-thickness: 1px;\n  text-decoration-color: var(--fg-color-1);\n  &:hover {\n    background: #94949426;\n    border-radius: 1px;\n    box-shadow: 0 0 0 3px #94949426;\n    cursor: pointer !important;\n  }\n}\n\n.notion-link .notion-page-title-text {\n  border-bottom: 1px solid var(--fg-color-1);\n}\n\n.notion-link:has(.notion-page-title-text) {\n  border-bottom: none;\n  text-decoration: none;\n}\n\n/* Ensure the cover wrapper doesn't clip the icon */\n.notion-page-cover-wrapper {\n  overflow: visible !important;\n}\n\n/* Style page icon like actual Notion - overlapping cover, aligned with content */\n.notion-page-icon-hero {\n  margin-top: -70px !important;\n  margin-left: 0 !important;\n  left: 0 !important;\n  justify-content: flex-start !important;\n  transform: none !important;\n  overflow: visible !important;\n  position: relative;\n  z-index: 10;\n}\n\n.notion-page-icon-hero.notion-page-icon-image {\n  overflow: visible !important;\n}\n\n/* Only show arrows on page icons within clickable links */\n.notion-link .notion-page-icon-inline,\n.notion-link .notion-page-icon-span,\n.notion-page-link .notion-page-icon-inline,\n.notion-page-link .notion-page-icon-span {\n  position: relative;\n}\n\n.notion-link .notion-page-icon-inline::after,\n.notion-link .notion-page-icon-span::after,\n.notion-page-link .notion-page-icon-inline::after,\n.notion-page-link .notion-page-icon-span::after {\n  content: \"\";\n  position: absolute;\n  right: -0.2em;\n  bottom: 0;\n  width: 0.7em;\n  height: 0.7em;\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 13 13' fill='none'%3E%3Cpath d='M6.30826 4.43292L1.76184 8.98454C1.76176 8.98462 1.76169 8.9847 1.76161 8.98477C1.76158 8.9848 1.76156 8.98482 1.76154 8.98484C1.46068 9.28584 1.25 9.6914 1.25 10.1565C1.25 10.6117 1.45865 11.0119 1.73417 11.2886C2.01014 11.5658 2.41107 11.7773 2.87078 11.7773C3.34169 11.7773 3.73758 11.5617 4.03477 11.2733L4.03482 11.2734L4.04244 11.2657L8.58864 6.72474V8.667C8.58864 9.51956 9.22729 10.2935 10.1521 10.2935C11.0528 10.2935 11.75 9.54534 11.75 8.66127V2.92671C11.75 2.48722 11.5981 2.06381 11.2838 1.74808C10.9689 1.43182 10.5446 1.27728 10.1006 1.27728H4.36028C3.46161 1.27728 2.72804 1.97749 2.72804 2.86942C2.72804 3.79734 3.51104 4.43292 4.35455 4.43292H6.30826Z' fill='%233E3C38' stroke='white' stroke-width='1.5'/%3E%3C/svg%3E\");\n  background-repeat: no-repeat;\n  background-size: contain;\n}\n\n.notion-toggle:has(.notion-h1) {\n  margin-top: 2rem;\n}\n\n/* Fix column layout to be evenly spaced */\n.notion-row .notion-column {\n  flex: 1 1 0;\n  min-width: 0;\n}\n\n/* Ensure assets in columns take full width of their column */\n.notion-column .notion-asset-wrapper {\n  width: 100% !important;\n  max-width: 100%;\n}\n\n/* Fix video sizing within columns */\n.notion-column .notion-asset-wrapper video {\n  width: 100%;\n  height: auto;\n}\n\n/* Fix iframe sizing within columns */\n.notion-column .notion-asset-wrapper iframe {\n  width: 100%;\n}\n\n/* Ensure the inner div of asset wrapper in columns expands properly */\n.notion-column .notion-asset-wrapper > div {\n  width: 100% !important;\n}\n\n/* Images in columns should also take full width */\n.notion-column .notion-asset-wrapper img {\n  width: 100%;\n  height: auto;\n  object-fit: contain;\n}\n\n/* Ensure notion-row displays as proper flexbox */\n.notion-row {\n  display: flex !important;\n  flex-wrap: nowrap;\n  width: 100%;\n  gap: 0;\n}\n\n/* Responsive: stack columns on small screens */\n@media (max-width: 640px) {\n  .notion-row {\n    flex-wrap: wrap;\n    gap: 1rem;\n  }\n\n  .notion-row > .notion-column {\n    width: 100% !important;\n    flex: 1 1 100% !important;\n  }\n}\n\n/* Override any inline width styles on asset wrappers in columns */\n.notion-column .notion-asset-wrapper[style*=\"width\"] {\n  width: 100% !important;\n}\n\n/* Handle asset wrapper inner positioning div */\n.notion-column .notion-asset-wrapper > div[style] {\n  width: 100% !important;\n  max-width: 100% !important;\n}\n\n/* Ensure lazy image wrappers in columns expand properly */\n.notion-column .lazy-image-wrapper {\n  width: 100% !important;\n}\n"
  },
  {
    "path": "styles/custom-viewer-styles.css",
    "content": ".viewer-container img {\n  /* Prevent long-press context menu on mobile */\n  -webkit-touch-callout: none;\n  -webkit-user-select: none;\n  -moz-user-select: none;\n  -ms-user-select: none;\n  user-select: none;\n\n  /* Prevent image highlighting */\n  -webkit-tap-highlight-color: transparent;\n\n  /* Disable pointer events for saving */\n  pointer-events: auto;\n}\n\n.viewer-image-mobile {\n  -webkit-user-drag: none !important;\n  -webkit-user-select: none !important;\n  -moz-user-select: none !important;\n  -ms-user-select: none !important;\n  user-select: none !important;\n  -webkit-touch-callout: none !important;\n  -webkit-tap-highlight-color: transparent !important;\n  pointer-events: auto !important;\n}\n"
  },
  {
    "path": "styles/globals.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer utilities {\n  @keyframes fade-in {\n    from {\n      opacity: 0;\n    }\n    to {\n      opacity: 1;\n    }\n  }\n\n  .animate-fade-in {\n    animation: fade-in 0.5s ease-in-out;\n  }\n}\n\n@layer base {\n  :root {\n    --background: 0 0% 100%; /* white */\n    --foreground: 224 71.4% 4.1%; /* gray-950 */\n\n    --muted: 220 14.3% 95.9%; /* gray-100 */\n    --muted-foreground: 220 8.9% 46.1%; /* gray-500 */\n\n    --popover: 0 0% 100%; /* white */\n    --popover-foreground: 224 71.4% 4.1%; /* gray-950 */\n\n    --card: 0 0% 100%; /* white */\n    --card-foreground: 224 71.4% 4.1%; /* gray-950 */\n\n    --border: 220 13% 91%; /* gray-200 */\n    --input: 216 12.2% 83.9%; /* gray-300 */\n\n    --primary: 220.9 39.3% 11%; /* gray-900 */\n    --primary-foreground: 210 20% 98%; /* gray-50 */\n\n    --secondary: 220 14.3% 95.9%; /* gray-100 */\n    --secondary-foreground: 220.9 39.3% 11%; /* gray-900 */\n\n    --accent: 220 14.3% 95.9%; /* gray-100 */\n    --accent-foreground: 220.9 39.3% 11%; /* gray-900 */\n\n    --destructive: 0 84.2% 60.2%; /* red-500 */\n    --destructive-foreground: 210 20% 98%; /* gray-50 */\n\n    --warning: 38 92% 50%; /* amber-500 */\n    --warning-foreground: 210 20% 98%; /* gray-50 */\n\n    --ring: 217.9 10.6% 64.9%; /* gray-400 */\n\n    --radius: 0.5rem; /* md */\n\n    --sidebar-background: 0 0% 98%; /* white */\n    --sidebar-foreground: 240 5.3% 26.1%; /* gray-900 */\n    --sidebar-primary: 240 5.9% 10%; /* gray-950 */\n    --sidebar-primary-foreground: 0 0% 98%; /* white */\n    --sidebar-accent: 240 4.8% 95.9%; /* gray-100 */\n    --sidebar-accent-foreground: 240 5.9% 10%; /* gray-950 */\n    --sidebar-border: 220 13% 91%; /* gray-200 */\n    --sidebar-ring: 217.2 91.2% 59.8%; /* gray-400 */\n  }\n\n  .dark {\n    --background: 224 71.4% 4.1%; /* gray-950 */\n    --foreground: 210 20% 98%; /* gray-50 */\n\n    --muted: 215 27.9% 16.9%; /* gray-800 */\n    --muted-foreground: 217.9 10.6% 64.9%; /* gray-400 */\n\n    --popover: 224 71.4% 4.1%; /* gray-950 */\n    --popover-foreground: 210 20% 98%; /* gray-50 */\n\n    --card: 224 71.4% 4.1%; /* gray-950 */\n    --card-foreground: 210 20% 98%; /* gray-50 */\n\n    --border: 215 27.9% 16.9%; /* gray-800 */\n    --input: 216.9 19.1% 26.7%; /* gray-700 */\n\n    --primary: 210 20% 98%; /* gray-50 */\n    --primary-foreground: 220.9 39.3% 11%; /* gray-900 */\n\n    --secondary: 215 27.9% 16.9%; /* gray-800 */\n    --secondary-foreground: 210 20% 98%; /* gray-50 */\n\n    --accent: 215 27.9% 16.9%; /* gray-800 */\n    --accent-foreground: 210 20% 98%; /* gray-50 */\n\n    --destructive: 0 62.8% 30.6%; /* red-900 */\n    --destructive-foreground: 0 85.7% 97.3%; /* red-50 */\n\n    --warning: 38 92% 30%; /* amber-800 */\n    --warning-foreground: 38 92% 95%; /* amber-50 */\n\n    --ring: 215 27.9% 16.9%; /* gray-800 */\n\n    --sidebar-background: 240 5.9% 10%; /* gray-950 */\n    --sidebar-foreground: 240 4.8% 95.9%; /* gray-100 */\n    --sidebar-primary: 224.3 76.3% 48%; /* blue-500 */\n    --sidebar-primary-foreground: 0 0% 100%; /* white */\n    --sidebar-accent: 240 3.7% 15.9%; /* gray-200 */\n    --sidebar-accent-foreground: 240 4.8% 95.9%; /* gray-950 */\n    --sidebar-border: 240 3.7% 15.9%; /* gray-200 */\n    --sidebar-ring: 217.2 91.2% 59.8%; /* gray-400 */\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n@layer utilities {\n  .touch-action-manipulation {\n    touch-action: manipulation;\n  }\n\n  .touch-action-pinch-zoom {\n    touch-action: pinch-zoom;\n  }\n\n  .transform-container {\n    transform-origin: 0 0;\n    will-change: transform;\n  }\n\n  .touch-zoom-container {\n    touch-action: none; /* Disable all browser handling */\n    -webkit-overflow-scrolling: touch;\n    isolation: isolate; /* Create a new stacking context */\n    contain: paint; /* Optimize rendering */\n  }\n}\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nimport scrollbarHide from \"tailwind-scrollbar-hide\";\n\nmodule.exports = {\n  darkMode: [\"class\"],\n  content: [\n    \"./pages/**/*.{ts,tsx}\",\n    \"./components/**/*.{ts,tsx}\",\n    \"./app/**/*.{ts,tsx}\",\n    \"./src/**/*.{ts,tsx}\",\n    \"./node_modules/@tremor/**/*.{js,ts,jsx,tsx}\", // TREMOR\n    \"./ee/**/*.{ts,tsx}\",\n  ],\n  theme: {\n    container: {\n      center: \"true\",\n      padding: \"2rem\",\n      screens: {\n        \"2xl\": \"1400px\",\n      },\n    },\n    extend: {\n      colors: {\n        border: \"hsl(var(--border))\",\n        input: \"hsl(var(--input))\",\n        ring: \"hsl(var(--ring))\",\n        background: \"hsl(var(--background))\",\n        foreground: \"hsl(var(--foreground))\",\n        primary: {\n          DEFAULT: \"hsl(var(--primary))\",\n          foreground: \"hsl(var(--primary-foreground))\",\n        },\n        secondary: {\n          DEFAULT: \"hsl(var(--secondary))\",\n          foreground: \"hsl(var(--secondary-foreground))\",\n        },\n        destructive: {\n          DEFAULT: \"hsl(var(--destructive))\",\n          foreground: \"hsl(var(--destructive-foreground))\",\n        },\n        warning: {\n          DEFAULT: \"hsl(var(--warning))\",\n          foreground: \"hsl(var(--warning-foreground))\",\n        },\n        muted: {\n          DEFAULT: \"hsl(var(--muted))\",\n          foreground: \"hsl(var(--muted-foreground))\",\n        },\n        accent: {\n          DEFAULT: \"hsl(var(--accent))\",\n          foreground: \"hsl(var(--accent-foreground))\",\n        },\n        popover: {\n          DEFAULT: \"hsl(var(--popover))\",\n          foreground: \"hsl(var(--popover-foreground))\",\n        },\n        card: {\n          DEFAULT: \"hsl(var(--card))\",\n          foreground: \"hsl(var(--card-foreground))\",\n        },\n        tremor: {\n          brand: {\n            faint: \"#eff6ff\",\n            muted: \"#bfdbfe\",\n            subtle: \"#60a5fa\",\n            DEFAULT: \"#3b82f6\",\n            emphasis: \"#1d4ed8\",\n            inverted: \"#ffffff\",\n          },\n          background: {\n            muted: \"#f9fafb\",\n            subtle: \"#f3f4f6\",\n            DEFAULT: \"#ffffff\",\n            emphasis: \"#374151\",\n          },\n          border: {\n            DEFAULT: \"#e5e7eb\",\n          },\n          ring: {\n            DEFAULT: \"#e5e7eb\",\n          },\n          content: {\n            subtle: \"#9ca3af\",\n            DEFAULT: \"#6b7280\",\n            emphasis: \"#374151\",\n            strong: \"#111827\",\n            inverted: \"#ffffff\",\n          },\n        },\n        \"dark-tremor\": {\n          brand: {\n            faint: \"#0B1229\",\n            muted: \"#172554\",\n            subtle: \"#1e40af\",\n            DEFAULT: \"#3b82f6\",\n            emphasis: \"#60a5fa\",\n            inverted: \"#030712\",\n          },\n          background: {\n            muted: \"#131A2B\",\n            subtle: \"#1f2937\",\n            DEFAULT: \"#111827\",\n            emphasis: \"#d1d5db\",\n          },\n          border: {\n            DEFAULT: \"#1f2937\",\n          },\n          ring: {\n            DEFAULT: \"#1f2937\",\n          },\n          content: {\n            subtle: \"#4b5563\",\n            DEFAULT: \"#6b7280\",\n            emphasis: \"#e5e7eb\",\n            strong: \"#f9fafb\",\n            inverted: \"#000000\",\n          },\n        },\n        sidebar: {\n          DEFAULT: \"hsl(var(--sidebar-background))\",\n          foreground: \"hsl(var(--sidebar-foreground))\",\n          primary: \"hsl(var(--sidebar-primary))\",\n          \"primary-foreground\": \"hsl(var(--sidebar-primary-foreground))\",\n          accent: \"hsl(var(--sidebar-accent))\",\n          \"accent-foreground\": \"hsl(var(--sidebar-accent-foreground))\",\n          border: \"hsl(var(--sidebar-border))\",\n          ring: \"hsl(var(--sidebar-ring))\",\n        },\n      },\n      boxShadow: {\n        \"tremor-input\": \"0 1px 2px 0 rgb(0 0 0 / 0.05)\",\n        \"tremor-card\":\n          \"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)\",\n        \"tremor-dropdown\":\n          \"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)\",\n        \"dark-tremor-input\": \"0 1px 2px 0 rgb(0 0 0 / 0.05)\",\n        \"dark-tremor-card\":\n          \"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)\",\n        \"dark-tremor-dropdown\":\n          \"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)\",\n      },\n      borderRadius: {\n        \"tremor-small\": \"0.375rem\",\n        \"tremor-default\": \"0.5rem\",\n        \"tremor-full\": \"9999px\",\n        lg: \"var(--radius)\",\n        md: \"calc(var(--radius) - 2px)\",\n        sm: \"calc(var(--radius) - 4px)\",\n      },\n      fontSize: {\n        \"tremor-label\": [\"0.75rem\"],\n        \"tremor-default\": [\"0.875rem\", { lineHeight: \"1.25rem\" }],\n        \"tremor-title\": [\"1.125rem\", { lineHeight: \"1.75rem\" }],\n        \"tremor-metric\": [\"1.875rem\", { lineHeight: \"2.25rem\" }],\n      },\n      keyframes: {\n        \"scale-in\": {\n          \"0%\": {\n            transform: \"scale(0.95)\",\n          },\n          \"100%\": {\n            transform: \"scale(1)\",\n          },\n        },\n        \"fade-in\": {\n          \"0%\": {\n            opacity: \"0\",\n          },\n          \"100%\": {\n            opacity: \"1\",\n          },\n        },\n        gauge_fadeIn: {\n          from: {\n            opacity: \"0\",\n          },\n          to: {\n            opacity: \"1\",\n          },\n        },\n        gauge_fill: {\n          from: {\n            \"stroke-dashoffset\": \"332\",\n            opacity: \"0\",\n          },\n          to: {\n            opacity: \"1\",\n          },\n        },\n        flyEmoji: {\n          \"0%\": {\n            transform: \"translateY(0) scale(1)\",\n            opacity: \"0.7\",\n          },\n          \"100%\": {\n            transform: \"translateY(-150px) scale(2)\",\n            opacity: \"0\",\n          },\n        },\n        \"accordion-down\": {\n          from: {\n            height: \"0\",\n          },\n          to: {\n            height: \"var(--radix-accordion-content-height)\",\n          },\n        },\n        \"accordion-up\": {\n          from: {\n            height: \"var(--radix-accordion-content-height)\",\n          },\n          to: {\n            height: \"0\",\n          },\n        },\n        \"collapsible-down\": {\n          from: {\n            height: \"0\",\n            opacity: \"0\",\n          },\n          to: {\n            height: \"var(--radix-collapsible-content-height)\",\n            opacity: \"1\",\n          },\n        },\n        \"collapsible-up\": {\n          from: {\n            height: \"var(--radix-collapsible-content-height)\",\n            opacity: \"1\",\n          },\n          to: {\n            height: \"0\",\n            opacity: \"0\",\n          },\n        },\n        \"caret-blink\": {\n          \"0%,70%,100%\": {\n            opacity: \"1\",\n          },\n          \"20%,50%\": {\n            opacity: \"0\",\n          },\n        },\n      },\n      gridTemplateColumns: {\n        16: \"repeat(16, minmax(0, 1fr))\",\n      },\n      animation: {\n        \"scale-in\": \"scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1)\",\n        \"fade-in\": \"fade-in 0.2s ease-out forwards\",\n        gauge_fadeIn: \"gauge_fadeIn 1s ease forwards\",\n        gauge_fill: \"gauge_fill 1s ease forwards\",\n        flyEmoji: \"flyEmoji 1s forwards\",\n        \"accordion-down\": \"accordion-down 0.2s ease-out\",\n        \"accordion-up\": \"accordion-up 0.2s ease-out\",\n        \"collapsible-down\": \"collapsible-down 0.2s ease-out\",\n        \"collapsible-up\": \"collapsible-up 0.2s ease-out\",\n        \"caret-blink\": \"caret-blink 1.25s ease-out infinite\",\n      },\n    },\n  },\n  //**  START: TREMOR safelist **//\n  safelist: [\n    {\n      pattern:\n        /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,\n      variants: [\"hover\", \"ui-selected\"],\n    },\n    {\n      pattern:\n        /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,\n      variants: [\"hover\", \"ui-selected\"],\n    },\n    {\n      pattern:\n        /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,\n      variants: [\"hover\", \"ui-selected\"],\n    },\n    {\n      pattern:\n        /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,\n    },\n    {\n      pattern:\n        /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,\n    },\n    {\n      pattern:\n        /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,\n    },\n  ],\n  //**  END: TREMOR safelist **//\n  plugins: [\n    require(\"@tailwindcss/forms\"),\n    require(\"tailwindcss-animate\"),\n    require(\"@tailwindcss/typography\"),\n    scrollbarHide,\n  ],\n};\n"
  },
  {
    "path": "trigger.config.ts",
    "content": "import { ffmpeg } from \"@trigger.dev/build/extensions/core\";\nimport { prismaExtension } from \"@trigger.dev/build/extensions/prisma\";\nimport { pythonExtension } from \"@trigger.dev/python/extension\";\nimport { defineConfig, timeout } from \"@trigger.dev/sdk/v3\";\n\nexport default defineConfig({\n  project: \"proj_plmsfqvqunboixacjjus\",\n  dirs: [\"./lib/trigger\", \"./ee/features/ai/lib/trigger\"],\n  maxDuration: timeout.None, // no max duration\n  retries: {\n    enabledInDev: false,\n    default: {\n      maxAttempts: 3,\n      minTimeoutInMs: 1000,\n      maxTimeoutInMs: 10000,\n      factor: 2,\n      randomize: true,\n    },\n  },\n  build: {\n    extensions: [\n      prismaExtension({\n        schema: \"prisma/schema/schema.prisma\",\n      }),\n      ffmpeg(),\n      pythonExtension({\n        scripts: [\"./**/*.py\"],\n      }),\n    ],\n  },\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"downlevelIteration\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"paths\": {\n      \"@/*\": [\"./*\"]\n    },\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ]\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \"trigger.config.ts\"\n  ],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "vercel.json",
    "content": "{\n  \"functions\": {\n    \"pages/api/mupdf/convert-page.ts\": {\n      \"memory\": 2048\n    },\n    \"pages/api/mupdf/annotate-document.ts\": {\n      \"memory\": 2048\n    }\n  }\n}\n"
  }
]